Harden M9: Ed25519 attestation, full SHA-256 integrity, locked-by-default, prompt improvements, registry verification

This commit is contained in:
rob 2026-07-20 16:50:49 -03:00
parent 04153a4bbd
commit 56cb34276e
34 changed files with 2549 additions and 257 deletions

View File

@ -123,6 +123,7 @@ Opens the graphical interface where you can create and manage tools visually. Fe
- **My Tools** - Browse, create, edit, and delete tools organized by category - **My Tools** - Browse, create, edit, and delete tools organized by category
- **Registry** - Search and install community tools from the CmdForge registry - **Registry** - Search and install community tools from the CmdForge registry
- **Providers** - Manage AI provider configurations - **Providers** - Manage AI provider configurations
- **Reuse & Discovery** - Compare similar tools and preview safe extraction of reusable steps
### CLI Mode ### CLI Mode
@ -178,10 +179,26 @@ cmdforge settings mytool diff # Show changes from defaults
cmdforge config show # Show current config cmdforge config show # Show current config
cmdforge config connect username # Connect to registry account cmdforge config connect username # Connect to registry account
cmdforge config disconnect # Disconnect from registry cmdforge config disconnect # Disconnect from registry
# Private, local pipeline suggestions (disabled by default)
cmdforge usage enable # Begin recording tool names in shell pipes
cmdforge usage suggestions # Show frequent pipelines
cmdforge usage clear # Delete all local usage history
# Model Context Protocol (optional: pip install -e ".[mcp]")
cmdforge mcp add local --command npx --arg=-y --arg @scope/server
cmdforge mcp add remote --transport streamable-http --url https://example.com/mcp
cmdforge mcp connect remote
cmdforge mcp serve # Expose approved tools over stdio
cmdforge mcp serve --transport streamable-http # Loopback-only HTTP by default
``` ```
`cf` searches the public registry when no local tool matches. Registry results `cf` searches the public registry when no local tool matches. Registry results
show available relevance and quality evidence and install on selection. show available relevance and quality evidence and install on selection.
When local pipeline discovery is explicitly enabled, `cf` also suggests
frequently repeated pipe pairs. Only tool names, anonymous pipe identifiers,
counts, and timestamps are kept in `~/.cmdforge/usage.json`; command arguments
and input/output content are never recorded or transmitted.
### Running Tools ### Running Tools
@ -664,6 +681,9 @@ cmdforge registry search --owner official --min-downloads 100
# Connect your account (opens browser for authentication) # Connect your account (opens browser for authentication)
cmdforge config connect yourusername cmdforge config connect yourusername
# Create an Ed25519 release key and register its public half
cmdforge registry signing-key init
# Publish a tool # Publish a tool
cmdforge registry publish mytool cmdforge registry publish mytool
@ -678,6 +698,51 @@ cmdforge registry status --sync
``` ```
Published tools go through moderation before appearing publicly. You'll receive feedback if changes are requested. Published tools go through moderation before appearing publicly. You'll receive feedback if changes are requested.
Once a signing key is registered, publishing signs the transitive content
identity automatically. The registry rejects unsigned or invalid releases and
clients verify the signature before installing. The private key remains in
`~/.cmdforge/release-signing-key.json` with `0600` permissions.
### Prompt Optimization
Generate local deterministic prompt variations without contacting a provider:
```bash
cmdforge optimize summarize -n 5
```
Structural contract checks cannot rank prompt meaning, so CmdForge will not
claim a winning variation unless explicit behavioral cases are supplied:
```json
[
{"input": "A long input", "contains": "summary"},
{"input": "Another input", "expected": {"result": "expected value"}}
]
```
```bash
cmdforge optimize summarize --behavior-tests behavior.json \
--test-provider opencode-pickle
```
Behavior tests execute every candidate and may incur provider cost. Use
`--provider NAME` separately when you explicitly want a provider to generate a
rephrased candidate.
### Community Improvements
```bash
cmdforge registry improve owner/tool 1.0.0 0 proposed-prompt.txt \
--rationale "Clearer output requirement"
cmdforge registry review-improvement 42 approve --notes "Tests pass"
# Apply the approved text, bump the tool version, then publish it:
cmdforge registry publish mytool --improvement-id 42
```
Submissions are auto-tested before review. Contributor credit and the
`optimized`/`community-reviewed` badges are awarded only when a published
version actually contains the approved change.
### Tool Documentation ### Tool Documentation
@ -750,6 +815,10 @@ The graphical interface provides a modern desktop experience:
### Tool Builder ### Tool Builder
- Visual form for creating and editing tools - Visual form for creating and editing tools
- Reuse & Discovery panel with asynchronous registry similarity results,
exact local reuse evidence, and opt-in pipeline suggestions
- Extraction creates a separate tool only after showing a unified diff; the
current draft is never rewritten automatically
- Add arguments with flags and default values - Add arguments with flags and default values
- Add prompt steps (AI calls) with profile selection - Add prompt steps (AI calls) with profile selection
- Add code steps with **AI-assisted code generation**: - Add code steps with **AI-assisted code generation**:

View File

