Harden M9: Ed25519 attestation, full SHA-256 integrity, locked-by-default, prompt improvements, registry verification
This commit is contained in:
parent
04153a4bbd
commit
56cb34276e
69
README.md
69
README.md
|
|
@ -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**:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
return False
|
||||||
for name, node in self.nodes.items():
|
for name, node in self.nodes.items():
|
||||||
|
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:
|
for dep_name in node.dependencies:
|
||||||
expected_hash = node.dependency_hashes.get(dep_name)
|
dep = self.nodes.get(dep_name)
|
||||||
if expected_hash == "unresolved":
|
if dep is None or node.dependency_hashes[dep_name] != dep.content_hash:
|
||||||
# Unresolved dependencies don't invalidate the chain
|
|
||||||
continue
|
|
||||||
if dep_name not in self.nodes:
|
|
||||||
return False
|
|
||||||
dep_node = self.nodes[dep_name]
|
|
||||||
if expected_hash and expected_hash != dep_node.content_hash:
|
|
||||||
return False
|
return False
|
||||||
|
expected = _identity_hash(node.definition_hash, node.dependency_hashes)
|
||||||
|
if not _constant_time_equal(expected, node.content_hash):
|
||||||
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 not isinstance(self.command, str) or not self.command.strip():
|
if self.transport == "stdio":
|
||||||
raise ValueError(f"MCP server '{self.name}' requires a command")
|
if not isinstance(self.command, str) or not self.command.strip():
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,16 +111,25 @@ 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:
|
||||||
rephrased = _call_provider_for_variation(
|
return []
|
||||||
f"Rephrase this instruction while keeping the same meaning. Output only the rephrased text:\n\n{original}"
|
|
||||||
)
|
# Provider calls are explicit. The default/mock path is deterministic and
|
||||||
if rephrased and rephrased != original:
|
# never sends a user's prompt to an external process or service.
|
||||||
strategies.append(("rephrase", rephrased))
|
if provider != "mock":
|
||||||
|
rephrased = _call_provider_for_variation(
|
||||||
|
provider,
|
||||||
|
"Rephrase this instruction while keeping the same meaning. "
|
||||||
|
f"Output only the rephrased text:\n\n{original}",
|
||||||
|
)
|
||||||
|
if rephrased and rephrased != original:
|
||||||
|
strategies.append(("rephrase", rephrased))
|
||||||
|
|
||||||
# Deterministic mock variations
|
# Deterministic mock variations
|
||||||
mock_strategies = [
|
mock_strategies = [
|
||||||
|
|
@ -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,11 +187,17 @@ 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
|
||||||
baseline = run_contract_tests(tool)
|
if evaluator:
|
||||||
result.baseline_score = _count_passed(baseline)
|
result.baseline_score = evaluator(tool)
|
||||||
|
else:
|
||||||
|
baseline = run_contract_tests(tool)
|
||||||
|
result.baseline_score = _count_passed(baseline)
|
||||||
|
|
||||||
# Generate variations
|
# Generate variations
|
||||||
result.variations = generate_variations(tool, count=count, provider=provider)
|
result.variations = generate_variations(tool, count=count, provider=provider)
|
||||||
|
|
@ -198,14 +216,26 @@ def optimize_tool(
|
||||||
step.prompt = variation.variation
|
step.prompt = variation.variation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
test_result = run_contract_tests(tool_copy)
|
if evaluator:
|
||||||
result.scores[idx] = _count_passed(test_result)
|
result.scores[idx] = evaluator(tool_copy)
|
||||||
|
else:
|
||||||
|
test_result = run_contract_tests(tool_copy)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}}}",
|
||||||
|
)
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}"
|
||||||
Loading…
Reference in New Issue