@ -36,6 +36,7 @@ dependencies = [
"NodeGraphQt>=0.6.0", "NodeGraphQt>=0.6.0",
"setuptools", # Required for distutils compatibility (Python 3.12+) "setuptools", # Required for distutils compatibility (Python 3.12+)
"jsonschema>=4.0", # JSON schema validation for structured output "jsonschema>=4.0", # JSON schema validation for structured output
"cryptography>=41.0", # Ed25519 release attestations
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@ -1,28 +1,52 @@
"""Supply chain attestation (M9.5). """Ed25519 supply-chain attestations for CmdForge releases."""
Tool publishers sign releases. Registry verifies signatures before from __future__ import annotations
accepting publish. Clients verify signatures before installing.
"""
import hashlib import base64
import hmac
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Optional from typing import Dict, Optional, Tuple
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
@dataclass def _canonical_payload(
tool_name: str,
version: str,
content_hash: str,
signer: str,
signed_at: str,
algorithm: str,
) -> bytes:
return json.dumps(
{
"algorithm": algorithm,
"content_hash": content_hash,
"signed_at": signed_at,
"signer": signer,
"tool_name": tool_name,
"version": version,
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
@dataclass(frozen=True)
class Attestation: class Attestation:
"""A signed attestation for a tool release."""
tool_name: str tool_name: str
version: str version: str
content_hash: str content_hash: str
signer: str # publisher identity signer: str
signature: str signature: str
signed_at: str = "" signed_at: str
algorithm: str = "hmac-sha256" algorithm: str = "ed25519"
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@ -35,76 +59,89 @@ class Attestation:
"algorithm": self.algorithm, "algorithm": self.algorithm,
} }
@classmethod
def from_dict(cls, value: dict) -> "Attestation":
return cls(**{key: value[key] for key in (
"tool_name", "version", "content_hash", "signer", "signature",
"signed_at", "algorithm",
)})
def generate_keypair() -> Tuple[str, str]:
"""Generate base64-encoded raw Ed25519 private/public keys."""
private = Ed25519PrivateKey.generate()
private_bytes = private.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
public_bytes = private.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
return (
base64.b64encode(private_bytes).decode("ascii"),
base64.b64encode(public_bytes).decode("ascii"),
)
def sign_tool( def sign_tool(
tool_name: str, tool_name: str,
version: str, version: str,
content_hash: str, content_hash: str,
signer: str, signer: str,
secret_key: str, private_key: str,
*,
signed_at: Optional[str] = None,
) -> Attestation: ) -> Attestation:
"""Sign a tool release with HMAC-SHA256. """Sign release identity using a publisher's private Ed25519 key."""
timestamp = signed_at or datetime.now(timezone.utc).isoformat()
Args: algorithm = "ed25519"
tool_name: Tool name payload = _canonical_payload(
version: Tool version tool_name, version, content_hash, signer, timestamp, algorithm
content_hash: Content hash from integrity module )
signer: Publisher identity (username) key = Ed25519PrivateKey.from_private_bytes(base64.b64decode(private_key))
secret_key: Signing key (from config or keyring) signature = base64.b64encode(key.sign(payload)).decode("ascii")
Returns:
Attestation with signature
"""
payload = f"{tool_name}:{version}:{content_hash}:{signer}"
signature = hmac.new(
secret_key.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
return Attestation( return Attestation(
tool_name=tool_name, tool_name, version, content_hash, signer, signature, timestamp, algorithm
version=version,
content_hash=content_hash,
signer=signer,
signature=signature,
signed_at=datetime.now(timezone.utc).isoformat(),
) )
def verify_attestation(attestation: Attestation, secret_key: str) -> bool: def verify_attestation(attestation: Attestation, public_key: str) -> bool:
"""Verify a tool attestation signature. """Verify every signed field using the publisher's trusted public key."""
if attestation.algorithm != "ed25519":
return False
try:
key = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key))
signature = base64.b64decode(attestation.signature, validate=True)
payload = _canonical_payload(
attestation.tool_name,
attestation.version,
attestation.content_hash,
attestation.signer,
attestation.signed_at,
attestation.algorithm,
)
key.verify(signature, payload)
return True
except (ValueError, TypeError, InvalidSignature):
return False
Args:
attestation: The attestation to verify
secret_key: The signing key to verify against
Returns: def verify_trusted_attestation(
True if signature is valid attestation: Attestation, trusted_publishers: Dict[str, str]
""" ) -> bool:
payload = f"{attestation.tool_name}:{attestation.version}:{attestation.content_hash}:{attestation.signer}" """Anchor an attestation to an explicitly trusted publisher key."""
expected = hmac.new( public_key = trusted_publishers.get(attestation.signer)
secret_key.encode(), return bool(public_key) and verify_attestation(attestation, public_key)
payload.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, attestation.signature)
def verify_content_hash(content_hash: str, tool_dict: dict) -> bool: def verify_content_hash(content_hash: str, tool_dict: dict) -> bool:
"""Verify that a content hash matches the tool definition. """Verify a definition hash against a serialized Tool definition."""
Args:
content_hash: The hash to verify
tool_dict: Tool dictionary (from to_dict())
Returns:
True if hash matches
"""
from .integrity import compute_tool_hash from .integrity import compute_tool_hash
# Reconstruct what the hash should be from .tool import Tool
tool_dict_copy = dict(tool_dict)
tool_dict_copy.pop("path", None) try:
content = json.dumps(tool_dict_copy, sort_keys=True) return compute_tool_hash(Tool.from_dict(tool_dict)) == content_hash
expected = hashlib.sha256(content.encode()).hexdigest()[:16] except (KeyError, TypeError, ValueError):
return expected == content_hash return False

View File

@ -17,6 +17,7 @@ from .config_commands import cmd_config
from .settings_commands import cmd_settings from .settings_commands import cmd_settings
from .system_deps_commands import cmd_system_deps from .system_deps_commands import cmd_system_deps
from .mcp_commands import cmd_mcp from .mcp_commands import cmd_mcp
from .usage_commands import cmd_usage
def main(): def main():
@ -107,6 +108,17 @@ def main():
p_optimize = subparsers.add_parser("optimize", help="Generate and test prompt variations for a tool") p_optimize = subparsers.add_parser("optimize", help="Generate and test prompt variations for a tool")
p_optimize.add_argument("name", help="Tool name") p_optimize.add_argument("name", help="Tool name")
p_optimize.add_argument("-n", "--count", type=int, default=5, help="Number of variations") p_optimize.add_argument("-n", "--count", type=int, default=5, help="Number of variations")
p_optimize.add_argument(
"--provider", default="mock",
help="Provider used to generate a variation (default: local deterministic mock)",
)
p_optimize.add_argument(
"--behavior-tests", metavar="FILE",
help="JSON cases used to score semantic behavior (executes the tool)",
)
p_optimize.add_argument(
"--test-provider", help="Provider override used while running behavior tests",
)
p_optimize.set_defaults(func=cmd_optimize) p_optimize.set_defaults(func=cmd_optimize)
# 'check' command # 'check' command
@ -229,8 +241,40 @@ def main():
p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing") p_reg_publish.add_argument("--dry-run", action="store_true", help="Validate without publishing")
p_reg_publish.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompts") p_reg_publish.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompts")
p_reg_publish.add_argument("--owner", default="", help="Owner override (admin only, e.g. 'official')") p_reg_publish.add_argument("--owner", default="", help="Owner override (admin only, e.g. 'official')")
p_reg_publish.add_argument(
"--improvement-id", type=int,
help="Credit an approved improvement contained in this version",
)
p_reg_publish.set_defaults(func=cmd_registry) p_reg_publish.set_defaults(func=cmd_registry)
p_reg_signing = registry_sub.add_parser(
"signing-key", help="Initialize or inspect the release signing key"
)
p_reg_signing.add_argument(
"action", nargs="?", choices=["init", "status"], default="status"
)
p_reg_signing.set_defaults(func=cmd_registry)
p_reg_improve = registry_sub.add_parser(
"improve", help="Submit an auto-tested prompt/code improvement"
)
p_reg_improve.add_argument("tool", help="Tool as owner/name")
p_reg_improve.add_argument("version", help="Exact tool version")
p_reg_improve.add_argument("step_index", type=int, help="Step to replace")
p_reg_improve.add_argument("file", help="File containing proposed prompt/code")
p_reg_improve.add_argument("--rationale", default="")
p_reg_improve.set_defaults(func=cmd_registry)
p_reg_review_improvement = registry_sub.add_parser(
"review-improvement", help="Review a tested community improvement"
)
p_reg_review_improvement.add_argument("id", type=int)
p_reg_review_improvement.add_argument(
"decision", choices=["approve", "reject", "request_changes"]
)
p_reg_review_improvement.add_argument("--notes", default="")
p_reg_review_improvement.set_defaults(func=cmd_registry)
# registry update-readme # registry update-readme
p_reg_update_readme = registry_sub.add_parser("update-readme", help="Update README for a published tool") p_reg_update_readme = registry_sub.add_parser("update-readme", help="Update README for a published tool")
p_reg_update_readme.add_argument("tool", nargs="?", default="", help="Tool name (local name, will resolve owner)") p_reg_update_readme.add_argument("tool", nargs="?", default="", help="Tool name (local name, will resolve owner)")
@ -477,8 +521,21 @@ def main():
# mcp serve # mcp serve
p_mcp_serve = mcp_sub.add_parser("serve", help="Start CmdForge as an MCP server") p_mcp_serve = mcp_sub.add_parser("serve", help="Start CmdForge as an MCP server")
p_mcp_serve.add_argument( p_mcp_serve.add_argument(
"--transport", choices=["stdio"], default="stdio", "--transport", choices=["stdio", "streamable-http"], default="stdio",
help="Transport (M7.3 supports stdio)", help="MCP server transport",
)
p_mcp_serve.add_argument("--host", default="127.0.0.1")
p_mcp_serve.add_argument("--port", type=int, default=8000)
p_mcp_serve.add_argument(
"--allowed-origin", action="append", default=[],
help="Allowed browser Origin; repeat as needed",
)
p_mcp_serve.add_argument(
"--auth-token", help="Bearer token (or ${ENV_NAME})",
)
p_mcp_serve.add_argument(
"--external-url",
help="Public HTTPS URL when binding beyond localhost (TLS proxy required)",
) )
p_mcp_serve.set_defaults(func=cmd_mcp) p_mcp_serve.set_defaults(func=cmd_mcp)
@ -495,10 +552,15 @@ def main():
p_mcp_add = mcp_sub.add_parser("add", help="Add an MCP server") p_mcp_add = mcp_sub.add_parser("add", help="Add an MCP server")
p_mcp_add.add_argument("name", help="Server name") p_mcp_add.add_argument("name", help="Server name")
p_mcp_add.add_argument( p_mcp_add.add_argument(
"--transport", choices=["stdio"], default="stdio", "--transport", choices=["stdio", "streamable-http"], default="stdio",
help="Transport type (M7.2 supports stdio)", help="Transport type",
) )
p_mcp_add.add_argument("--command", help="Executable for stdio servers") p_mcp_add.add_argument("--command", help="Executable for stdio servers")
p_mcp_add.add_argument("--url", help="Endpoint for Streamable HTTP servers")
p_mcp_add.add_argument(
"--header", action="append", default=[], metavar="NAME=VALUE",
help="HTTP header; repeat as needed (supports ${NAME} references)",
)
p_mcp_add.add_argument( p_mcp_add.add_argument(
"--arg", dest="server_args", action="append", default=[], metavar="VALUE", "--arg", dest="server_args", action="append", default=[], metavar="VALUE",
help="Command argument; repeat for each argument (use --arg=-y for leading dashes)", help="Command argument; repeat for each argument (use --arg=-y for leading dashes)",
@ -525,6 +587,20 @@ def main():
# Default for mcp with no subcommand (list) # Default for mcp with no subcommand (list)
p_mcp.set_defaults(func=cmd_mcp, mcp_cmd="list") p_mcp.set_defaults(func=cmd_mcp, mcp_cmd="list")
p_usage = subparsers.add_parser(
"usage", help="Manage opt-in local pipeline discovery"
)
usage_sub = p_usage.add_subparsers(dest="usage_cmd")
for action in ("enable", "disable", "status", "clear"):
command = usage_sub.add_parser(action)
command.set_defaults(func=cmd_usage)
p_usage_suggestions = usage_sub.add_parser(
"suggestions", help="Show frequently composed tool pipelines"
)
p_usage_suggestions.add_argument("--threshold", type=int, default=3)
p_usage_suggestions.set_defaults(func=cmd_usage)
p_usage.set_defaults(func=cmd_usage, usage_cmd="status")
args = parser.parse_args() args = parser.parse_args()
# If no command, launch UI # If no command, launch UI
@ -682,7 +758,7 @@ def cmd_inspect(args):
def cmd_optimize(args): def cmd_optimize(args):
"""Generate and test prompt variations for a tool.""" """Generate and test prompt variations for a tool."""
from ..prompt_optimizer import optimize_tool from ..prompt_optimizer import load_behavioral_evaluator, optimize_tool
from ..tool import load_tool from ..tool import load_tool
tool = load_tool(args.name) tool = load_tool(args.name)
@ -693,7 +769,21 @@ def cmd_optimize(args):
print(f"Optimizing '{tool.name}' — generating {args.count} prompt variations...") print(f"Optimizing '{tool.name}' — generating {args.count} prompt variations...")
print() print()
result = optimize_tool(tool, count=args.count) if args.count < 1:
print("Error: --count must be at least 1.", file=sys.stderr)
return 2
evaluator = None
if args.behavior_tests:
try:
evaluator = load_behavioral_evaluator(
args.behavior_tests, args.test_provider
)
except (OSError, ValueError) as exc:
print(f"Error loading behavior tests: {exc}", file=sys.stderr)
return 2
result = optimize_tool(
tool, count=args.count, provider=args.provider, evaluator=evaluator
)
print(f"Baseline score: {result.baseline_score}") print(f"Baseline score: {result.baseline_score}")
print(f"Variations tested: {len(result.variations)}") print(f"Variations tested: {len(result.variations)}")
@ -708,7 +798,7 @@ def cmd_optimize(args):
print() print()
print("To apply this variation, edit the tool's config.yaml and update the prompt step.") print("To apply this variation, edit the tool's config.yaml and update the prompt step.")
else: else:
print("No improvements found. The original prompt may already be optimal.") print(result.note or "No improvements found.")
return 0 return 0

View File

@ -51,6 +51,8 @@ def _cmd_mcp_list(args):
print(f" Command: {_sanitize(command)}") print(f" Command: {_sanitize(command)}")
if s.cwd: if s.cwd:
print(f" Working dir: {s.cwd}") print(f" Working dir: {s.cwd}")
else:
print(f" URL: {_sanitize(s.url or '')}")
print(f" Approved: {'yes' if s.approved else 'no'}") print(f" Approved: {'yes' if s.approved else 'no'}")
print(f" Timeout: {s.timeout}s") print(f" Timeout: {s.timeout}s")
print() print()
@ -98,6 +100,7 @@ def _cmd_mcp_add(args):
try: try:
server_env = _parse_env_entries(getattr(args, "env", [])) server_env = _parse_env_entries(getattr(args, "env", []))
headers = _parse_header_entries(getattr(args, "header", []))
except ValueError as exc: except ValueError as exc:
print(f"Error: {exc}") print(f"Error: {exc}")
return 1 return 1
@ -111,6 +114,8 @@ def _cmd_mcp_add(args):
name=name, name=name,
transport=getattr(args, "transport", "stdio"), transport=getattr(args, "transport", "stdio"),
command=getattr(args, "command", None), command=getattr(args, "command", None),
url=getattr(args, "url", None),
headers=headers,
args=getattr(args, "server_args", []) or [], args=getattr(args, "server_args", []) or [],
cwd=getattr(args, "cwd", None), cwd=getattr(args, "cwd", None),
env=server_env, env=server_env,
@ -171,13 +176,34 @@ def _parse_env_entries(entries):
return result return result
def _parse_header_entries(entries):
result = {}
for entry in entries:
if "=" not in entry:
raise ValueError(f"invalid --header value '{entry}'; expected NAME=VALUE")
name, value = entry.split("=", 1)
if not name.strip() or any(char in name for char in "\r\n"):
raise ValueError(f"invalid HTTP header name '{name}'")
if any(char in value for char in "\r\n"):
raise ValueError(f"invalid HTTP header value for '{name}'")
result[name.strip()] = value
return result
def _cmd_mcp_serve(args): def _cmd_mcp_serve(args):
"""Start CmdForge as an MCP server.""" """Start CmdForge as an MCP server."""
from ..mcp_server import serve as start_server from ..mcp_server import serve as start_server
transport = getattr(args, "transport", "stdio") transport = getattr(args, "transport", "stdio")
try: try:
start_server(transport=transport) start_server(
transport=transport,
host=getattr(args, "host", "127.0.0.1"),
port=getattr(args, "port", 8000),
allowed_origins=getattr(args, "allowed_origin", []),
auth_token=getattr(args, "auth_token", None),
external_url=getattr(args, "external_url", None),
)
except ImportError as exc: except ImportError as exc:
print(f"Error: {exc}") print(f"Error: {exc}")
return 1 return 1

View File

@ -479,6 +479,19 @@ def main():
# Use stderr for UI if stdout is piped, so tool output stays clean # Use stderr for UI if stdout is piped, so tool output stays clean
_ui_out = sys.stderr if not sys.stdout.isatty() else sys.stdout _ui_out = sys.stderr if not sys.stdout.isatty() else sys.stdout
try:
from ..usage import get_suggestions
suggestions = get_suggestions()[:3]
if suggestions:
_write(f"{GREEN}Frequent pipelines you could save as tools:{RESET}\n")
for item in suggestions:
_write(
f" {' | '.join(item['tools'])} "
f"{DIM}({item['count']} uses){RESET}\n"
)
except (OSError, ValueError):
pass
try: try:
with TTYInput() as tty_input: with TTYInput() as tty_input:
result = run_picker(tty_input) result = run_picker(tty_input)

View File

@ -29,6 +29,12 @@ def cmd_registry(args):
return _cmd_registry_update(args) return _cmd_registry_update(args)
elif args.registry_cmd == "publish": elif args.registry_cmd == "publish":
return _cmd_registry_publish(args) return _cmd_registry_publish(args)
elif args.registry_cmd == "signing-key":
return _cmd_registry_signing_key(args)
elif args.registry_cmd == "improve":
return _cmd_registry_improve(args)
elif args.registry_cmd == "review-improvement":
return _cmd_registry_review_improvement(args)
elif args.registry_cmd == "update-readme": elif args.registry_cmd == "update-readme":
return _cmd_registry_update_readme(args) return _cmd_registry_update_readme(args)
elif args.registry_cmd == "my-tools": elif args.registry_cmd == "my-tools":
@ -51,6 +57,8 @@ def cmd_registry(args):
print(" info <tool> Show tool information") print(" info <tool> Show tool information")
print(" update Update local index cache") print(" update Update local index cache")
print(" publish [path] Publish a tool") print(" publish [path] Publish a tool")
print(" signing-key Manage Ed25519 release signing")
print(" improve Submit a community improvement")
print(" update-readme Update README for published tool(s)") print(" update-readme Update README for published tool(s)")
print(" my-tools List your published tools") print(" my-tools List your published tools")
print(" status <tool> Check moderation status of a tool") print(" status <tool> Check moderation status of a tool")
@ -598,6 +606,7 @@ def _cmd_registry_publish(args):
from ..tool import load_tool, Tool, ToolStep from ..tool import load_tool, Tool, ToolStep
dep_result = None dep_result = None
my_owner = ""
try: try:
client = get_client() client = get_client()
@ -737,6 +746,7 @@ def _cmd_registry_publish(args):
_print_quality_summary(preflight_result.get("quality")) _print_quality_summary(preflight_result.get("quality"))
if remote_report.get("errors"): if remote_report.get("errors"):
return 1 return 1
release_content_hash = preflight_result.get("content_hash", "")
if sys.stdin.isatty() and not getattr(args, "force", False): if sys.stdin.isatty() and not getattr(args, "force", False):
try: try:
if input("Publish this validated version? [y/N] ").strip().lower() != "y": if input("Publish this validated version? [y/N] ").strip().lower() != "y":
@ -751,7 +761,26 @@ def _cmd_registry_publish(args):
try: try:
client = get_client() client = get_client()
owner = getattr(args, "owner", "") owner = getattr(args, "owner", "")
result = client.publish_tool(config_yaml, readme, defaults, owner=owner) attestation = None
from ..signing import load_signing_key
signing_key = load_signing_key()
if signing_key:
if not release_content_hash:
print(
"Registry did not return a content identity; refusing to sign.",
file=sys.stderr,
)
return 1
from ..attestation import sign_tool
signer = owner or my_owner
attestation = sign_tool(
name, version, release_content_hash, signer, signing_key[0]
).to_dict()
result = client.publish_tool(
config_yaml, readme, defaults, owner=owner,
attestation=attestation,
improvement_id=getattr(args, "improvement_id", None),
)
pr_url = result.get("pr_url", "") pr_url = result.get("pr_url", "")
status = result.get("status", "") status = result.get("status", "")
@ -826,6 +855,64 @@ def _cmd_registry_publish(args):
return 0 return 0
def _cmd_registry_signing_key(args):
"""Initialize a local key and register its public half with the registry."""
from ..signing import SIGNING_KEY_FILE, initialize_signing_key, load_signing_key
from ..registry_client import RegistryError, get_client
if args.action == "status":
key = load_signing_key()
print(
f"Signing key: {SIGNING_KEY_FILE}"
if key else "No release signing key configured."
)
return 0 if key else 1
try:
_, public_key = initialize_signing_key()
get_client().set_signing_public_key(public_key)
except (OSError, ValueError, RegistryError) as exc:
print(f"Could not initialize signing key: {exc}", file=sys.stderr)
return 1
print(f"Release signing key initialized: {SIGNING_KEY_FILE}")
print("The private key is stored locally with mode 0600; back it up securely.")
return 0
def _cmd_registry_improve(args):
from ..registry_client import RegistryError, get_client
if "/" not in args.tool:
print("Tool must be specified as owner/name.", file=sys.stderr)
return 2
owner, name = args.tool.split("/", 1)
try:
proposed = Path(args.file).read_text(encoding="utf-8")
result = get_client().submit_improvement(
owner, name, args.version, args.step_index, proposed,
args.rationale,
)
except (OSError, RegistryError) as exc:
print(f"Could not submit improvement: {exc}", file=sys.stderr)
return 1
print(f"Improvement {result.get('id')} tested and submitted for review.")
return 0
def _cmd_registry_review_improvement(args):
from ..registry_client import RegistryError, get_client
try:
result = get_client().review_improvement(args.id, args.decision, args.notes)
except RegistryError as exc:
print(f"Could not review improvement: {exc}", file=sys.stderr)
return 1
print(f"Improvement {args.id}: {result.get('status')}")
if result.get("ready_to_apply"):
print(
"Publish the updated version with "
f"--improvement-id {args.id} to apply credit and badges."
)
return 0
def _cmd_registry_update_readme(args): def _cmd_registry_update_readme(args):
"""Update README for published tool(s) on the registry.""" """Update README for published tool(s) on the registry."""
from ..registry_client import RegistryError, get_client from ..registry_client import RegistryError, get_client

View File

@ -0,0 +1,29 @@
"""CLI for opt-in local usage discovery."""
from ..usage import clear_usage, get_suggestions, is_enabled, set_enabled
def cmd_usage(args):
action = getattr(args, "usage_cmd", None) or "status"
if action == "enable":
set_enabled(True)
print("Local pipeline discovery enabled. No input, output, arguments, or telemetry is recorded.")
return 0
if action == "disable":
set_enabled(False)
print("Local pipeline discovery disabled.")
return 0
if action == "clear":
clear_usage()
print("Local usage history cleared.")
return 0
if action == "suggestions":
suggestions = get_suggestions(getattr(args, "threshold", 3))
if not suggestions:
print("No frequent pipeline suggestions yet.")
return 0
for index, item in enumerate(suggestions, start=1):
print(f"{index}. {' | '.join(item['tools'])} ({item['count']} uses)")
return 0
print(f"Local pipeline discovery: {'enabled' if is_enabled() else 'disabled'}")
return 0

View File

@ -98,6 +98,9 @@ def run_submission_tests(submission: ImprovementSubmission, tool: Tool) -> dict:
from .contract_testing import run_contract_tests from .contract_testing import run_contract_tests
if submission.tool_name != tool.name or submission.tool_version != (tool.version or ""):
raise ValueError("Submission targets a different tool or version")
# Baseline # Baseline
baseline = run_contract_tests(tool) baseline = run_contract_tests(tool)
baseline_passed = sum( baseline_passed = sum(
@ -110,9 +113,27 @@ def run_submission_tests(submission: ImprovementSubmission, tool: Tool) -> dict:
return {"error": "Step index out of range", "baseline_passed": baseline_passed} return {"error": "Step index out of range", "baseline_passed": baseline_passed}
step = tool_copy.steps[submission.step_index] step = tool_copy.steps[submission.step_index]
current = step.prompt if isinstance(step, PromptStep) else (
step.code if isinstance(step, CodeStep) else None
)
if current != submission.original:
return {
"error": "Original step has changed since submission",
"baseline_passed": baseline_passed,
}
if isinstance(step, PromptStep) and submission.step_type == "prompt": if isinstance(step, PromptStep) and submission.step_type == "prompt":
step.prompt = submission.proposed step.prompt = submission.proposed
elif isinstance(step, CodeStep) and submission.step_type == "code": elif isinstance(step, CodeStep) and submission.step_type == "code":
try:
compile(submission.proposed, f"<{tool.name}:step-{submission.step_index}>", "exec")
except SyntaxError as exc:
result = {
"error": f"Proposed code has invalid syntax: {exc.msg}",
"baseline_passed": baseline_passed,
"passed_for_review": False,
}
submission.test_result = result
return result
step.code = submission.proposed step.code = submission.proposed
else: else:
return {"error": "Step type mismatch", "baseline_passed": baseline_passed} return {"error": "Step type mismatch", "baseline_passed": baseline_passed}
@ -128,6 +149,18 @@ def run_submission_tests(submission: ImprovementSubmission, tool: Tool) -> dict:
"proposed_passed": proposed_passed, "proposed_passed": proposed_passed,
"improvement": proposed_passed - baseline_passed, "improvement": proposed_passed - baseline_passed,
"regressed": proposed_passed < baseline_passed, "regressed": proposed_passed < baseline_passed,
"baseline_outcome": baseline.outcome,
"proposed_outcome": proposed_result.outcome,
"passed_for_review": (
(
proposed_result.outcome == "passed"
and proposed_passed >= baseline_passed
)
or (
submission.step_type == "code"
and proposed_result.outcome == "unsupported"
)
),
"details": [r.to_dict() for r in proposed_result.results], "details": [r.to_dict() for r in proposed_result.results],
} }
@ -146,10 +179,12 @@ def review_submission(
if decision not in ("approve", "reject", "request_changes"): if decision not in ("approve", "reject", "request_changes"):
raise ValueError("Decision must be: approve, reject, or request_changes") raise ValueError("Decision must be: approve, reject, or request_changes")
if submission.status == "pending": if submission.status != "tested" or not (
submission.test_result or {}
).get("passed_for_review"):
raise ValueError("Submission must be tested before review") raise ValueError("Submission must be tested before review")
submission.status = decision submission.status = "approved" if decision == "approve" else decision
return SubmissionReview( return SubmissionReview(
submission=submission, submission=submission,
decision=decision, decision=decision,

View File

@ -1,5 +1,9 @@
"""Tool builder page - create and edit tools.""" """Tool builder page - create and edit tools."""
import copy
import difflib
import re
import yaml import yaml
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -7,9 +11,9 @@ from PySide6.QtWidgets import (
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton, QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel, QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame, QStackedWidget, QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup QButtonGroup, QInputDialog
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QThread, Signal, QTimer
from ...tool import ( from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep, Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
@ -19,6 +23,84 @@ from ...tool import (
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
class SimilarToolsWorker(QThread):
"""Search the registry without blocking the tool builder."""
results_ready = Signal(str, list)
def __init__(self, query: str, parent=None):
super().__init__(parent)
self.query = query
def run(self):
try:
from ...registry_client import RegistryClient, RegistryError
try:
result = RegistryClient().search_tools(self.query, per_page=5)
except RegistryError:
result = None
self.results_ready.emit(self.query, list(result.data) if result else [])
except Exception:
# Guidance is advisory; an unavailable registry must never prevent
# local authoring.
self.results_ready.emit(self.query, [])
def build_extracted_tool(tool: Tool, opportunity: dict, name: str) -> Tool:
"""Build, but do not save, a reusable tool from an evidenced sequence."""
if opportunity.get("type") != "repeated_sequence":
raise ValueError("Only an internal repeated sequence can be extracted")
locations = opportunity.get("locations") or []
length = int(opportunity.get("length", 0))
if not locations or length < 2:
raise ValueError("Reuse evidence does not identify a valid sequence")
start = int(locations[0]) - 1
selected = tool.steps[start:start + length]
if len(selected) != length:
raise ValueError("Reuse evidence falls outside the current draft")
available = {"input", "settings"} | {
argument.variable for argument in tool.arguments
}
for step in selected:
if not isinstance(step, PromptStep):
raise ValueError("Only prompt-step sequences can be extracted safely")
references = set(re.findall(r"(?<!\{)\{([A-Za-z_]\w*)\}(?!\})", step.prompt or ""))
missing = references - available
if missing:
raise ValueError(
"The sequence depends on values produced outside its boundary: "
+ ", ".join(sorted(missing))
)
if step.output_var:
available.update(
value.strip() for value in step.output_var.split(",") if value.strip()
)
output_var = getattr(selected[-1], "output_var", "result") or "result"
output_schema = getattr(selected[-1], "output_schema", None)
return Tool(
name=name,
description=f"Extracted reusable sequence from {tool.name}",
category=tool.category,
arguments=copy.deepcopy(tool.arguments),
steps=copy.deepcopy(selected),
output=f"{{{output_var}}}",
dependencies=[],
system_dependencies=copy.deepcopy(tool.system_dependencies),
visibility=tool.visibility,
input_schema=copy.deepcopy(tool.input_schema),
output_schema=copy.deepcopy(output_schema),
)
def extraction_diff(tool: Tool) -> str:
"""Return a unified-diff preview for a proposed new tool."""
rendered = yaml.safe_dump(tool.to_dict(), sort_keys=False).splitlines(True)
return "".join(difflib.unified_diff(
[], rendered, fromfile="/dev/null", tofile=f"{tool.name}/config.yaml",
))
def _switch_to_existing_tool(main_window, name: str) -> None: def _switch_to_existing_tool(main_window, name: str) -> None:
"""Remove the creation page before opening an existing tool editor.""" """Remove the creation page before opening an existing tool editor."""
main_window.close_tool_builder() main_window.close_tool_builder()
@ -35,6 +117,7 @@ class ToolBuilderPage(QWidget):
self.original_name = tool_name self.original_name = tool_name
self._tool = None self._tool = None
self._flow_widget = None # Lazy-loaded self._flow_widget = None # Lazy-loaded
self._guidance_worker = None
self._setup_ui() self._setup_ui()
@ -104,6 +187,44 @@ class ToolBuilderPage(QWidget):
left_layout.addWidget(info_box) left_layout.addWidget(info_box)
# Reuse guidance is advisory and never changes a draft automatically.
guidance_box = QGroupBox("Reuse & Discovery")
guidance_layout = QVBoxLayout(guidance_box)
self.guidance_summary = QLabel(
"CmdForge can check for similar tools, reusable step sequences, "
"and frequent local pipelines."
)
self.guidance_summary.setWordWrap(True)
guidance_layout.addWidget(self.guidance_summary)
self.guidance_list = QListWidget()
self.guidance_list.setMaximumHeight(130)
self.guidance_list.currentItemChanged.connect(
self._guidance_selection_changed
)
guidance_layout.addWidget(self.guidance_list)
guidance_buttons = QHBoxLayout()
self.btn_refresh_guidance = QPushButton("Refresh")
self.btn_refresh_guidance.clicked.connect(self._refresh_guidance)
guidance_buttons.addWidget(self.btn_refresh_guidance)
self.btn_open_guidance = QPushButton("Open Existing")
self.btn_open_guidance.setObjectName("secondary")
self.btn_open_guidance.clicked.connect(self._open_guidance_tool)
self.btn_open_guidance.setEnabled(False)
guidance_buttons.addWidget(self.btn_open_guidance)
self.btn_extract_guidance = QPushButton("Extract as New Tool")
self.btn_extract_guidance.clicked.connect(self._extract_guidance)
self.btn_extract_guidance.setEnabled(False)
guidance_buttons.addWidget(self.btn_extract_guidance)
guidance_layout.addLayout(guidance_buttons)
left_layout.addWidget(guidance_box)
self._guidance_timer = QTimer(self)
self._guidance_timer.setSingleShot(True)
self._guidance_timer.setInterval(700)
self._guidance_timer.timeout.connect(self._refresh_guidance)
self.name_input.textChanged.connect(self._schedule_guidance)
self.desc_input.textChanged.connect(self._schedule_guidance)
# Arguments group # Arguments group
args_box = QGroupBox() args_box = QGroupBox()
args_layout = QVBoxLayout(args_box) args_layout = QVBoxLayout(args_box)
@ -404,6 +525,175 @@ class ToolBuilderPage(QWidget):
layout.addWidget(splitter, 1) layout.addWidget(splitter, 1)
def _current_draft(self) -> Tool:
"""Materialize the current form without saving it."""
current = self._tool
return Tool(
name=self.name_input.text().strip() or "untitled-tool",
description=self.desc_input.text().strip(),
category=self.category_combo.currentText() or "Other",
arguments=copy.deepcopy(current.arguments) if current else [],
steps=copy.deepcopy(current.steps) if current else [],
output=self.output_input.toPlainText().strip() or "{response}",
dependencies=list(current.dependencies) if current else [],
system_dependencies=(
copy.deepcopy(current.system_dependencies) if current else []
),
visibility=current.visibility if current else "public",
input_schema=copy.deepcopy(current.input_schema) if current else None,
output_schema=copy.deepcopy(current.output_schema) if current else None,
)
def _schedule_guidance(self):
self._guidance_timer.start()
def _add_guidance_item(self, text: str, data: dict) -> None:
item = QListWidgetItem(text)
item.setData(Qt.UserRole, data)
self.guidance_list.addItem(item)
def _refresh_guidance(self):
"""Refresh deterministic local guidance, then search remotely."""
from ...preflight import analyze_tool
from ...usage import get_suggestions
draft = self._current_draft()
self.guidance_list.clear()
report = analyze_tool(
draft,
check_local_dependencies=False,
include_contract_tests=False,
check_reuse=True,
)
for opportunity in report.reuse_opportunities:
self._add_guidance_item(
f"Reusable: {opportunity.get('detail', 'exact sequence')}",
opportunity,
)
for suggestion in get_suggestions():
tools = suggestion["tools"]
self._add_guidance_item(
f"Frequent pipeline ({suggestion['count']}x): " + " | ".join(tools),
{"type": "usage_pipeline", **suggestion},
)
query = (draft.description or draft.name).strip()
if not query or query == "untitled-tool":
self._finish_guidance(query, [])
return
if self._guidance_worker and self._guidance_worker.isRunning():
# The pending result is ignored if the query has since changed.
self.guidance_summary.setText("Checking registry…")
return
self.guidance_summary.setText("Checking registry…")
self.btn_refresh_guidance.setEnabled(False)
self._guidance_worker = SimilarToolsWorker(query, self)
self._guidance_worker.results_ready.connect(self._finish_guidance)
self._guidance_worker.start()
def _finish_guidance(self, query: str, results: list):
current_query = (
self.desc_input.text().strip() or self.name_input.text().strip()
)
if query == current_query:
seen = set()
for result in results:
name = result.get("name", "")
owner = result.get("owner", "")
reference = f"{owner}/{name}" if owner else name
if reference and reference not in seen:
seen.add(reference)
score = result.get("quality_score")
quality = f" [{score}/100]" if score is not None else ""
self._add_guidance_item(
f"Registry: {reference}{quality}{result.get('description', '')}",
{"type": "registry_similar", "tool": reference},
)
self.btn_refresh_guidance.setEnabled(True)
count = self.guidance_list.count()
self.guidance_summary.setText(
f"{count} advisory suggestion{'s' if count != 1 else ''}. "
"Nothing changes until you confirm an action."
if count else "No evidenced reuse opportunities found."
)
self._guidance_selection_changed(self.guidance_list.currentItem())
if query != current_query:
self._guidance_timer.start(0)
def _guidance_selection_changed(self, current, previous=None):
data = current.data(Qt.UserRole) if current else {}
kind = data.get("type")
local_name = data.get("tool", "")
self.btn_open_guidance.setEnabled(
kind == "duplicate_sequence" and bool(local_name)
)
self.btn_extract_guidance.setEnabled(
kind in {"repeated_sequence", "usage_pipeline"}
)
def _open_guidance_tool(self):
item = self.guidance_list.currentItem()
data = item.data(Qt.UserRole) if item else {}
if data.get("type") == "duplicate_sequence" and data.get("tool"):
_switch_to_existing_tool(self.main_window, data["tool"])
def _extract_guidance(self):
"""Preview and explicitly save an evidenced reusable tool."""
item = self.guidance_list.currentItem()
data = item.data(Qt.UserRole) if item else {}
kind = data.get("type")
if kind not in {"repeated_sequence", "usage_pipeline"}:
return
suggested = (
"-".join(data.get("tools", []))
if kind == "usage_pipeline" else f"{self.name_input.text().strip()}-shared"
)
name, accepted = QInputDialog.getText(
self, "Extract Reusable Tool", "New tool name:", text=suggested
)
name = name.strip()
if not accepted or not name:
return
valid, error = validate_tool_name(name)
if not valid:
QMessageBox.warning(self, "Validation", error)
return
if tool_exists(name):
QMessageBox.warning(self, "Tool Already Exists", f"'{name}' already exists.")
return
try:
if kind == "usage_pipeline":
from ...usage import build_composite_tool
extracted = build_composite_tool(name, data["tools"])
else:
extracted = build_extracted_tool(self._current_draft(), data, name)
except (KeyError, TypeError, ValueError) as exc:
QMessageBox.warning(self, "Cannot Extract", str(exc))
return
preview = QMessageBox(self)
preview.setIcon(QMessageBox.Information)
preview.setWindowTitle("Review Extraction")
preview.setText(
f"Create '{name}' as a separate tool? Your current draft will not be modified."
)
preview.setDetailedText(extraction_diff(extracted))
create_button = preview.addButton("Create Tool", QMessageBox.AcceptRole)
preview.addButton(QMessageBox.Cancel)
preview.exec()
if preview.clickedButton() is not create_button:
return
try:
save_tool(extracted)
except (OSError, ValueError) as exc:
QMessageBox.critical(self, "Extraction Failed", str(exc))
return
QMessageBox.information(
self, "Tool Created", f"Created '{name}'. The current draft is unchanged."
)
self._populate_deps_combo()
self._refresh_guidance()
def _set_view_mode(self, mode: int): def _set_view_mode(self, mode: int):
"""Switch between list (0) and flow (1) views.""" """Switch between list (0) and flow (1) views."""
if mode == 1 and self._flow_widget is None: if mode == 1 and self._flow_widget is None:

View File

@ -63,7 +63,9 @@ class ImprovementReport:
} }
def generate_improvements(tool: Tool) -> ImprovementReport: def generate_improvements(
tool: Tool, scrutiny_report: Optional[dict] = None
) -> ImprovementReport:
"""Analyze a tool and generate improvement suggestions. """Analyze a tool and generate improvement suggestions.
Categories: Categories:
@ -77,6 +79,35 @@ def generate_improvements(tool: Tool) -> ImprovementReport:
_check_efficiency(tool, report) _check_efficiency(tool, report)
_check_transparency(tool, report) _check_transparency(tool, report)
# Preserve the registry scrutiny engine's evidence rather than running a
# disconnected approximation. Only actionable warnings/failures become
# suggestions; passing checks are deliberately omitted.
for finding in (scrutiny_report or {}).get("findings", []):
result = finding.get("result")
if result not in ("warning", "fail"):
continue
check = str(finding.get("check", "scrutiny")).lower()
category = next(
(value for value in ("honesty", "efficiency", "transparency")
if value in check),
"transparency",
)
suggestion = finding.get("suggestion") or (
"Review this finding and make the behavior explicit."
)
candidate = ImprovementSuggestion(
category=category,
severity="high" if result == "fail" else "medium",
title=f"Scrutiny: {finding.get('check', 'review required')}",
description=finding.get("message", "Scrutiny identified an issue."),
location=finding.get("location") or "tool",
suggested=suggestion,
)
marker = (candidate.category, candidate.title, candidate.location)
existing = {(s.category, s.title, s.location) for s in report.suggestions}
if marker not in existing:
report.suggestions.append(candidate)
return report return report

View File

@ -1,24 +1,27 @@
"""Transitive integrity verification (M9.4). """Content-addressable, transitive integrity verification for CmdForge tools."""
Content-addressable tool identity: tool = hash(tool definition + all dep hashes). from __future__ import annotations
Extends lockfile to include transitive integrity chain.
"""
import hashlib import hashlib
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from typing import Dict, List, Optional, Set
from typing import Any, Dict, List, Optional
from .tool import Tool, load_tool from .tool import Tool, ToolStep, load_tool
def _sha256(value: object) -> str:
encoded = json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
@dataclass @dataclass
class IntegrityNode: class IntegrityNode:
"""One node in the integrity chain."""
name: str name: str
version: str = "" version: str = ""
definition_hash: str = ""
content_hash: str = "" content_hash: str = ""
dependencies: List[str] = field(default_factory=list) dependencies: List[str] = field(default_factory=list)
dependency_hashes: Dict[str, str] = field(default_factory=dict) dependency_hashes: Dict[str, str] = field(default_factory=dict)
@ -27,6 +30,7 @@ class IntegrityNode:
return { return {
"name": self.name, "name": self.name,
"version": self.version, "version": self.version,
"definition_hash": self.definition_hash,
"hash": self.content_hash, "hash": self.content_hash,
"dependencies": self.dependencies, "dependencies": self.dependencies,
"dependency_hashes": self.dependency_hashes, "dependency_hashes": self.dependency_hashes,
@ -35,99 +39,142 @@ class IntegrityNode:
@dataclass @dataclass
class IntegrityChain: class IntegrityChain:
"""Full transitive integrity chain for a tool."""
root: IntegrityNode root: IntegrityNode
nodes: Dict[str, IntegrityNode] = field(default_factory=dict) nodes: Dict[str, IntegrityNode] = field(default_factory=dict)
errors: List[str] = field(default_factory=list)
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:
"""Check all dependency hashes match.""" if self.errors or self.root.name not in self.nodes:
for name, node in self.nodes.items():
for dep_name in node.dependencies:
expected_hash = node.dependency_hashes.get(dep_name)
if expected_hash == "unresolved":
# Unresolved dependencies don't invalidate the chain
continue
if dep_name not in self.nodes:
return False return False
dep_node = self.nodes[dep_name] for name, node in self.nodes.items():
if expected_hash and expected_hash != dep_node.content_hash: if not node.definition_hash or not node.content_hash:
return False
if sorted(node.dependencies) != sorted(node.dependency_hashes):
return False
for dep_name in node.dependencies:
dep = self.nodes.get(dep_name)
if dep is None or node.dependency_hashes[dep_name] != dep.content_hash:
return False
expected = _identity_hash(node.definition_hash, node.dependency_hashes)
if not _constant_time_equal(expected, node.content_hash):
return False return False
return True return True
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"root": self.root.to_dict(), "root": self.root.to_dict(),
"nodes": {k: v.to_dict() for k, v in self.nodes.items()}, "nodes": {key: value.to_dict() for key, value in self.nodes.items()},
"errors": list(self.errors),
"valid": self.is_valid, "valid": self.is_valid,
} }
def _constant_time_equal(left: str, right: str) -> bool:
import hmac
return hmac.compare_digest(left, right)
def compute_tool_hash(tool: Tool) -> str: def compute_tool_hash(tool: Tool) -> str:
"""Compute a content hash for a tool definition.""" """Return the full SHA-256 hash of the canonical tool definition."""
tool_dict = tool.to_dict() data = tool.to_dict()
# Remove path (not part of content identity) data.pop("path", None)
tool_dict.pop("path", None) # Registry bookkeeping is not executable tool content.
content = json.dumps(tool_dict, sort_keys=True) for key in ("registry_hash", "registry_status", "registry_feedback"):
return hashlib.sha256(content.encode()).hexdigest()[:16] data.pop(key, None)
return _sha256(data)
def build_integrity_chain(tool: Tool, max_depth: int = 10) -> IntegrityChain: def _identity_hash(definition_hash: str, dependency_hashes: Dict[str, str]) -> str:
"""Build a transitive integrity chain for a tool. """Hash a definition together with the identities of every direct dep."""
return _sha256({
"definition": definition_hash,
"dependencies": dict(sorted(dependency_hashes.items())),
})
Traverses all ToolStep dependencies and computes hashes for each.
""" def compute_content_identity(
chain = IntegrityChain(root=_build_node(tool)) definition_hash: str, dependency_hashes: Dict[str, str]
chain.nodes[tool.name] = chain.root ) -> str:
_traverse_deps(tool, chain, depth=0, max_depth=max_depth) """Return the canonical identity used by integrity chains and lockfiles."""
return _identity_hash(definition_hash, dependency_hashes)
def _dependency_names(tool: Tool) -> List[str]:
names = list(tool.dependencies)
names.extend(step.tool for step in tool.steps if isinstance(step, ToolStep))
return sorted(set(names))
def build_integrity_chain(tool: Tool, max_depth: int = 50) -> IntegrityChain:
"""Resolve dependencies bottom-up and compute a Merkle-style identity."""
placeholder = IntegrityNode(tool.name, tool.version or "")
chain = IntegrityChain(root=placeholder)
visiting: Set[str] = set()
def visit(current: Tool, depth: int) -> Optional[IntegrityNode]:
name = current.name
if name in visiting:
chain.errors.append(f"Dependency cycle detected at {name}")
return None
if name in chain.nodes:
return chain.nodes[name]
if depth > max_depth:
chain.errors.append(f"Maximum dependency depth exceeded at {name}")
return None
visiting.add(name)
dep_hashes: Dict[str, str] = {}
dependencies = _dependency_names(current)
for dep_name in dependencies:
from .resolver import ToolSpec
from .semver import matches_constraint
dep_spec = ToolSpec.parse(dep_name)
dep_tool = load_tool(dep_spec.full_name)
if dep_tool is None:
chain.errors.append(f"Unresolved dependency: {dep_name}")
continue
if dep_spec.version and not matches_constraint(
dep_tool.version or "", dep_spec.version
):
chain.errors.append(
f"Dependency version mismatch: {dep_name} "
f"resolved to {dep_tool.version or 'unknown'}"
)
continue
dep_node = visit(dep_tool, depth + 1)
if dep_node is not None:
dep_hashes[dep_name] = dep_node.content_hash
visiting.remove(name)
definition_hash = compute_tool_hash(current)
node = IntegrityNode(
name=name,
version=current.version or "",
definition_hash=definition_hash,
content_hash=_identity_hash(definition_hash, dep_hashes),
dependencies=dependencies,
dependency_hashes=dep_hashes,
)
chain.nodes[name] = node
return node
root = visit(tool, 0)
if root is not None:
chain.root = root
return chain return chain
def _build_node(tool: Tool) -> IntegrityNode: def verify_integrity(chain: IntegrityChain, *, verify_installed: bool = False) -> bool:
"""Build an integrity node from a tool.""" """Verify chain structure, optionally rebuilding it from installed tools."""
deps = [] if not chain.is_valid:
dep_hashes = {} return False
if not verify_installed:
for step in tool.steps: return True
if hasattr(step, "tool") and step.tool: root_tool = load_tool(chain.root.name)
deps.append(step.tool) if root_tool is None:
return False
# Resolve dependency hashes rebuilt = build_integrity_chain(root_tool)
for dep_name in deps: return rebuilt.is_valid and _constant_time_equal(
dep_tool = load_tool(dep_name) rebuilt.root.content_hash, chain.root.content_hash
if dep_tool:
dep_hashes[dep_name] = compute_tool_hash(dep_tool)
else:
dep_hashes[dep_name] = "unresolved"
return IntegrityNode(
name=tool.name,
version=tool.version or "",
content_hash=compute_tool_hash(tool),
dependencies=deps,
dependency_hashes=dep_hashes,
) )
def _traverse_deps(tool: Tool, chain: IntegrityChain, depth: int, max_depth: int):
"""Recursively traverse dependencies and add to chain."""
if depth >= max_depth:
return
for step in tool.steps:
if hasattr(step, "tool") and step.tool:
dep_name = step.tool
if dep_name in chain.nodes:
continue
dep_tool = load_tool(dep_name)
if not dep_tool:
continue
node = _build_node(dep_tool)
chain.nodes[dep_name] = node
_traverse_deps(dep_tool, chain, depth + 1, max_depth)
def verify_integrity(chain: IntegrityChain) -> bool:
"""Verify that all hashes in the chain are consistent."""
return chain.is_valid

View File

@ -26,6 +26,8 @@ class LockedPackage:
direct: bool # True if in manifest direct: bool # True if in manifest
required_by: List[str] = field(default_factory=list) # Parent packages required_by: List[str] = field(default_factory=list) # Parent packages
path: Optional[str] = None # Relative path for local tools path: Optional[str] = None # Relative path for local tools
content_hash: str = "" # Definition plus dependency identities
dependency_hashes: Dict[str, str] = field(default_factory=dict)
@property @property
def owner(self) -> str: def owner(self) -> str:
@ -145,7 +147,9 @@ class Lockfile:
source=pkg_data.get("source", "registry"), source=pkg_data.get("source", "registry"),
direct=pkg_data.get("direct", False), direct=pkg_data.get("direct", False),
required_by=pkg_data.get("required_by", []), required_by=pkg_data.get("required_by", []),
path=pkg_data.get("path") path=pkg_data.get("path"),
content_hash=pkg_data.get("content_hash", ""),
dependency_hashes=pkg_data.get("dependency_hashes", {})
) )
return cls( return cls(
@ -180,6 +184,10 @@ class Lockfile:
pkg_dict["required_by"] = pkg.required_by pkg_dict["required_by"] = pkg.required_by
if pkg.path: if pkg.path:
pkg_dict["path"] = pkg.path pkg_dict["path"] = pkg.path
if pkg.content_hash:
pkg_dict["content_hash"] = pkg.content_hash
if pkg.dependency_hashes:
pkg_dict["dependency_hashes"] = dict(sorted(pkg.dependency_hashes.items()))
d["packages"][name] = pkg_dict d["packages"][name] = pkg_dict
return d return d
@ -259,6 +267,34 @@ def generate_lockfile(
) )
lock.packages[qualified_name] = pkg lock.packages[qualified_name] = pkg
from .integrity import compute_content_identity
visiting = set()
def identity_for(package_name: str) -> str:
pkg = lock.packages.get(package_name)
node = graph.nodes.get(package_name)
if pkg is None or node is None or not pkg.integrity:
return ""
if pkg.content_hash:
return pkg.content_hash
if package_name in visiting:
return ""
visiting.add(package_name)
dependencies = {}
for child_name in sorted(node.children):
child_hash = identity_for(child_name)
if not child_hash:
visiting.remove(package_name)
return ""
dependencies[child_name] = child_hash
visiting.remove(package_name)
pkg.dependency_hashes = dependencies
pkg.content_hash = compute_content_identity(pkg.integrity, dependencies)
return pkg.content_hash
for package_name in sorted(lock.packages):
identity_for(package_name)
return lock return lock
@ -368,4 +404,30 @@ def verify_lockfile(
except Exception as e: except Exception as e:
errors.append(f"{name}: could not verify integrity ({e})") errors.append(f"{name}: could not verify integrity ({e})")
if locked.content_hash:
from .integrity import compute_content_identity
actual_dependencies = {}
missing_dependencies = []
for dep_name, expected_hash in locked.dependency_hashes.items():
dep = lock.packages.get(dep_name)
if dep is None or not dep.content_hash:
missing_dependencies.append(dep_name)
elif dep.content_hash != expected_hash:
errors.append(
f"{name}: dependency identity mismatch ({dep_name})"
)
else:
actual_dependencies[dep_name] = dep.content_hash
if missing_dependencies:
errors.append(
f"{name}: unresolved integrity dependencies: "
+ ", ".join(missing_dependencies)
)
elif locked.integrity:
actual_identity = compute_content_identity(
locked.integrity, actual_dependencies
)
if actual_identity != locked.content_hash:
errors.append(f"{name}: transitive content identity mismatch")
return errors return errors

View File

@ -13,6 +13,7 @@ from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar
from urllib.parse import urlparse
import yaml import yaml
@ -20,7 +21,7 @@ MCP_CONFIG_FILE = Path.home() / ".cmdforge" / "mcp.yaml"
MCP_CONFIG_VERSION = 1 MCP_CONFIG_VERSION = 1
MCP_DEPTH_ENV = "CMDFORGE_MCP_DEPTH" MCP_DEPTH_ENV = "CMDFORGE_MCP_DEPTH"
RESULT_MODES = ("auto", "structured", "content", "text") RESULT_MODES = ("auto", "structured", "content", "text")
SUPPORTED_TRANSPORTS = ("stdio",) SUPPORTED_TRANSPORTS = ("stdio", "streamable-http")
DEFAULT_INHERITED_ENV = ( DEFAULT_INHERITED_ENV = (
"PATH", "PATH",
"HOME", "HOME",
@ -95,13 +96,15 @@ def _glob_match(pattern: str, name: str) -> bool:
class McpServerConfig: class McpServerConfig:
"""Configuration for one MCP server. """Configuration for one MCP server.
M7.2 intentionally supports stdio only. ``approved`` records explicit ``approved`` records explicit user consent to execute a local command or
user consent to execute the configured local command. connect to the configured HTTP endpoint.
""" """
name: str name: str
transport: str = "stdio" transport: str = "stdio"
command: Optional[str] = None command: Optional[str] = None
url: Optional[str] = None
headers: Dict[str, str] = field(default_factory=dict)
args: List[str] = field(default_factory=list) args: List[str] = field(default_factory=list)
cwd: Optional[str] = None cwd: Optional[str] = None
env: Dict[str, str] = field(default_factory=dict) env: Dict[str, str] = field(default_factory=dict)
@ -116,10 +119,25 @@ class McpServerConfig:
if self.transport not in SUPPORTED_TRANSPORTS: if self.transport not in SUPPORTED_TRANSPORTS:
raise ValueError( raise ValueError(
f"Unsupported MCP transport '{self.transport}'. " f"Unsupported MCP transport '{self.transport}'. "
f"Supported in M7.2: {', '.join(SUPPORTED_TRANSPORTS)}" f"Supported transports: {', '.join(SUPPORTED_TRANSPORTS)}"
) )
if self.transport == "stdio":
if not isinstance(self.command, str) or not self.command.strip(): if not isinstance(self.command, str) or not self.command.strip():
raise ValueError(f"MCP server '{self.name}' requires a command") raise ValueError(f"MCP server '{self.name}' requires a command")
if self.url is not None:
raise ValueError(f"MCP stdio server '{self.name}' cannot define url")
if self.headers:
raise ValueError(f"MCP stdio server '{self.name}' cannot define headers")
else:
if self.command is not None:
raise ValueError(
f"MCP streamable-http server '{self.name}' cannot define command"
)
_validate_remote_url(self.name, self.url)
if self.args or self.cwd or self.env:
raise ValueError(
f"MCP streamable-http server '{self.name}' cannot define args, cwd, or env"
)
if not isinstance(self.args, list) or not all(isinstance(arg, str) for arg in self.args): if not isinstance(self.args, list) or not all(isinstance(arg, str) for arg in self.args):
raise ValueError(f"MCP server '{self.name}' args must be a list of strings") raise ValueError(f"MCP server '{self.name}' args must be a list of strings")
if not isinstance(self.env, dict) or not all( if not isinstance(self.env, dict) or not all(
@ -131,6 +149,15 @@ class McpServerConfig:
raise ValueError( raise ValueError(
f"MCP server '{self.name}' env must map valid variable names to strings" f"MCP server '{self.name}' env must map valid variable names to strings"
) )
if not isinstance(self.headers, dict) or not all(
isinstance(key, str) and key.strip()
and "\n" not in key and "\r" not in key
and isinstance(value, str) and "\n" not in value and "\r" not in value
for key, value in self.headers.items()
):
raise ValueError(
f"MCP server '{self.name}' headers must map safe names to strings"
)
if not isinstance(self.inherit_env, list) or not all( if not isinstance(self.inherit_env, list) or not all(
isinstance(name, str) and _ENV_NAME.fullmatch(name) isinstance(name, str) and _ENV_NAME.fullmatch(name)
for name in self.inherit_env for name in self.inherit_env
@ -155,6 +182,8 @@ def _fingerprint(cfg: McpServerConfig) -> str:
[ [
cfg.transport, cfg.transport,
cfg.command, cfg.command,
cfg.url,
cfg.headers,
cfg.args, cfg.args,
cfg.cwd, cfg.cwd,
cfg.env, cfg.env,
@ -203,6 +232,36 @@ def _build_server_env(cfg: McpServerConfig) -> Dict[str, str]:
return environment return environment
def _is_loopback_host(hostname: Optional[str]) -> bool:
if not hostname:
return False
if hostname.lower() == "localhost":
return True
try:
import ipaddress
return ipaddress.ip_address(hostname).is_loopback
except ValueError:
return False
def _validate_remote_url(name: str, url: Optional[str]) -> None:
if not isinstance(url, str) or not url.strip():
raise ValueError(f"MCP streamable-http server '{name}' requires a url")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError(f"MCP server '{name}' has an invalid HTTP URL")
if parsed.username or parsed.password:
raise ValueError("MCP URLs must not contain credentials; use headers")
if parsed.scheme != "https" and not _is_loopback_host(parsed.hostname):
raise ValueError(
f"Remote MCP server '{name}' must use HTTPS; HTTP is allowed only for localhost"
)
def _build_http_headers(cfg: McpServerConfig) -> Dict[str, str]:
return {name: _expand_env_value(value) for name, value in cfg.headers.items()}
def _load_mcp_document() -> dict: def _load_mcp_document() -> dict:
if not MCP_CONFIG_FILE.exists(): if not MCP_CONFIG_FILE.exists():
return {} return {}
@ -229,6 +288,8 @@ def load_mcp_config() -> List[McpServerConfig]:
name=name, name=name,
transport=raw.get("transport", "stdio"), transport=raw.get("transport", "stdio"),
command=raw.get("command"), command=raw.get("command"),
url=raw.get("url"),
headers=raw.get("headers", {}),
args=raw.get("args", []), args=raw.get("args", []),
cwd=raw.get("cwd"), cwd=raw.get("cwd"),
env=raw.get("env", {}), env=raw.get("env", {}),
@ -276,9 +337,14 @@ def save_mcp_config(servers: List[McpServerConfig]) -> None:
def _server_to_dict(server: McpServerConfig) -> dict: def _server_to_dict(server: McpServerConfig) -> dict:
data: dict = { data: dict = {
"transport": server.transport, "transport": server.transport,
"command": server.command,
"approved": server.approved, "approved": server.approved,
} }
if server.command:
data["command"] = server.command
if server.url:
data["url"] = server.url
if server.headers:
data["headers"] = server.headers
if server.args: if server.args:
data["args"] = server.args data["args"] = server.args
if server.cwd: if server.cwd:
@ -329,8 +395,8 @@ def _serialize_tool(tool: Any) -> Dict[str, Any]:
class McpClientManager: class McpClientManager:
"""Invocation-scoped MCP configuration and schema manager. """Invocation-scoped MCP configuration and schema manager.
SDK sessions are intentionally scoped to each discovery or tool operation SDK sessions are intentionally scoped to each discovery or tool operation.
in M7.2. This guarantees subprocess cleanup while the manager reuses loaded This guarantees transport cleanup while the manager reuses loaded
configuration and discovered schemas across all MCP steps in one tool run. configuration and discovered schemas across all MCP steps in one tool run.
""" """
@ -368,7 +434,10 @@ class McpClientManager:
raise KeyError(f"MCP server '{server_name}' not configured. Add it to {MCP_CONFIG_FILE}") raise KeyError(f"MCP server '{server_name}' not configured. Add it to {MCP_CONFIG_FILE}")
cfg.validate() cfg.validate()
if not cfg.approved: if not cfg.approved:
command = " ".join([cfg.command or ""] + cfg.args) command = (
" ".join([cfg.command or ""] + cfg.args)
if cfg.transport == "stdio" else cfg.url or ""
)
raise PermissionError( raise PermissionError(
f"MCP server '{server_name}' is not approved to execute: {_sanitize(command)}. " f"MCP server '{server_name}' is not approved to execute: {_sanitize(command)}. "
"Re-add it with 'cmdforge mcp add ...' or set approved: true after review." "Re-add it with 'cmdforge mcp add ...' or set approved: true after review."
@ -401,7 +470,7 @@ class McpClientManager:
seen_cursors.add(cursor) seen_cursors.add(cursor)
return tools return tools
tools = _run_stdio_operation(cfg, list_all_tools, "discover tools") tools = _run_operation(cfg, list_all_tools, "discover tools")
self._schemas[server_name] = {tool["name"]: tool for tool in tools} self._schemas[server_name] = {tool["name"]: tool for tool in tools}
self._schema_fingerprints[server_name] = fingerprint self._schema_fingerprints[server_name] = fingerprint
return tools return tools
@ -440,7 +509,57 @@ class McpClientManager:
) )
return _normalize_result(result, result_mode) return _normalize_result(result, result_mode)
return _run_stdio_operation(cfg, call, f"call {tool_name}") return _run_operation(cfg, call, f"call {tool_name}")
def _run_operation(
cfg: McpServerConfig,
operation: Callable[[Any], Awaitable[T]],
operation_name: str,
) -> T:
if cfg.transport == "stdio":
return _run_stdio_operation(cfg, operation, operation_name)
return _run_http_operation(cfg, operation, operation_name)
def _run_http_operation(
cfg: McpServerConfig,
operation: Callable[[Any], Awaitable[T]],
operation_name: str,
) -> T:
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
import httpx
resolved_headers = _build_http_headers(cfg)
async def run() -> T:
timeout = httpx.Timeout(cfg.timeout)
async with httpx.AsyncClient(
headers=resolved_headers, timeout=timeout, follow_redirects=False
) as client:
async with streamable_http_client(
cfg.url, http_client=client
) as (read, write, _):
async with ClientSession(
read, write,
read_timeout_seconds=timedelta(seconds=cfg.timeout),
) as session:
await session.initialize()
return await operation(session)
try:
return asyncio.run(asyncio.wait_for(run(), timeout=cfg.timeout))
except (asyncio.TimeoutError, TimeoutError) as exc:
raise TimeoutError(
f"MCP {operation_name} on '{cfg.name}' timed out after {cfg.timeout}s"
) from exc
except Exception as exc:
secrets = list(resolved_headers.values())
raise RuntimeError(
f"MCP {operation_name} on '{cfg.name}' failed: "
f"{_sanitize(str(exc), secrets)}"
) from exc
def _run_stdio_operation( def _run_stdio_operation(

View File

@ -1,14 +1,18 @@
"""MCP server support: expose CmdForge tools as MCP tools.""" """MCP server support: expose CmdForge tools as MCP tools."""
import inspect import inspect
import hmac
import os import os
import sys import sys
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal, Optional
from urllib.parse import urlparse
from .mcp_client import ( from .mcp_client import (
MCP_DEPTH_ENV, MCP_DEPTH_ENV,
McpServeConfig, McpServeConfig,
_MCP_CALL_DEPTH, _MCP_CALL_DEPTH,
_expand_env_value,
_is_loopback_host,
_require_mcp_sdk, _require_mcp_sdk,
load_mcp_serve_config, load_mcp_serve_config,
) )
@ -87,16 +91,70 @@ def _build_tool_schema(tool) -> dict:
} }
def serve(transport: str = "stdio") -> None: class _StaticTokenVerifier:
def __init__(self, token: str):
self._token = token
async def verify_token(self, token: str):
if not hmac.compare_digest(token, self._token):
return None
from mcp.server.auth.provider import AccessToken
return AccessToken(
token=token, client_id="cmdforge-mcp-client", scopes=[],
subject="cmdforge-mcp",
)
def _validate_http_server_options(
host: str,
port: int,
allowed_origins: list[str],
auth_token: Optional[str],
external_url: Optional[str],
) -> tuple[list[str], Optional[str]]:
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
raise ValueError("MCP server port must be between 1 and 65535")
local = _is_loopback_host(host)
resolved_token = _expand_env_value(auth_token) if auth_token else None
if not local:
parsed = urlparse(external_url or "")
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError(
"Non-local MCP serving requires --external-url https://... "
"behind a TLS-terminating proxy"
)
if not resolved_token:
raise ValueError("Non-local MCP serving requires --auth-token")
origins = allowed_origins or (
[f"http://localhost:{port}", f"http://127.0.0.1:{port}"]
if local else [f"{urlparse(external_url).scheme}://{urlparse(external_url).netloc}"]
)
for origin in origins:
parsed = urlparse(origin)
if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.path not in ("", "/"):
raise ValueError(f"Invalid allowed origin: {origin}")
if not local and parsed.scheme != "https":
raise ValueError("Non-local MCP origins must use HTTPS")
return origins, resolved_token
def serve(
transport: str = "stdio",
*,
host: str = "127.0.0.1",
port: int = 8000,
allowed_origins: Optional[list[str]] = None,
auth_token: Optional[str] = None,
external_url: Optional[str] = None,
) -> None:
"""Start CmdForge as an MCP server on the given transport. """Start CmdForge as an MCP server on the given transport.
Currently only stdio transport is supported (M7.3). HTTP defaults to loopback and enables SDK DNS-rebinding/origin protection.
Non-local binding requires bearer auth and an explicit public HTTPS URL;
TLS is expected to terminate at the reverse proxy represented by that URL.
""" """
if transport != "stdio": if transport not in ("stdio", "streamable-http"):
raise NotImplementedError( raise ValueError(f"Unsupported MCP server transport '{transport}'")
f"Unsupported MCP server transport '{transport}'. "
f"M7.3 supports stdio only."
)
_require_mcp_sdk() _require_mcp_sdk()
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@ -104,7 +162,37 @@ def serve(transport: str = "stdio") -> None:
serve_config = load_mcp_serve_config() serve_config = load_mcp_serve_config()
from .tool import list_tools, load_tool from .tool import list_tools, load_tool
server = FastMCP("CmdForge") server_options = {}
if transport == "streamable-http":
origins, resolved_token = _validate_http_server_options(
host, port, allowed_origins or [], auth_token, external_url
)
from mcp.server.transport_security import TransportSecuritySettings
allowed_hosts = [host, f"{host}:{port}", "localhost", f"localhost:{port}"]
if external_url:
external_host = urlparse(external_url).netloc
allowed_hosts.extend([external_host, urlparse(external_url).hostname or ""])
server_options.update({
"host": host,
"port": port,
"transport_security": TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[value for value in allowed_hosts if value],
allowed_origins=origins,
),
})
if resolved_token:
from mcp.server.auth.settings import AuthSettings
issuer = external_url or f"http://{host}:{port}"
server_options.update({
"token_verifier": _StaticTokenVerifier(resolved_token),
"auth": AuthSettings(
issuer_url=issuer,
resource_server_url=f"{issuer.rstrip('/')}/mcp",
required_scopes=[],
),
})
server = FastMCP("CmdForge", **server_options)
tool_names = list_tools() tool_names = list_tools()
exposed_count = 0 exposed_count = 0

View File

@ -5,7 +5,7 @@ contract conformance tests, and surfaces the highest-scoring variation.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Callable, Dict, List, Optional
from .tool import Tool, PromptStep from .tool import Tool, PromptStep
@ -37,6 +37,8 @@ class OptimizationResult:
scores: Dict[int, int] = field(default_factory=dict) # variation_index -> score scores: Dict[int, int] = field(default_factory=dict) # variation_index -> score
best_index: Optional[int] = None best_index: Optional[int] = None
baseline_score: int = 0 baseline_score: int = 0
evaluation_kind: str = "structural"
note: str = ""
@property @property
def best(self) -> Optional[PromptVariation]: def best(self) -> Optional[PromptVariation]:
@ -55,6 +57,8 @@ class OptimizationResult:
return { return {
"tool": self.tool_name, "tool": self.tool_name,
"baseline_score": self.baseline_score, "baseline_score": self.baseline_score,
"evaluation_kind": self.evaluation_kind,
"note": self.note,
"best_index": self.best_index, "best_index": self.best_index,
"improvement_pct": round(self.improvement, 1), "improvement_pct": round(self.improvement, 1),
"best": self.best.to_dict() if self.best else None, "best": self.best.to_dict() if self.best else None,
@ -93,7 +97,7 @@ def generate_variations(
original = step.prompt original = step.prompt
strategies = _get_strategies(original, count) strategies = _get_strategies(original, count, provider=provider)
for strategy, variation_text in strategies: for strategy, variation_text in strategies:
if variation_text and variation_text != original: if variation_text and variation_text != original:
@ -107,13 +111,22 @@ def generate_variations(
return variations return variations
def _get_strategies(original: str, count: int) -> List[tuple]: def _get_strategies(
original: str, count: int, provider: str = "mock"
) -> List[tuple]:
"""Generate variations using different strategies.""" """Generate variations using different strategies."""
strategies: List[tuple] = [] strategies: List[tuple] = []
# Attempt AI-driven rephrase if a real provider is available if count <= 0:
return []
# Provider calls are explicit. The default/mock path is deterministic and
# never sends a user's prompt to an external process or service.
if provider != "mock":
rephrased = _call_provider_for_variation( rephrased = _call_provider_for_variation(
f"Rephrase this instruction while keeping the same meaning. Output only the rephrased text:\n\n{original}" provider,
"Rephrase this instruction while keeping the same meaning. "
f"Output only the rephrased text:\n\n{original}",
) )
if rephrased and rephrased != original: if rephrased and rephrased != original:
strategies.append(("rephrase", rephrased)) strategies.append(("rephrase", rephrased))
@ -141,17 +154,15 @@ def _truncate(text: str, max_len: int) -> str:
return text[:max_len - 3].rsplit(" ", 1)[0] + "..." return text[:max_len - 3].rsplit(" ", 1)[0] + "..."
def _call_provider_for_variation(prompt: str) -> Optional[str]: def _call_provider_for_variation(provider: str, prompt: str) -> Optional[str]:
"""Try to call a provider for variation generation.""" """Call the provider explicitly selected by the user."""
try:
from .providers import call_provider from .providers import call_provider
result = call_provider("opencode-pickle", prompt, timeout=15)
result = call_provider(provider, prompt, timeout=15)
if result.success and result.text: if result.success and result.text:
text = result.text.strip() text = result.text.strip()
if text and len(text) >= 10: if text and len(text) >= 10:
return text return text
except Exception:
pass
return None return None
@ -159,6 +170,7 @@ def optimize_tool(
tool: Tool, tool: Tool,
count: int = 5, count: int = 5,
provider: str = "mock", provider: str = "mock",
evaluator: Optional[Callable[[Tool], float]] = None,
) -> OptimizationResult: ) -> OptimizationResult:
"""Run prompt optimization for a tool. """Run prompt optimization for a tool.
@ -175,9 +187,15 @@ def optimize_tool(
""" """
from .contract_testing import run_contract_tests from .contract_testing import run_contract_tests
result = OptimizationResult(tool_name=tool.name) result = OptimizationResult(
tool_name=tool.name,
evaluation_kind="behavioral" if evaluator else "structural",
)
# Baseline score # Baseline score
if evaluator:
result.baseline_score = evaluator(tool)
else:
baseline = run_contract_tests(tool) baseline = run_contract_tests(tool)
result.baseline_score = _count_passed(baseline) result.baseline_score = _count_passed(baseline)
@ -198,14 +216,26 @@ def optimize_tool(
step.prompt = variation.variation step.prompt = variation.variation
try: try:
if evaluator:
result.scores[idx] = evaluator(tool_copy)
else:
test_result = run_contract_tests(tool_copy) test_result = run_contract_tests(tool_copy)
result.scores[idx] = _count_passed(test_result) result.scores[idx] = _count_passed(test_result)
except Exception: except Exception:
result.scores[idx] = 0 result.scores[idx] = 0
# Find best # Find best
if result.scores: if evaluator and result.scores:
result.best_index = max(result.scores, key=lambda k: result.scores[k]) candidate = max(result.scores, key=lambda k: result.scores[k])
if result.scores[candidate] > result.baseline_score:
result.best_index = candidate
else:
result.note = "No variation improved on the behavioral baseline."
elif result.scores:
result.note = (
"Structural conformance cannot compare prompt semantics; "
"no best variation was selected."
)
return result return result
@ -214,3 +244,57 @@ def _count_passed(test_result) -> int:
"""Count passed tests in a ConformanceReport.""" """Count passed tests in a ConformanceReport."""
return sum(1 for r in getattr(test_result, "results", []) return sum(1 for r in getattr(test_result, "results", [])
if getattr(r, "state", "") == "passed") if getattr(r, "state", "") == "passed")
def load_behavioral_evaluator(path, provider_override: Optional[str] = None):
"""Load explicit behavioral cases and return a semantic score function.
The JSON file must contain a list of cases. Each case accepts ``input`` and
optional ``args``, plus exactly one of ``expected`` (exact value) or
``contains`` (substring). Supplying this file is explicit authorization to
execute the candidate tools and their configured providers.
"""
import json
from pathlib import Path
cases = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(cases, list) or not cases:
raise ValueError("Behavior test file must contain a non-empty JSON list")
for index, case in enumerate(cases):
if not isinstance(case, dict):
raise ValueError(f"Behavior case {index} must be an object")
assertions = [key for key in ("expected", "contains") if key in case]
if len(assertions) != 1:
raise ValueError(
f"Behavior case {index} needs exactly one of expected or contains"
)
if not isinstance(case.get("args", {}), dict):
raise ValueError(f"Behavior case {index} args must be an object")
def evaluate(tool: Tool) -> float:
from .runner import run_tool
passed = 0
for case in cases:
output, exit_code = run_tool(
tool,
input_text=str(case.get("input", "")),
custom_args=case.get("args", {}),
provider_override=provider_override,
dry_run=False,
verbose=False,
)
if exit_code != 0:
continue
if "expected" in case:
try:
actual = json.loads(output)
except (TypeError, json.JSONDecodeError):
actual = output
if actual == case["expected"]:
passed += 1
elif str(case["contains"]) in output:
passed += 1
return passed
return evaluate

View File

@ -1140,6 +1140,25 @@ def create_app() -> Flask:
except (AttributeError, TypeError, ValueError): except (AttributeError, TypeError, ValueError):
audit_stale = True audit_stale = True
contributor_rows = query_all(
g.db,
"""SELECT p.slug, c.contribution FROM tool_contributors c
JOIN publishers p ON p.id = c.publisher_id
WHERE c.tool_id = ? ORDER BY p.slug""",
[row["id"]],
)
approved_improvement = query_one(
g.db,
"""SELECT id FROM improvement_submissions
WHERE applied_tool_id = ? LIMIT 1""",
[row["id"]],
)
badges = []
if row.get("attestation_json"):
badges.append("verified")
if approved_improvement:
badges.extend(["optimized", "community-reviewed"])
payload = { payload = {
"owner": row["owner"], "owner": row["owner"],
"name": row["name"], "name": row["name"],
@ -1164,6 +1183,11 @@ def create_app() -> Flask:
"quality": json.loads(latest_audit["quality_json"]) if latest_audit else None, "quality": json.loads(latest_audit["quality_json"]) if latest_audit else None,
"audit_evaluated_at": latest_audit["evaluated_at"] if latest_audit else None, "audit_evaluated_at": latest_audit["evaluated_at"] if latest_audit else None,
"audit_stale": audit_stale, "audit_stale": audit_stale,
"contributors": [
{"name": item["slug"], "contribution": item["contribution"]}
for item in contributor_rows
],
"badges": badges,
} }
response = jsonify({"data": payload}) response = jsonify({"data": payload})
response.headers["Cache-Control"] = "max-age=60" response.headers["Cache-Control"] = "max-age=60"
@ -1313,6 +1337,18 @@ def create_app() -> Flask:
except Exception: except Exception:
g.db.rollback() g.db.rollback()
publisher = query_one(
g.db, "SELECT signing_public_key FROM publishers WHERE id = ?",
[row["publisher_id"]],
)
attestation = None
if row.get("attestation_json"):
try:
attestation = json.loads(row["attestation_json"])
except (TypeError, json.JSONDecodeError):
return error_response(
"INVALID_ATTESTATION", "Stored release attestation is corrupt", 500
)
response = jsonify({ response = jsonify({
"data": { "data": {
"owner": row["owner"], "owner": row["owner"],
@ -1321,12 +1357,172 @@ def create_app() -> Flask:
"config": row["config_yaml"], "config": row["config_yaml"],
"readme": row["readme"] or "", "readme": row["readme"] or "",
"config_hash": row.get("config_hash") or "", "config_hash": row.get("config_hash") or "",
"content_hash": row.get("content_hash") or "",
"dependency_hashes": json.loads(
row.get("dependency_hashes_json") or "{}"
),
"defaults": row.get("defaults") or "", "defaults": row.get("defaults") or "",
"attestation": attestation,
"signing_public_key": (
publisher["signing_public_key"] if publisher else ""
) or "",
} }
}) })
response.headers["Cache-Control"] = "max-age=3600, immutable" response.headers["Cache-Control"] = "max-age=3600, immutable"
return response return response
@app.route("/api/v1/tools/by-content-hash/<path:content_hash>", methods=["GET"])
def tool_by_content_hash(content_hash: str) -> Response:
"""Resolve an approved public release by its transitive identity."""
if not re.fullmatch(r"sha256:[0-9a-f]{64}", content_hash):
return error_response("VALIDATION_ERROR", "Invalid content hash", 400)
row = query_one(
g.db,
"""SELECT owner, name, version, description, category, tags,
downloads, published_at
FROM tools WHERE content_hash = ? AND visibility = 'public'
AND moderation_status = 'approved'
ORDER BY published_at DESC LIMIT 1""",
[content_hash],
)
if not row:
return error_response("TOOL_NOT_FOUND", "Content hash not found", 404)
result = dict(row)
result["tags"] = json.loads(result.get("tags") or "[]")
return jsonify({"data": result})
@app.route(
"/api/v1/tools/<owner>/<name>/<version>/improvements", methods=["POST"]
)
@require_token
def submit_improvement(owner: str, name: str, version: str) -> Response:
"""Submit and automatically test a prompt/code improvement."""
row = query_one(
g.db,
"""SELECT id, config_yaml, visibility, owner FROM tools
WHERE owner = ? AND name = ? AND version = ?
AND moderation_status = 'approved'""",
[owner, name, version],
)
if not row:
return error_response("TOOL_NOT_FOUND", "Tool release not found", 404)
if (
row["visibility"] != "public"
and g.current_publisher["slug"] != row["owner"]
and g.current_publisher.get("role") not in ("moderator", "admin")
):
return error_response("TOOL_NOT_FOUND", "Tool release not found", 404)
payload = request.get_json(silent=True) or {}
try:
from ..community import create_submission, run_submission_tests
from ..tool import Tool
tool = Tool.from_dict(yaml.safe_load(row["config_yaml"]) or {})
submission = create_submission(
tool,
int(payload.get("step_index", -1)),
str(payload.get("proposed", "")),
g.current_publisher["slug"],
str(payload.get("rationale", "")),
)
if not submission.proposed.strip():
raise ValueError("Proposed content must not be empty")
test_result = run_submission_tests(submission, tool)
except (KeyError, TypeError, ValueError) as exc:
return error_response("VALIDATION_ERROR", str(exc), 400)
if not test_result.get("passed_for_review"):
return error_response(
"IMPROVEMENT_TEST_FAILED",
test_result.get("error") or "Proposed change did not pass automated tests",
400,
details={"test_result": test_result},
)
cursor = g.db.execute(
"""INSERT INTO improvement_submissions (
tool_id, submitter_id, step_index, step_type, original,
proposed, rationale, status, test_result_json, submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'tested', ?, ?)""",
[
row["id"], g.current_publisher["id"], submission.step_index,
submission.step_type, submission.original, submission.proposed,
submission.rationale, json.dumps(test_result, sort_keys=True),
submission.submitted_at,
],
)
g.db.commit()
return jsonify({
"data": {"id": cursor.lastrowid, **submission.to_dict()}
}), 201
@app.route("/api/v1/improvements/<int:submission_id>", methods=["PATCH"])
@require_token
def review_improvement(submission_id: int) -> Response:
"""Allow the tool owner/moderator to review a tested submission."""
row = query_one(
g.db,
"""SELECT s.*, t.owner FROM improvement_submissions s
JOIN tools t ON t.id = s.tool_id WHERE s.id = ?""",
[submission_id],
)
if not row:
return error_response("NOT_FOUND", "Improvement not found", 404)
if (
g.current_publisher["slug"] != row["owner"]
and g.current_publisher.get("role") not in ("moderator", "admin")
):
return error_response("FORBIDDEN", "Owner or moderator required", 403)
decision = (request.get_json(silent=True) or {}).get("decision")
if decision not in ("approve", "reject", "request_changes"):
return error_response("VALIDATION_ERROR", "Invalid decision", 400)
status = "approved" if decision == "approve" else decision
now = datetime.now(timezone.utc).isoformat()
notes = (request.get_json(silent=True) or {}).get("notes", "")
g.db.execute(
"""UPDATE improvement_submissions SET status = ?, reviewer_id = ?,
review_notes = ?, reviewed_at = ? WHERE id = ?""",
[status, g.current_publisher["id"], notes, now, submission_id],
)
g.db.commit()
return jsonify({
"data": {
"id": submission_id, "status": status,
"ready_to_apply": status == "approved",
}
})
@app.route("/api/v1/me/signing-key", methods=["PUT"])
@require_token
def set_signing_key() -> Response:
"""Register an Ed25519 public key as the publisher trust anchor."""
public_key = (request.get_json(silent=True) or {}).get("public_key", "")
try:
import base64
raw = base64.b64decode(public_key, validate=True)
if len(raw) != 32:
raise ValueError
except (ValueError, TypeError):
return error_response(
"VALIDATION_ERROR", "public_key must be a base64 Ed25519 key", 400
)
existing_key = query_one(
g.db, "SELECT signing_public_key FROM publishers WHERE id = ?",
[g.current_publisher["id"]],
)
current_key = (
existing_key["signing_public_key"] if existing_key else ""
) or ""
if current_key and current_key != public_key:
return error_response(
"SIGNING_KEY_EXISTS",
"Signing key rotation requires administrator recovery",
409,
)
g.db.execute(
"UPDATE publishers SET signing_public_key = ? WHERE id = ?",
[public_key, g.current_publisher["id"]],
)
g.db.commit()
return jsonify({"data": {"signing_public_key": public_key}})
@app.route("/api/v1/categories", methods=["GET"]) @app.route("/api/v1/categories", methods=["GET"])
def list_categories() -> Response: def list_categories() -> Response:
page, per_page, sort, order, error = parse_pagination("/categories", "name") page, per_page, sort, order, error = parse_pagination("/categories", "name")
@ -2470,6 +2666,8 @@ def create_app() -> Flask:
readme = payload.get("readme") or "" readme = payload.get("readme") or ""
defaults = payload.get("defaults") or "" defaults = payload.get("defaults") or ""
dry_run = bool(payload.get("dry_run")) dry_run = bool(payload.get("dry_run"))
attestation_data = payload.get("attestation")
improvement_id = payload.get("improvement_id")
size_resp = validate_payload_size("config", config_text, MAX_CONFIG_BYTES) size_resp = validate_payload_size("config", config_text, MAX_CONFIG_BYTES)
if size_resp: if size_resp:
@ -2638,10 +2836,64 @@ def create_app() -> Flask:
# Compute config hash early for idempotency check # Compute config hash early for idempotency check
config_hash = compute_yaml_hash(config_text) config_hash = compute_yaml_hash(config_text)
from ..integrity import compute_content_identity
from ..tool import ToolStep
dependency_names = set(published_tool.dependencies)
dependency_names.update(
step.tool for step in published_tool.steps if isinstance(step, ToolStep)
)
dependency_hashes = {}
for dependency_name in sorted(dependency_names):
from ..resolver import ToolSpec
dep_spec = ToolSpec.parse(dependency_name)
dep_owner = dep_spec.owner or owner
dep_row = resolve_tool(dep_owner, dep_spec.name, dep_spec.version)
if dep_row:
dep_identity = dep_row.get("content_hash") or dep_row.get("config_hash")
if dep_identity:
dependency_hashes[dependency_name] = dep_identity
content_hash = compute_content_identity(config_hash, dependency_hashes)
publisher_record = query_one(
g.db, "SELECT signing_public_key FROM publishers WHERE id = ?",
[g.current_publisher["id"]],
)
signing_public_key = (
publisher_record["signing_public_key"] if publisher_record else ""
) or ""
attestation_json = None
if signing_public_key and not dry_run and not attestation_data:
return error_response(
"ATTESTATION_REQUIRED",
"This publisher has a signing key; releases must be signed",
400,
)
if attestation_data:
try:
from ..attestation import Attestation, verify_attestation
attestation = Attestation.from_dict(attestation_data)
except (KeyError, TypeError, ValueError):
return error_response(
"INVALID_ATTESTATION", "Malformed release attestation", 400
)
if (
attestation.tool_name != name
or attestation.version != version
or attestation.content_hash != content_hash
or attestation.signer != owner
or not signing_public_key
or not verify_attestation(attestation, signing_public_key)
):
return error_response(
"INVALID_ATTESTATION",
"Release attestation does not match this publisher and content",
400,
)
attestation_json = json.dumps(attestation.to_dict(), sort_keys=True)
existing = query_one( existing = query_one(
g.db, g.db,
"SELECT published_at, config_hash, moderation_status, visibility FROM tools WHERE owner = ? AND name = ? AND version = ?", "SELECT published_at, config_hash, content_hash, moderation_status, visibility FROM tools WHERE owner = ? AND name = ? AND version = ?",
[owner, name, version], [owner, name, version],
) )
if existing: if existing:
@ -2653,6 +2905,7 @@ def create_app() -> Flask:
"name": name, "name": name,
"version": version, "version": version,
"config_hash": config_hash, "config_hash": config_hash,
"content_hash": existing.get("content_hash") or content_hash,
"pr_url": "", "pr_url": "",
"status": existing["moderation_status"], "status": existing["moderation_status"],
"visibility": existing["visibility"], "visibility": existing["visibility"],
@ -2667,6 +2920,43 @@ def create_app() -> Flask:
details={"published_at": existing["published_at"]}, details={"published_at": existing["published_at"]},
) )
applied_improvement = None
if improvement_id is not None:
try:
improvement_id = int(improvement_id)
except (TypeError, ValueError):
return error_response("VALIDATION_ERROR", "Invalid improvement_id", 400)
applied_improvement = query_one(
g.db,
"""SELECT s.*, t.owner, t.name FROM improvement_submissions s
JOIN tools t ON t.id = s.tool_id
WHERE s.id = ? AND s.status = 'approved'
AND s.applied_tool_id IS NULL""",
[improvement_id],
)
if (
not applied_improvement
or applied_improvement["owner"] != owner
or applied_improvement["name"] != name
or applied_improvement["step_index"] >= len(published_tool.steps)
):
return error_response(
"VALIDATION_ERROR", "Improvement cannot be applied to this release", 400
)
candidate_step = published_tool.steps[applied_improvement["step_index"]]
candidate_value = (
candidate_step.prompt if applied_improvement["step_type"] == "prompt"
and hasattr(candidate_step, "prompt") else
candidate_step.code if applied_improvement["step_type"] == "code"
and hasattr(candidate_step, "code") else None
)
if candidate_value != applied_improvement["proposed"]:
return error_response(
"VALIDATION_ERROR",
"Published config does not contain the approved improvement",
400,
)
suggestions = {"category": None, "similar_tools": []} suggestions = {"category": None, "similar_tools": []}
try: try:
from .categorize import suggest_categories from .categorize import suggest_categories
@ -2714,6 +3004,10 @@ def create_app() -> Flask:
# Check scrutiny decision # Check scrutiny decision
if scrutiny_report: if scrutiny_report:
suggestions["scrutiny"] = scrutiny_report suggestions["scrutiny"] = scrutiny_report
from ..improvement import generate_improvements
suggestions["improvements"] = generate_improvements(
published_tool, scrutiny_report
).to_dict()
if scrutiny_report.get("decision") == "reject": if scrutiny_report.get("decision") == "reject":
# Find the failing check for error message # Find the failing check for error message
fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"] fail_findings = [f for f in scrutiny_report.get("findings", []) if f.get("result") == "fail"]
@ -2749,6 +3043,7 @@ def create_app() -> Flask:
"suggestions": suggestions, "suggestions": suggestions,
"preflight": preflight_report.to_dict(), "preflight": preflight_report.to_dict(),
"quality": quality_report, "quality": quality_report,
"content_hash": content_hash,
} }
}) })
@ -2780,9 +3075,10 @@ def create_app() -> Flask:
owner, name, version, description, category, tags, config_yaml, readme, owner, name, version, description, category, tags, config_yaml, readme,
defaults, publisher_id, deprecated, deprecated_message, replacement, downloads, defaults, publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, source, source_url, source_json, scrutiny_status, scrutiny_report, source, source_url, source_json,
config_hash, visibility, moderation_status, forked_from, forked_version, config_hash, content_hash, dependency_hashes_json, visibility,
published_at moderation_status, forked_from, forked_version, published_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) attestation_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
[ [
owner, owner,
@ -2805,14 +3101,28 @@ def create_app() -> Flask:
source_url, source_url,
source_json, source_json,
config_hash, config_hash,
content_hash,
json.dumps(dependency_hashes, sort_keys=True),
visibility, visibility,
moderation_status, moderation_status,
forked_from, forked_from,
forked_version, forked_version,
datetime.now(timezone.utc).isoformat(), datetime.now(timezone.utc).isoformat(),
attestation_json,
], ],
) )
tool_id = insert_cursor.lastrowid tool_id = insert_cursor.lastrowid
if applied_improvement:
g.db.execute(
"UPDATE improvement_submissions SET applied_tool_id = ? WHERE id = ?",
[tool_id, applied_improvement["id"]],
)
g.db.execute(
"""INSERT OR IGNORE INTO tool_contributors
(tool_id, publisher_id, contribution)
VALUES (?, ?, 'improvement')""",
[tool_id, applied_improvement["submitter_id"]],
)
audit = preflight_report.audit_evidence or {} audit = preflight_report.audit_evidence or {}
g.db.execute( g.db.execute(
""" """
@ -2866,6 +3176,7 @@ def create_app() -> Flask:
"name": name, "name": name,
"version": version, "version": version,
"config_hash": config_hash, "config_hash": config_hash,
"content_hash": content_hash,
"pr_url": "", "pr_url": "",
"status": moderation_status, "status": moderation_status,
"visibility": visibility, "visibility": visibility,

View File

@ -26,22 +26,32 @@ def registry_dependency_findings(conn, tool: Tool, owner: str) -> Dict[str, List
missing: List[str] = [] missing: List[str] = []
deprecated: List[str] = [] deprecated: List[str] = []
for reference in sorted(references): for reference in sorted(references):
if reference == tool.name or reference == f"{owner}/{tool.name}": from ..resolver import ToolSpec
from ..semver import matches_constraint
spec = ToolSpec.parse(reference)
if spec.name == tool.name and (spec.owner in (None, owner)):
continue continue
if "/" in reference: if spec.owner:
dep_owner, dep_name = reference.split("/", 1) candidates = [(spec.owner, spec.name)]
candidates = [(dep_owner, dep_name)]
else: else:
candidates = [(owner, reference), ("official", reference)] candidates = [(owner, spec.name), ("official", spec.name)]
row = None row = None
for dep_owner, dep_name in candidates: for dep_owner, dep_name in candidates:
row = conn.execute( rows = conn.execute(
""" """
SELECT deprecated, replacement FROM tools SELECT version, deprecated, replacement FROM tools
WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1 WHERE owner = ? AND name = ? ORDER BY id DESC
""", """,
[dep_owner, dep_name], [dep_owner, dep_name],
).fetchone() ).fetchall()
row = next(
(
candidate for candidate in rows
if not spec.version
or matches_constraint(candidate["version"], spec.version)
),
None,
)
if row is not None: if row is not None:
break break
if row is None: if row is None:

View File

@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS publishers (
ban_reason TEXT, ban_reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
,signing_public_key TEXT
); );
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (
@ -61,6 +62,8 @@ CREATE TABLE IF NOT EXISTS tools (
source_url TEXT, source_url TEXT,
source_json TEXT, source_json TEXT,
config_hash TEXT, config_hash TEXT,
content_hash TEXT,
dependency_hashes_json TEXT,
visibility TEXT DEFAULT 'public', visibility TEXT DEFAULT 'public',
moderation_status TEXT DEFAULT 'pending', moderation_status TEXT DEFAULT 'pending',
moderation_note TEXT, moderation_note TEXT,
@ -69,6 +72,7 @@ CREATE TABLE IF NOT EXISTS tools (
forked_from TEXT, forked_from TEXT,
forked_version TEXT, forked_version TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
attestation_json TEXT,
UNIQUE(owner, name, version) UNIQUE(owner, name, version)
); );
@ -94,6 +98,31 @@ CREATE TABLE IF NOT EXISTS tool_audits (
CREATE INDEX IF NOT EXISTS idx_tool_audits_tool_time CREATE INDEX IF NOT EXISTS idx_tool_audits_tool_time
ON tool_audits(tool_id, evaluated_at DESC, id DESC); ON tool_audits(tool_id, evaluated_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS improvement_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
submitter_id INTEGER NOT NULL REFERENCES publishers(id),
step_index INTEGER NOT NULL,
step_type TEXT NOT NULL,
original TEXT NOT NULL,
proposed TEXT NOT NULL,
rationale TEXT,
status TEXT NOT NULL DEFAULT 'tested',
test_result_json TEXT NOT NULL,
reviewer_id INTEGER REFERENCES publishers(id),
review_notes TEXT,
submitted_at TIMESTAMP NOT NULL,
reviewed_at TIMESTAMP
,applied_tool_id INTEGER REFERENCES tools(id)
);
CREATE TABLE IF NOT EXISTS tool_contributors (
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
contribution TEXT NOT NULL,
PRIMARY KEY (tool_id, publisher_id, contribution)
);
CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
name, description, tags, readme, name, description, tags, readme,
content='tools', content='tools',
@ -489,6 +518,8 @@ def migrate_db(conn: sqlite3.Connection) -> None:
("source_url", "TEXT", "NULL"), ("source_url", "TEXT", "NULL"),
("source_json", "TEXT", "NULL"), ("source_json", "TEXT", "NULL"),
("config_hash", "TEXT", "NULL"), ("config_hash", "TEXT", "NULL"),
("content_hash", "TEXT", "NULL"),
("dependency_hashes_json", "TEXT", "NULL"),
("visibility", "TEXT", "'public'"), ("visibility", "TEXT", "'public'"),
("moderation_status", "TEXT", "'pending'"), ("moderation_status", "TEXT", "'pending'"),
("moderation_note", "TEXT", "NULL"), ("moderation_note", "TEXT", "NULL"),
@ -497,6 +528,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
("forked_from", "TEXT", "NULL"), ("forked_from", "TEXT", "NULL"),
("forked_version", "TEXT", "NULL"), ("forked_version", "TEXT", "NULL"),
("defaults", "TEXT", "NULL"), ("defaults", "TEXT", "NULL"),
("attestation_json", "TEXT", "NULL"),
] ]
for col_name, col_type, default in tools_migrations: for col_name, col_type, default in tools_migrations:
@ -514,6 +546,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
("banned_at", "TIMESTAMP", "NULL"), ("banned_at", "TIMESTAMP", "NULL"),
("banned_by", "TEXT", "NULL"), ("banned_by", "TEXT", "NULL"),
("ban_reason", "TEXT", "NULL"), ("ban_reason", "TEXT", "NULL"),
("signing_public_key", "TEXT", "NULL"),
] ]
for col_name, col_type, default in publishers_migrations: for col_name, col_type, default in publishers_migrations:
@ -540,6 +573,18 @@ def migrate_db(conn: sqlite3.Connection) -> None:
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
cursor = conn.execute("PRAGMA table_info(improvement_submissions)")
improvement_cols = {row[1] for row in cursor.fetchall()}
if improvement_cols and "applied_tool_id" not in improvement_cols:
try:
conn.execute(
"ALTER TABLE improvement_submissions "
"ADD COLUMN applied_tool_id INTEGER REFERENCES tools(id)"
)
conn.commit()
except sqlite3.OperationalError:
pass
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL # Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
# This ensures existing tools remain visible after migration (one-time migration) # This ensures existing tools remain visible after migration (one-time migration)
# Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review # Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review
@ -573,6 +618,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)") conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_owner ON tools(owner)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)") conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_moderation ON tools(moderation_status, visibility)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_hash ON tools(config_hash)") conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_hash ON tools(config_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_content_hash ON tools(content_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)") conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_role ON publishers(role)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)") conn.execute("CREATE INDEX IF NOT EXISTS idx_publishers_banned ON publishers(banned)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_type, target_id)")

View File

@ -116,6 +116,10 @@ class DownloadResult:
readme: str = "" readme: str = ""
config_hash: str = "" # Registry hash for integrity verification config_hash: str = "" # Registry hash for integrity verification
defaults: str = "" # Default settings YAML content defaults: str = "" # Default settings YAML content
attestation: Optional[Dict[str, Any]] = None
signing_public_key: str = ""
content_hash: str = ""
dependency_hashes: Dict[str, str] = field(default_factory=dict)
class RegistryClient: class RegistryClient:
@ -562,8 +566,56 @@ class RegistryClient:
readme=data.get("readme", ""), readme=data.get("readme", ""),
config_hash=data.get("config_hash", ""), config_hash=data.get("config_hash", ""),
defaults=data.get("defaults", "") defaults=data.get("defaults", "")
,attestation=data.get("attestation")
,signing_public_key=data.get("signing_public_key", "")
,content_hash=data.get("content_hash", "")
,dependency_hashes=data.get("dependency_hashes", {})
) )
def get_tool_by_content_hash(self, content_hash: str) -> ToolInfo:
response = self._request("GET", f"/tools/by-content-hash/{content_hash}")
if response.status_code != 200:
self._handle_error_response(response)
return ToolInfo.from_dict(response.json().get("data", {}))
def set_signing_public_key(self, public_key: str) -> Dict[str, Any]:
"""Register the public key used to verify this publisher's releases."""
response = self._request(
"PUT", "/me/signing-key",
json_data={"public_key": public_key}, require_auth=True,
)
if response.status_code != 200:
self._handle_error_response(response)
return response.json().get("data", {})
def submit_improvement(
self, owner: str, name: str, version: str, step_index: int,
proposed: str, rationale: str = "",
) -> Dict[str, Any]:
response = self._request(
"POST", f"/tools/{owner}/{name}/{version}/improvements",
json_data={
"step_index": step_index, "proposed": proposed,
"rationale": rationale,
},
require_auth=True,
)
if response.status_code != 201:
self._handle_error_response(response)
return response.json().get("data", {})
def review_improvement(
self, submission_id: int, decision: str, notes: str = ""
) -> Dict[str, Any]:
response = self._request(
"PATCH", f"/improvements/{submission_id}",
json_data={"decision": decision, "notes": notes},
require_auth=True,
)
if response.status_code != 200:
self._handle_error_response(response)
return response.json().get("data", {})
def get_categories(self) -> List[Dict[str, Any]]: def get_categories(self) -> List[Dict[str, Any]]:
""" """
Get list of tool categories. Get list of tool categories.
@ -623,7 +675,9 @@ class RegistryClient:
defaults: str = "", defaults: str = "",
dry_run: bool = False, dry_run: bool = False,
visibility: str = "public", visibility: str = "public",
owner: str = "" owner: str = "",
attestation: Optional[Dict[str, Any]] = None,
improvement_id: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Publish a tool to the registry. Publish a tool to the registry.
@ -649,6 +703,10 @@ class RegistryClient:
payload["defaults"] = defaults payload["defaults"] = defaults
if owner: if owner:
payload["owner"] = owner payload["owner"] = owner
if attestation:
payload["attestation"] = attestation
if improvement_id is not None:
payload["improvement_id"] = improvement_id
response = self._request( response = self._request(
"POST", "POST",

View File

@ -354,7 +354,10 @@ class ToolResolver:
config_yaml=result.config_yaml, config_yaml=result.config_yaml,
readme=result.readme, readme=result.readme,
config_hash=result.config_hash, config_hash=result.config_hash,
defaults=result.defaults defaults=result.defaults,
attestation=result.attestation,
signing_public_key=result.signing_public_key,
content_hash=result.content_hash,
) )
if self.verbose: if self.verbose:
@ -383,7 +386,10 @@ class ToolResolver:
config_yaml: str, config_yaml: str,
readme: str = "", readme: str = "",
config_hash: str = "", config_hash: str = "",
defaults: str = "" defaults: str = "",
attestation: Optional[dict] = None,
signing_public_key: str = "",
content_hash: str = "",
) -> ResolvedTool: ) -> ResolvedTool:
"""Install a tool fetched from registry to global directory.""" """Install a tool fetched from registry to global directory."""
# Verify hash if provided # Verify hash if provided
@ -396,6 +402,24 @@ class ToolResolver:
f"got {computed_hash[:20]}... - content may have been tampered with" f"got {computed_hash[:20]}... - content may have been tampered with"
) )
if attestation:
from .attestation import Attestation, verify_attestation
try:
signed = Attestation.from_dict(attestation)
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError("Malformed release attestation") from exc
if (
not signing_public_key
or signed.tool_name != name
or signed.version != version
or signed.content_hash != content_hash
or signed.signer != owner
or not verify_attestation(signed, signing_public_key)
):
raise RuntimeError(
f"Invalid release attestation for {owner}/{name}@{version}"
)
# Create directory structure # Create directory structure
tool_dir = TOOLS_DIR / owner / name tool_dir = TOOLS_DIR / owner / name
tool_dir.mkdir(parents=True, exist_ok=True) tool_dir.mkdir(parents=True, exist_ok=True)
@ -628,7 +652,10 @@ def install_from_registry(spec: str, version: Optional[str] = None) -> ResolvedT
config_yaml=result.config_yaml, config_yaml=result.config_yaml,
readme=result.readme, readme=result.readme,
config_hash=result.config_hash, config_hash=result.config_hash,
defaults=result.defaults defaults=result.defaults,
attestation=result.attestation,
signing_public_key=result.signing_public_key,
content_hash=result.content_hash,
) )

View File

@ -1234,6 +1234,14 @@ def main():
else: else:
print(output) print(output)
if exit_code == 0 and not args.dry_run:
try:
from .usage import record_invocation
record_invocation(resolved.full_name)
except (OSError, ValueError):
# Usage discovery is optional and must never break tool execution.
pass
sys.exit(exit_code) sys.exit(exit_code)

43
src/cmdforge/signing.py Normal file
View File

@ -0,0 +1,43 @@
"""Local publisher signing-key storage."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional, Tuple
from .attestation import generate_keypair
from .config import CONFIG_DIR
SIGNING_KEY_FILE = CONFIG_DIR / "release-signing-key.json"
def load_signing_key(path: Path = SIGNING_KEY_FILE) -> Optional[Tuple[str, str]]:
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
private_key = data.get("private_key", "")
public_key = data.get("public_key", "")
if not private_key or not public_key:
raise ValueError("Signing key file is incomplete")
return private_key, public_key
def initialize_signing_key(
path: Path = SIGNING_KEY_FILE, *, overwrite: bool = False
) -> Tuple[str, str]:
if path.exists() and not overwrite:
existing = load_signing_key(path)
if existing is None:
raise ValueError("Signing key file could not be loaded")
return existing
private_key, public_key = generate_keypair()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
path.write_text(json.dumps({
"algorithm": "ed25519",
"private_key": private_key,
"public_key": public_key,
}, indent=2) + "\n", encoding="utf-8")
os.chmod(path, 0o600)
return private_key, public_key

202
src/cmdforge/usage.py Normal file
View File

@ -0,0 +1,202 @@
"""Opt-in, local-only pipeline usage discovery.
Only tool names, anonymous pipe inode numbers, counts, and timestamps are
stored. Inputs, outputs, arguments, environment, and working directories are
never recorded.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterator, List, Optional
from .config import CONFIG_DIR
USAGE_FILE = CONFIG_DIR / "usage.json"
USAGE_LOCK_FILE = CONFIG_DIR / ".usage.lock"
USAGE_VERSION = 1
EVENT_TTL_SECONDS = 120
MAX_EVENTS = 200
DEFAULT_THRESHOLD = 3
def _empty_document(enabled: bool = False) -> dict:
return {
"version": USAGE_VERSION,
"enabled": enabled,
"events": [],
"pipelines": {},
}
@contextmanager
def _locked() -> Iterator[None]:
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
with open(USAGE_LOCK_FILE, "a+", encoding="utf-8") as lock:
os.chmod(USAGE_LOCK_FILE, 0o600)
try:
import fcntl
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
except ImportError:
pass
try:
yield
finally:
try:
import fcntl
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
except ImportError:
pass
def _load_unlocked() -> dict:
if not USAGE_FILE.exists():
return _empty_document()
try:
data = json.loads(USAGE_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return _empty_document()
if not isinstance(data, dict) or data.get("version") != USAGE_VERSION:
return _empty_document()
return data
def _save_unlocked(data: dict) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
temp_path = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=CONFIG_DIR,
prefix=".usage-", suffix=".tmp", delete=False,
) as handle:
temp_path = Path(handle.name)
os.chmod(temp_path, 0o600)
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, USAGE_FILE)
os.chmod(USAGE_FILE, 0o600)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
def is_enabled() -> bool:
with _locked():
return bool(_load_unlocked().get("enabled", False))
def set_enabled(enabled: bool) -> None:
with _locked():
data = _load_unlocked()
data["enabled"] = bool(enabled)
if not enabled:
data["events"] = []
_save_unlocked(data)
def clear_usage() -> None:
with _locked():
enabled = bool(_load_unlocked().get("enabled", False))
_save_unlocked(_empty_document(enabled=enabled))
def _pipe_inode(stream) -> Optional[int]:
try:
info = os.fstat(stream.fileno())
except (AttributeError, OSError, ValueError):
return None
return info.st_ino if stat.S_ISFIFO(info.st_mode) else None
def record_invocation(tool_name: str, stdin=None, stdout=None) -> None:
"""Record a successful invocation when it participates in a shell pipe."""
import sys
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
input_pipe = _pipe_inode(stdin)
output_pipe = _pipe_inode(stdout)
if input_pipe is None and output_pipe is None:
return
now = datetime.now(timezone.utc)
timestamp = now.timestamp()
with _locked():
data = _load_unlocked()
if not data.get("enabled", False):
return
events = [
event for event in data.get("events", [])
if timestamp - float(event.get("time", 0)) <= EVENT_TTL_SECONDS
]
discovered = set()
for event in events:
if input_pipe is not None and event.get("stdout_pipe") == input_pipe:
discovered.add((event["tool"], tool_name))
if output_pipe is not None and event.get("stdin_pipe") == output_pipe:
discovered.add((tool_name, event["tool"]))
pipelines = data.setdefault("pipelines", {})
for first, second in discovered:
if first == second:
continue
key = json.dumps([first, second], separators=(",", ":"))
current = pipelines.setdefault(key, {"tools": [first, second], "count": 0})
current["count"] = int(current.get("count", 0)) + 1
current["last_seen"] = now.isoformat()
events.append({
"tool": tool_name,
"time": timestamp,
"stdin_pipe": input_pipe,
"stdout_pipe": output_pipe,
})
data["events"] = events[-MAX_EVENTS:]
_save_unlocked(data)
def get_suggestions(threshold: int = DEFAULT_THRESHOLD) -> List[Dict]:
with _locked():
data = _load_unlocked()
if not data.get("enabled", False):
return []
suggestions = [
{
"tools": list(item.get("tools", [])),
"count": int(item.get("count", 0)),
"last_seen": item.get("last_seen", ""),
}
for item in data.get("pipelines", {}).values()
if int(item.get("count", 0)) >= threshold
and len(item.get("tools", [])) >= 2
]
return sorted(suggestions, key=lambda item: (-item["count"], item["tools"]))
def build_composite_tool(name: str, tools: List[str]):
"""Build, but do not save, a Tool that composes the suggested pipeline."""
from .tool import Tool, ToolStep
if len(tools) < 2:
raise ValueError("A composite requires at least two tools")
steps = []
previous = "input"
for index, tool_name in enumerate(tools, start=1):
output_var = f"pipeline_{index}"
steps.append(ToolStep(
tool=tool_name,
input_template="{input}" if previous == "input" else f"{{{previous}}}",
output_var=output_var,
))
previous = output_var
return Tool(
name=name,
description="Composite pipeline: " + " | ".join(tools),
dependencies=list(dict.fromkeys(tools)),
steps=steps,
output=f"{{{previous}}}",
)

View File

@ -2,44 +2,70 @@
from cmdforge.attestation import ( from cmdforge.attestation import (
Attestation, Attestation,
generate_keypair,
sign_tool, sign_tool,
verify_attestation, verify_attestation,
verify_content_hash, verify_content_hash,
verify_trusted_attestation,
) )
from cmdforge.tool import Tool from cmdforge.tool import Tool
class TestSignTool: class TestSignTool:
def test_creates_attestation(self): def test_creates_attestation(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key") private, public = generate_keypair()
att = sign_tool("mytool", "1.0.0", "abc123", "alice", private)
assert att.tool_name == "mytool" assert att.tool_name == "mytool"
assert att.version == "1.0.0" assert att.version == "1.0.0"
assert att.content_hash == "abc123" assert att.content_hash == "abc123"
assert att.signer == "alice" assert att.signer == "alice"
assert len(att.signature) == 64 # SHA256 hex assert len(att.signature) == 88
assert att.algorithm == "hmac-sha256" assert att.algorithm == "ed25519"
assert verify_attestation(att, public)
def test_different_keys_different_signatures(self): def test_different_keys_different_signatures(self):
att1 = sign_tool("tool", "1.0.0", "hash", "alice", "key1") key1, _ = generate_keypair()
att2 = sign_tool("tool", "1.0.0", "hash", "alice", "key2") key2, _ = generate_keypair()
att1 = sign_tool("tool", "1.0.0", "hash", "alice", key1)
att2 = sign_tool("tool", "1.0.0", "hash", "alice", key2)
assert att1.signature != att2.signature assert att1.signature != att2.signature
class TestVerifyAttestation: class TestVerifyAttestation:
def test_valid_signature(self): def test_valid_signature(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key") private, public = generate_keypair()
assert verify_attestation(att, "secret-key") att = sign_tool("mytool", "1.0.0", "abc123", "alice", private)
assert verify_attestation(att, public)
def test_wrong_key_fails(self): def test_wrong_key_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key") private, _ = generate_keypair()
assert not verify_attestation(att, "wrong-key") _, wrong_public = generate_keypair()
att = sign_tool("mytool", "1.0.0", "abc123", "alice", private)
assert not verify_attestation(att, wrong_public)
def test_tampered_content_fails(self): def test_tampered_content_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key") from dataclasses import replace
att.content_hash = "tampered" private, public = generate_keypair()
assert not verify_attestation(att, "secret-key") att = sign_tool("mytool", "1.0.0", "abc123", "alice", private)
att = replace(att, content_hash="tampered")
assert not verify_attestation(att, public)
def test_tampered_signer_fails(self): def test_tampered_signer_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key") from dataclasses import replace
att.signer = "eve" private, public = generate_keypair()
assert not verify_attestation(att, "secret-key") att = sign_tool("mytool", "1.0.0", "abc123", "alice", private)
att = replace(att, signer="eve")
assert not verify_attestation(att, public)
def test_timestamp_and_algorithm_are_signed(self):
from dataclasses import replace
private, public = generate_keypair()
att = sign_tool("tool", "1.0.0", "hash", "alice", private)
assert not verify_attestation(replace(att, signed_at="tomorrow"), public)
assert not verify_attestation(replace(att, algorithm="hmac-sha256"), public)
def test_requires_trusted_publisher_mapping(self):
private, public = generate_keypair()
att = sign_tool("tool", "1.0.0", "hash", "alice", private)
assert verify_trusted_attestation(att, {"alice": public})
assert not verify_trusted_attestation(att, {"mallory": public})

View File

@ -650,3 +650,76 @@ def test_switch_to_existing_tool_closes_creation_page_first():
call.close_tool_builder(), call.close_tool_builder(),
call.open_tool_builder("existing"), call.open_tool_builder("existing"),
] ]
def test_guided_extraction_builds_new_tool_without_mutating_draft():
pytest.importorskip("PySide6")
from cmdforge.gui.pages.tool_builder_page import (
build_extracted_tool,
extraction_diff,
)
schema = {"type": "string"}
repeated = [
PromptStep(
prompt="First {input}", provider="mock", output_var="first",
output_schema=schema,
),
PromptStep(
prompt="Second {first}", provider="mock", output_var="second",
output_schema=schema,
),
]
draft = Tool(
name="draft", steps=repeated + [
PromptStep(
prompt="First {input}", provider="mock", output_var="first_copy",
output_schema=schema,
),
PromptStep(
prompt="Second {first}", provider="mock", output_var="second_copy",
output_schema=schema,
),
], input_schema=schema,
)
before = draft.to_dict()
extracted = build_extracted_tool(
draft,
{"type": "repeated_sequence", "locations": [1, 3], "length": 2},
"draft-shared",
)
assert len(extracted.steps) == 2
assert extracted.output == "{second}"
assert extracted.output_schema == schema
assert draft.to_dict() == before
assert "+++ draft-shared/config.yaml" in extraction_diff(extracted)
def test_guided_extraction_rejects_hidden_external_step_dependency():
pytest.importorskip("PySide6")
from cmdforge.gui.pages.tool_builder_page import build_extracted_tool
schema = {"type": "string"}
draft = Tool(name="draft", steps=[
PromptStep(
prompt="Produce", provider="mock", output_var="earlier",
output_schema=schema,
),
PromptStep(
prompt="Use {earlier}", provider="mock", output_var="one",
output_schema=schema,
),
PromptStep(
prompt="Finish {one}", provider="mock", output_var="two",
output_schema=schema,
),
])
with pytest.raises(ValueError, match="outside its boundary"):
build_extracted_tool(
draft,
{"type": "repeated_sequence", "locations": [2, 4], "length": 2},
"draft-shared",
)

View File

@ -339,6 +339,146 @@ class TestPublishPreflightEndpoint:
assert response.status_code == 200 assert response.status_code == 200
@flask_required
class TestM9RegistryTrustAndCommunity:
def test_signed_publish_download_and_content_lookup(
self, client, auth_headers
):
from cmdforge.attestation import (
Attestation, generate_keypair, sign_tool, verify_attestation,
)
from cmdforge.registry.db import connect_db
private_key, public_key = generate_keypair()
response = client.put(
"/api/v1/me/signing-key", headers=auth_headers,
json={"public_key": public_key},
)
assert response.status_code == 200
config = "name: signed-tool\nversion: 1.0.0\noutput: stable\n"
preflight = client.post(
"/api/v1/tools", headers=auth_headers,
json={"config": config, "dry_run": True},
)
assert preflight.status_code == 200
content_hash = preflight.get_json()["data"]["content_hash"]
attestation = sign_tool(
"signed-tool", "1.0.0", content_hash, "testuser", private_key
)
published = client.post(
"/api/v1/tools", headers=auth_headers,
json={"config": config, "attestation": attestation.to_dict()},
)
assert published.status_code == 201
assert published.get_json()["data"]["content_hash"] == content_hash
conn = connect_db()
try:
conn.execute(
"UPDATE tools SET moderation_status = 'approved' "
"WHERE owner = 'testuser' AND name = 'signed-tool'"
)
conn.commit()
finally:
conn.close()
downloaded = client.get(
"/api/v1/tools/testuser/signed-tool/download?install=false"
)
assert downloaded.status_code == 200
data = downloaded.get_json()["data"]
assert data["content_hash"] == content_hash
assert verify_attestation(Attestation.from_dict(data["attestation"]), public_key)
lookup = client.get(f"/api/v1/tools/by-content-hash/{content_hash}")
assert lookup.status_code == 200
assert lookup.get_json()["data"]["name"] == "signed-tool"
def test_registered_key_requires_valid_signature(self, client, auth_headers):
from cmdforge.attestation import generate_keypair
_, public_key = generate_keypair()
assert client.put(
"/api/v1/me/signing-key", headers=auth_headers,
json={"public_key": public_key},
).status_code == 200
response = client.post(
"/api/v1/tools", headers=auth_headers,
json={"config": "name: unsigned-tool\nversion: 1.0.0\n"},
)
assert response.status_code == 400
assert response.get_json()["error"]["code"] == "ATTESTATION_REQUIRED"
def test_improvement_is_tested_reviewed_and_credited(
self, client, auth_headers
):
from cmdforge.registry.db import connect_db
config = (
"name: improvable\nversion: 1.0.0\noutput: '{result}'\n"
"input_schema:\n type: string\noutput_schema:\n type: string\n"
"steps:\n - type: prompt\n prompt: 'Summarize: {input}'\n"
" provider: mock\n output_var: result\n"
)
published = client.post(
"/api/v1/tools", headers=auth_headers, json={"config": config}
)
assert published.status_code == 201
conn = connect_db()
try:
conn.execute(
"UPDATE tools SET moderation_status = 'approved' "
"WHERE owner = 'testuser' AND name = 'improvable'"
)
conn.commit()
finally:
conn.close()
submitted = client.post(
"/api/v1/tools/testuser/improvable/1.0.0/improvements",
headers=auth_headers,
json={
"step_index": 0,
"proposed": "Summarize the input accurately and concisely: {input}",
"rationale": "Clearer expected behavior",
},
)
assert submitted.status_code == 201
submission_id = submitted.get_json()["data"]["id"]
reviewed = client.patch(
f"/api/v1/improvements/{submission_id}", headers=auth_headers,
json={"decision": "approve", "notes": "Validated"},
)
assert reviewed.status_code == 200
assert reviewed.get_json()["data"]["ready_to_apply"] is True
improved_config = config.replace("version: 1.0.0", "version: 1.0.1").replace(
"Summarize: {input}",
"Summarize the input accurately and concisely: {input}",
)
applied = client.post(
"/api/v1/tools", headers=auth_headers,
json={"config": improved_config, "improvement_id": submission_id},
)
assert applied.status_code == 201
conn = connect_db()
try:
conn.execute(
"UPDATE tools SET moderation_status = 'approved' "
"WHERE owner = 'testuser' AND name = 'improvable' "
"AND version = '1.0.1'"
)
conn.commit()
credited = conn.execute(
"SELECT COUNT(*) AS count FROM tool_contributors"
).fetchone()["count"]
finally:
conn.close()
assert credited == 1
detail = client.get("/api/v1/tools/testuser/improvable?version=1.0.1")
assert detail.status_code == 200
assert detail.get_json()["data"]["badges"] == [
"optimized", "community-reviewed"
]
@flask_required @flask_required
class TestPostCollectionsEndpoint: class TestPostCollectionsEndpoint:
"""Tests for POST /api/v1/collections endpoint.""" """Tests for POST /api/v1/collections endpoint."""

View File

@ -49,11 +49,11 @@ class TestReviewSubmission:
sub = ImprovementSubmission( sub = ImprovementSubmission(
tool_name="test", tool_version="1.0.0", submitter="alice", tool_name="test", tool_version="1.0.0", submitter="alice",
step_index=0, step_type="prompt", original="a", proposed="b", step_index=0, step_type="prompt", original="a", proposed="b",
status="tested", status="tested", test_result={"passed_for_review": True},
) )
review = review_submission(sub, "approve", "admin", "Good improvement") review = review_submission(sub, "approve", "admin", "Good improvement")
assert review.decision == "approve" assert review.decision == "approve"
assert sub.status == "approve" assert sub.status == "approved"
def test_review_pending_rejected(self): def test_review_pending_rejected(self):
sub = ImprovementSubmission( sub = ImprovementSubmission(

View File

@ -44,12 +44,14 @@ class TestIntegrityChain:
) )
chain = build_integrity_chain(tool) chain = build_integrity_chain(tool)
assert "missing-dep" not in chain.nodes assert "missing-dep" not in chain.nodes
# Chain is still valid because the dependency hash is "unresolved" assert not chain.is_valid
# and there's no node to compare against assert chain.errors == ["Unresolved dependency: missing-dep"]
assert chain.is_valid
def test_chain_valid_with_resolved_dep(self, tmp_path): def test_chain_valid_with_resolved_dep(self, tmp_path):
with patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"): with (
patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"),
patch("cmdforge.tool.BIN_DIR", tmp_path / "bin"),
):
from cmdforge.tool import save_tool from cmdforge.tool import save_tool
child = Tool(name="child", version="1.0.0") child = Tool(name="child", version="1.0.0")
@ -67,13 +69,35 @@ class TestIntegrityChain:
assert "child" in chain.nodes assert "child" in chain.nodes
assert chain.is_valid assert chain.is_valid
def test_child_change_changes_root_identity(self, tmp_path):
with (
patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"),
patch("cmdforge.tool.BIN_DIR", tmp_path / "bin"),
):
from cmdforge.tool import save_tool
child = Tool(name="child", version="1.0.0", output="one")
save_tool(child)
parent = Tool(
name="parent", version="1.0.0",
steps=[ToolStep(tool="child", output_var="x")], output="{x}",
)
first = build_integrity_chain(parent).root.content_hash
child.output = "two"
save_tool(child)
second = build_integrity_chain(parent).root.content_hash
assert first != second
class TestVerifyIntegrity: class TestVerifyIntegrity:
def test_valid_chain(self): def test_valid_chain(self):
chain = IntegrityChain( from cmdforge.integrity import compute_content_identity
root=IntegrityNode(name="root", content_hash="abc"), definition_hash = "sha256:" + "a" * 64
nodes={"root": IntegrityNode(name="root", content_hash="abc")}, content_hash = compute_content_identity(definition_hash, {})
node = IntegrityNode(
name="root", definition_hash=definition_hash,
content_hash=content_hash,
) )
chain = IntegrityChain(root=node, nodes={"root": node})
assert verify_integrity(chain) assert verify_integrity(chain)
def test_tampered_chain(self): def test_tampered_chain(self):

View File

@ -259,6 +259,12 @@ class TestGenerateLockfile:
assert "official/text-utils" in lock.packages assert "official/text-utils" in lock.packages
assert lock.packages["official/summarize"].direct is True assert lock.packages["official/summarize"].direct is True
assert lock.packages["official/text-utils"].direct is False assert lock.packages["official/text-utils"].direct is False
child_identity = lock.packages["official/text-utils"].content_hash
assert child_identity.startswith("sha256:")
assert lock.packages["official/summarize"].dependency_hashes == {
"official/text-utils": child_identity
}
assert lock.packages["official/summarize"].content_hash.startswith("sha256:")
class TestVerifyLockfile: class TestVerifyLockfile:
@ -319,7 +325,9 @@ class TestLockfileRoundTrip:
integrity="sha256:abc123", integrity="sha256:abc123",
source="registry", source="registry",
direct=True, direct=True,
required_by=[] required_by=[],
content_hash="sha256:root",
dependency_hashes={"official/text-utils": "sha256:child"},
), ),
"official/text-utils": LockedPackage( "official/text-utils": LockedPackage(
name="official/text-utils", name="official/text-utils",
@ -364,6 +372,10 @@ class TestLockfileRoundTrip:
assert summarize.integrity == "sha256:abc123" assert summarize.integrity == "sha256:abc123"
assert summarize.source == "registry" assert summarize.source == "registry"
assert summarize.direct is True assert summarize.direct is True
assert summarize.content_hash == "sha256:root"
assert summarize.dependency_hashes == {
"official/text-utils": "sha256:child"
}
text_utils = loaded.packages["official/text-utils"] text_utils = loaded.packages["official/text-utils"]
assert text_utils.direct is False assert text_utils.direct is False

View File

@ -12,6 +12,7 @@ from cmdforge.mcp_client import (
McpServerConfig, McpServerConfig,
McpClientManager, McpClientManager,
_build_server_env, _build_server_env,
_build_http_headers,
_normalize_result, _normalize_result,
_sanitize, _sanitize,
_fingerprint, _fingerprint,
@ -34,14 +35,15 @@ class TestMcpServerConfig:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"change, message", "change, message",
[ [
({"transport": "streamable-http"}, "Unsupported MCP transport"), ({"transport": "streamable-http"}, "requires a url"),
({"args": "-y"}, "list of strings"), ({"args": "-y"}, "list of strings"),
({"timeout": 0}, "greater than 0"), ({"timeout": 0}, "greater than 0"),
({"timeout": True}, "must be a number"), ({"timeout": True}, "must be a number"),
], ],
) )
def test_validation_rejects_invalid_config(self, change, message): def test_validation_rejects_invalid_config(self, change, message):
cfg = McpServerConfig(name="test", command="server", **change) command = None if change.get("transport") == "streamable-http" else "server"
cfg = McpServerConfig(name="test", command=command, **change)
with pytest.raises(ValueError, match=message): with pytest.raises(ValueError, match=message):
cfg.validate() cfg.validate()
@ -55,6 +57,31 @@ class TestMcpServerConfig:
b = McpServerConfig(name="b", command="cmd", args=["-v"]) b = McpServerConfig(name="b", command="cmd", args=["-v"])
assert _fingerprint(a) == _fingerprint(b) assert _fingerprint(a) == _fingerprint(b)
def test_streamable_http_accepts_https(self):
cfg = McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp", approved=True,
)
cfg.validate()
def test_streamable_http_allows_loopback_http_only(self):
McpServerConfig(
name="local", transport="streamable-http",
url="http://127.0.0.1:8000/mcp",
).validate()
with pytest.raises(ValueError, match="must use HTTPS"):
McpServerConfig(
name="remote", transport="streamable-http",
url="http://example.com/mcp",
).validate()
def test_streamable_http_rejects_url_credentials(self):
with pytest.raises(ValueError, match="must not contain credentials"):
McpServerConfig(
name="remote", transport="streamable-http",
url="https://user:pass@example.com/mcp",
).validate()
class TestSanitize: class TestSanitize:
def test_redacts_bearer(self): def test_redacts_bearer(self):
@ -85,6 +112,15 @@ class TestEnvironmentIsolation:
) )
assert _build_server_env(cfg)["SERVER_TOKEN"] == "allowed" assert _build_server_env(cfg)["SERVER_TOKEN"] == "allowed"
def test_http_header_reference_is_resolved(self, monkeypatch):
monkeypatch.setenv("MCP_TOKEN", "secret")
cfg = McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp",
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
)
assert _build_http_headers(cfg)["Authorization"] == "Bearer secret"
def test_missing_environment_reference_fails(self, monkeypatch): def test_missing_environment_reference_fails(self, monkeypatch):
monkeypatch.delenv("CMDFORGE_MISSING", raising=False) monkeypatch.delenv("CMDFORGE_MISSING", raising=False)
cfg = McpServerConfig( cfg = McpServerConfig(
@ -122,6 +158,19 @@ class TestMcpConfigPersistence:
def test_load_no_file(self, temp_mcp_file): def test_load_no_file(self, temp_mcp_file):
assert load_mcp_config() == [] assert load_mcp_config() == []
def test_streamable_http_round_trip(self, temp_mcp_file):
save_mcp_config([McpServerConfig(
name="remote", transport="streamable-http",
url="https://example.com/mcp",
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
approved=True,
)])
loaded = load_mcp_config()[0]
assert loaded.transport == "streamable-http"
assert loaded.command is None
assert loaded.url == "https://example.com/mcp"
assert loaded.headers == {"Authorization": "Bearer ${MCP_TOKEN}"}
def test_save_creates_0600_permissions(self, temp_mcp_file): def test_save_creates_0600_permissions(self, temp_mcp_file):
save_mcp_config([McpServerConfig(name="test", command="echo")]) save_mcp_config([McpServerConfig(name="test", command="echo")])
perms = oct(temp_mcp_file.stat().st_mode & 0o777) perms = oct(temp_mcp_file.stat().st_mode & 0o777)
@ -651,6 +700,45 @@ class TestRegisteredMcpTool:
serve() serve()
class TestStreamableHttpServerSafety:
def test_defaults_are_loopback_with_origin_allowlist(self):
from cmdforge.mcp_server import _validate_http_server_options
origins, token = _validate_http_server_options(
"127.0.0.1", 8000, [], None, None
)
assert token is None
assert origins == [
"http://localhost:8000", "http://127.0.0.1:8000"
]
def test_nonlocal_requires_https_and_auth(self):
from cmdforge.mcp_server import _validate_http_server_options
with pytest.raises(ValueError, match="external-url"):
_validate_http_server_options("0.0.0.0", 8000, [], "secret", None)
with pytest.raises(ValueError, match="auth-token"):
_validate_http_server_options(
"0.0.0.0", 8000, [], None, "https://mcp.example.com"
)
def test_nonlocal_infers_https_origin_and_expands_token(self, monkeypatch):
from cmdforge.mcp_server import _validate_http_server_options
monkeypatch.setenv("MCP_AUTH", "secret-value")
origins, token = _validate_http_server_options(
"0.0.0.0", 8000, [], "${MCP_AUTH}",
"https://mcp.example.com",
)
assert origins == ["https://mcp.example.com"]
assert token == "secret-value"
def test_nonlocal_rejects_insecure_origin(self):
from cmdforge.mcp_server import _validate_http_server_options
with pytest.raises(ValueError, match="origins must use HTTPS"):
_validate_http_server_options(
"0.0.0.0", 8000, ["http://example.com"], "secret",
"https://mcp.example.com",
)
class TestCmdForgeMcpServerEndToEnd: class TestCmdForgeMcpServerEndToEnd:
def test_stdio_discovery_and_invocation(self, tmp_path, monkeypatch): def test_stdio_discovery_and_invocation(self, tmp_path, monkeypatch):
pytest.importorskip("mcp") pytest.importorskip("mcp")

View File

@ -23,6 +23,21 @@ class TestTruncate:
class TestGenerateVariations: class TestGenerateVariations:
def test_mock_provider_never_calls_external_provider(self):
with patch("cmdforge.providers.call_provider") as call:
_get_strategies("Summarize the text", 2, provider="mock")
call.assert_not_called()
def test_selected_provider_is_honored(self):
from cmdforge.providers import ProviderResult
with patch(
"cmdforge.providers.call_provider",
return_value=ProviderResult(text="A materially clearer instruction", success=True),
) as call:
result = _get_strategies("Summarize the text", 1, provider="chosen")
assert result[0][1] == "A materially clearer instruction"
assert call.call_args.args[0] == "chosen"
def test_generates_for_prompt_steps(self, tmp_path): def test_generates_for_prompt_steps(self, tmp_path):
from cmdforge.tool import Tool, PromptStep from cmdforge.tool import Tool, PromptStep
@ -75,6 +90,29 @@ class TestGetStrategies:
class TestOptimizeTool: class TestOptimizeTool:
def test_structural_results_do_not_claim_a_best_prompt(self):
from cmdforge.tool import Tool, PromptStep
tool = Tool(
name="structural", steps=[PromptStep("Summarize", "mock", "out")],
output="{out}", input_schema={"type": "string"},
output_schema={"type": "string"},
)
result = optimize_tool(tool, count=2)
assert result.best is None
assert "cannot compare prompt semantics" in result.note
def test_behavioral_evaluator_selects_only_an_improvement(self):
from cmdforge.tool import Tool, PromptStep
tool = Tool(
name="behavioral", steps=[PromptStep("Do it", "mock", "out")],
output="{out}",
)
result = optimize_tool(
tool, count=3,
evaluator=lambda candidate: len(candidate.steps[0].prompt),
)
assert result.best is not None
assert result.scores[result.best_index] > result.baseline_score
def test_baseline_score_recorded(self, tmp_path): def test_baseline_score_recorded(self, tmp_path):
from cmdforge.tool import Tool, PromptStep from cmdforge.tool import Tool, PromptStep

11
tests/test_signing.py Normal file
View File

@ -0,0 +1,11 @@
import stat
from cmdforge.signing import initialize_signing_key, load_signing_key
def test_signing_key_is_persistent_and_private(tmp_path):
path = tmp_path / "release-key.json"
created = initialize_signing_key(path)
assert load_signing_key(path) == created
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert initialize_signing_key(path) == created

67
tests/test_usage.py Normal file
View File

@ -0,0 +1,67 @@
import json
import stat
from cmdforge.usage import (
build_composite_tool,
clear_usage,
get_suggestions,
is_enabled,
record_invocation,
set_enabled,
)
class Stream:
def __init__(self, inode=None):
self.inode = inode
def configure_paths(tmp_path, monkeypatch):
monkeypatch.setattr("cmdforge.usage.CONFIG_DIR", tmp_path)
monkeypatch.setattr("cmdforge.usage.USAGE_FILE", tmp_path / "usage.json")
monkeypatch.setattr("cmdforge.usage.USAGE_LOCK_FILE", tmp_path / ".usage.lock")
def test_tracking_is_disabled_by_default(tmp_path, monkeypatch):
configure_paths(tmp_path, monkeypatch)
assert not is_enabled()
monkeypatch.setattr("cmdforge.usage._pipe_inode", lambda stream: stream.inode)
record_invocation("one", Stream(), Stream())
assert not (tmp_path / "usage.json").exists()
def test_detects_only_pipe_link_and_stores_no_content(tmp_path, monkeypatch):
configure_paths(tmp_path, monkeypatch)
set_enabled(True)
monkeypatch.setattr("cmdforge.usage._pipe_inode", lambda stream: stream.inode)
for inode in range(44, 47):
record_invocation("tool-a", Stream(), Stream(inode))
record_invocation("tool-b", Stream(inode), Stream())
assert get_suggestions() == [{
"tools": ["tool-a", "tool-b"],
"count": 3,
"last_seen": get_suggestions()[0]["last_seen"],
}]
data = json.loads((tmp_path / "usage.json").read_text())
serialized = json.dumps(data)
for forbidden in ("input_text", "output", "arguments", "cwd", "environment"):
assert forbidden not in serialized
assert stat.S_IMODE((tmp_path / "usage.json").stat().st_mode) == 0o600
def test_disable_and_clear_preserve_consent_state(tmp_path, monkeypatch):
configure_paths(tmp_path, monkeypatch)
set_enabled(True)
clear_usage()
assert is_enabled()
set_enabled(False)
assert not is_enabled()
def test_build_composite_tool_preserves_pipeline_order():
tool = build_composite_tool("combined", ["tool-a", "tool-b"])
assert tool.dependencies == ["tool-a", "tool-b"]
assert tool.steps[0].input_template == "{input}"
assert tool.steps[1].input_template == "{pipeline_1}"
assert tool.output == "{pipeline_2}"