M9.2-M9.5: Add improvement pipeline, community workflow, transitive integrity, and supply chain attestation

This commit is contained in:
rob 2026-07-20 15:43:14 -03:00
parent bf3686ae40
commit 04153a4bbd
8 changed files with 945 additions and 0 deletions

110
src/cmdforge/attestation.py Normal file
View File

@ -0,0 +1,110 @@
"""Supply chain attestation (M9.5).
Tool publishers sign releases. Registry verifies signatures before
accepting publish. Clients verify signatures before installing.
"""
import hashlib
import hmac
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Optional
@dataclass
class Attestation:
"""A signed attestation for a tool release."""
tool_name: str
version: str
content_hash: str
signer: str # publisher identity
signature: str
signed_at: str = ""
algorithm: str = "hmac-sha256"
def to_dict(self) -> dict:
return {
"tool_name": self.tool_name,
"version": self.version,
"content_hash": self.content_hash,
"signer": self.signer,
"signature": self.signature,
"signed_at": self.signed_at,
"algorithm": self.algorithm,
}
def sign_tool(
tool_name: str,
version: str,
content_hash: str,
signer: str,
secret_key: str,
) -> Attestation:
"""Sign a tool release with HMAC-SHA256.
Args:
tool_name: Tool name
version: Tool version
content_hash: Content hash from integrity module
signer: Publisher identity (username)
secret_key: Signing key (from config or keyring)
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(
tool_name=tool_name,
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:
"""Verify a tool attestation signature.
Args:
attestation: The attestation to verify
secret_key: The signing key to verify against
Returns:
True if signature is valid
"""
payload = f"{attestation.tool_name}:{attestation.version}:{attestation.content_hash}:{attestation.signer}"
expected = hmac.new(
secret_key.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, attestation.signature)
def verify_content_hash(content_hash: str, tool_dict: dict) -> bool:
"""Verify that a content hash matches the 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
# Reconstruct what the hash should be
tool_dict_copy = dict(tool_dict)
tool_dict_copy.pop("path", None)
content = json.dumps(tool_dict_copy, sort_keys=True)
expected = hashlib.sha256(content.encode()).hexdigest()[:16]
return expected == content_hash

159
src/cmdforge/community.py Normal file
View File

@ -0,0 +1,159 @@
"""Community improvement workflow (M9.3).
Allows community members to submit suggested prompt/code improvements.
Submissions are auto-tested before review. Successful improvements
credit both original and improving author.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from .tool import Tool, PromptStep, CodeStep
@dataclass
class ImprovementSubmission:
"""A community-submitted improvement for a tool."""
tool_name: str
tool_version: str
submitter: str
step_index: int
step_type: str # "prompt" | "code"
original: str
proposed: str
rationale: str = ""
submitted_at: str = ""
status: str = "pending" # pending | tested | approved | rejected
test_result: Optional[dict] = None
def to_dict(self) -> dict:
return {
"tool_name": self.tool_name,
"tool_version": self.tool_version,
"submitter": self.submitter,
"step_index": self.step_index,
"step_type": self.step_type,
"original": self.original[:200],
"proposed": self.proposed[:200],
"rationale": self.rationale,
"submitted_at": self.submitted_at,
"status": self.status,
"test_result": self.test_result,
}
@dataclass
class SubmissionReview:
"""Review result for an improvement submission."""
submission: ImprovementSubmission
decision: str # "approve" | "reject" | "request_changes"
reviewer: str = ""
notes: str = ""
reviewed_at: str = ""
def create_submission(
tool: Tool,
step_index: int,
proposed: str,
submitter: str,
rationale: str = "",
) -> ImprovementSubmission:
"""Create a new improvement submission."""
if step_index < 0 or step_index >= len(tool.steps):
raise ValueError(f"Step index {step_index} out of range (0-{len(tool.steps)-1})")
step = tool.steps[step_index]
if isinstance(step, PromptStep):
original = step.prompt
step_type = "prompt"
elif isinstance(step, CodeStep):
original = step.code
step_type = "code"
else:
raise ValueError(f"Step {step_index} is not a prompt or code step")
return ImprovementSubmission(
tool_name=tool.name,
tool_version=tool.version or "",
submitter=submitter,
step_index=step_index,
step_type=step_type,
original=original,
proposed=proposed,
rationale=rationale,
submitted_at=datetime.now(timezone.utc).isoformat(),
)
def run_submission_tests(submission: ImprovementSubmission, tool: Tool) -> dict:
"""Run contract tests on the tool with the proposed change applied.
Returns a dict with test results comparing baseline vs proposed.
"""
import copy
from .contract_testing import run_contract_tests
# Baseline
baseline = run_contract_tests(tool)
baseline_passed = sum(
1 for r in baseline.results if r.state == "passed"
)
# Apply proposed change
tool_copy = copy.deepcopy(tool)
if submission.step_index >= len(tool_copy.steps):
return {"error": "Step index out of range", "baseline_passed": baseline_passed}
step = tool_copy.steps[submission.step_index]
if isinstance(step, PromptStep) and submission.step_type == "prompt":
step.prompt = submission.proposed
elif isinstance(step, CodeStep) and submission.step_type == "code":
step.code = submission.proposed
else:
return {"error": "Step type mismatch", "baseline_passed": baseline_passed}
# Test with proposed change
proposed_result = run_contract_tests(tool_copy)
proposed_passed = sum(
1 for r in proposed_result.results if r.state == "passed"
)
result = {
"baseline_passed": baseline_passed,
"proposed_passed": proposed_passed,
"improvement": proposed_passed - baseline_passed,
"regressed": proposed_passed < baseline_passed,
"details": [r.to_dict() for r in proposed_result.results],
}
submission.status = "tested"
submission.test_result = result
return result
def review_submission(
submission: ImprovementSubmission,
decision: str,
reviewer: str,
notes: str = "",
) -> SubmissionReview:
"""Review a tested submission."""
if decision not in ("approve", "reject", "request_changes"):
raise ValueError("Decision must be: approve, reject, or request_changes")
if submission.status == "pending":
raise ValueError("Submission must be tested before review")
submission.status = decision
return SubmissionReview(
submission=submission,
decision=decision,
reviewer=reviewer,
notes=notes,
reviewed_at=datetime.now(timezone.utc).isoformat(),
)

212
src/cmdforge/improvement.py Normal file
View File

@ -0,0 +1,212 @@
"""Scrutiny-driven improvement pipeline (M9.2).
Takes a tool with low scrutiny scores and generates actionable improvement
suggestions for honesty, efficiency, and transparency.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from .tool import Tool, PromptStep, CodeStep
@dataclass
class ImprovementSuggestion:
"""One actionable improvement suggestion."""
category: str # "honesty" | "efficiency" | "transparency"
severity: str # "high" | "medium" | "low"
title: str
description: str
location: str = "" # step index or "tool"
current: str = "" # current code/prompt text
suggested: str = "" # suggested replacement
auto_applicable: bool = False
def to_dict(self) -> dict:
return {
"category": self.category,
"severity": self.severity,
"title": self.title,
"description": self.description,
"location": self.location,
"current": self.current[:200],
"suggested": self.suggested[:200],
"auto_applicable": self.auto_applicable,
}
@dataclass
class ImprovementReport:
"""Collection of improvement suggestions for a tool."""
tool_name: str
suggestions: List[ImprovementSuggestion] = field(default_factory=list)
@property
def has_high_severity(self) -> bool:
return any(s.severity == "high" for s in self.suggestions)
@property
def by_category(self) -> Dict[str, List[ImprovementSuggestion]]:
result: Dict[str, List[ImprovementSuggestion]] = {}
for s in self.suggestions:
result.setdefault(s.category, []).append(s)
return result
def to_dict(self) -> dict:
return {
"tool": self.tool_name,
"suggestion_count": len(self.suggestions),
"has_high_severity": self.has_high_severity,
"suggestions": [s.to_dict() for s in self.suggestions],
}
def generate_improvements(tool: Tool) -> ImprovementReport:
"""Analyze a tool and generate improvement suggestions.
Categories:
- Honesty: description doesn't match behavior
- Efficiency: unnecessary AI calls, redundant steps
- Transparency: obfuscated code, unclear prompts
"""
report = ImprovementReport(tool_name=tool.name)
_check_honesty(tool, report)
_check_efficiency(tool, report)
_check_transparency(tool, report)
return report
def _check_honesty(tool: Tool, report: ImprovementReport):
"""Check description-to-behavior alignment."""
if not tool.description:
report.suggestions.append(ImprovementSuggestion(
category="honesty",
severity="high",
title="Missing description",
description="Tool has no description. Users cannot know what it does without reading the config.",
location="tool",
auto_applicable=False,
))
return
# Check if description mentions capabilities not in steps
desc_lower = tool.description.lower()
has_prompt = any(isinstance(s, PromptStep) for s in tool.steps)
has_code = any(isinstance(s, CodeStep) for s in tool.steps)
if "ai" in desc_lower or "generate" in desc_lower or "analyze" in desc_lower:
if not has_prompt:
report.suggestions.append(ImprovementSuggestion(
category="honesty",
severity="medium",
title="Description implies AI but no prompt step found",
description="The description mentions AI/generation/analysis but the tool has no PromptStep.",
location="tool",
auto_applicable=False,
))
# Check if description is too vague
if len(tool.description) < 20:
report.suggestions.append(ImprovementSuggestion(
category="honesty",
severity="low",
title="Description is very short",
description="A more detailed description helps users find and trust this tool.",
location="tool",
auto_applicable=False,
))
def _check_efficiency(tool: Tool, report: ImprovementReport):
"""Check for unnecessary AI calls and redundant steps."""
prompt_steps = [s for s in tool.steps if isinstance(s, PromptStep)]
# Multiple prompt steps with same provider — could be combined
if len(prompt_steps) > 1:
providers = [s.provider for s in prompt_steps]
if len(set(providers)) == 1:
report.suggestions.append(ImprovementSuggestion(
category="efficiency",
severity="medium",
title="Multiple prompt steps with same provider",
description=f"Found {len(prompt_steps)} prompt steps all using '{providers[0]}'. "
"Consider combining them into a single step to reduce API calls.",
location=f"steps 0-{len(prompt_steps)-1}",
auto_applicable=False,
))
# Prompt step that just passes input through
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
if step.prompt.strip() in ("{input}", "Repeat: {input}", "Echo: {input}"):
report.suggestions.append(ImprovementSuggestion(
category="efficiency",
severity="high",
title="Prompt step just echoes input",
description="This prompt step appears to just pass input through without transformation. "
"Remove it and use the input directly.",
location=f"step {i}",
current=step.prompt,
suggested="(remove this step — use {input} directly)",
auto_applicable=False,
))
# No steps at all — output is just a template
if not tool.steps and "{input}" in tool.output:
report.suggestions.append(ImprovementSuggestion(
category="efficiency",
severity="low",
title="Tool has no steps",
description="This tool only does variable substitution. Consider if it needs to be a CmdForge tool "
"or if a simple shell alias would suffice.",
location="tool",
auto_applicable=False,
))
def _check_transparency(tool: Tool, report: ImprovementReport):
"""Check for obfuscated code and unclear prompts."""
for i, step in enumerate(tool.steps):
if isinstance(step, CodeStep):
# Check for overly complex code
lines = step.code.strip().split("\n")
if len(lines) > 20:
report.suggestions.append(ImprovementSuggestion(
category="transparency",
severity="medium",
title=f"Code step {i} is complex ({len(lines)} lines)",
description="Long code steps are harder to audit. Consider extracting into a separate "
"tool or adding comments.",
location=f"step {i}",
auto_applicable=False,
))
# Check for exec/eval
if "exec(" in step.code or "eval(" in step.code:
report.suggestions.append(ImprovementSuggestion(
category="transparency",
severity="high",
title=f"Code step {i} uses exec/eval",
description="Using exec() or eval() in code steps makes the tool harder to audit and "
"poses security risks. Consider restructuring.",
location=f"step {i}",
auto_applicable=False,
))
if isinstance(step, PromptStep):
# Check for very short prompts
if len(step.prompt.strip()) < 20:
report.suggestions.append(ImprovementSuggestion(
category="transparency",
severity="medium",
title=f"Prompt step {i} is very short",
description="Short prompts give the AI model less context, leading to unpredictable output. "
"Add more detail about the expected format and content.",
location=f"step {i}",
current=step.prompt,
auto_applicable=False,
))

133
src/cmdforge/integrity.py Normal file
View File

@ -0,0 +1,133 @@
"""Transitive integrity verification (M9.4).
Content-addressable tool identity: tool = hash(tool definition + all dep hashes).
Extends lockfile to include transitive integrity chain.
"""
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from .tool import Tool, load_tool
@dataclass
class IntegrityNode:
"""One node in the integrity chain."""
name: str
version: str = ""
content_hash: str = ""
dependencies: List[str] = field(default_factory=list)
dependency_hashes: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"name": self.name,
"version": self.version,
"hash": self.content_hash,
"dependencies": self.dependencies,
"dependency_hashes": self.dependency_hashes,
}
@dataclass
class IntegrityChain:
"""Full transitive integrity chain for a tool."""
root: IntegrityNode
nodes: Dict[str, IntegrityNode] = field(default_factory=dict)
@property
def is_valid(self) -> bool:
"""Check all dependency hashes match."""
for name, node in self.nodes.items():
for dep_name in node.dependencies:
expected_hash = node.dependency_hashes.get(dep_name)
if expected_hash == "unresolved":
# Unresolved dependencies don't invalidate the chain
continue
if dep_name not in self.nodes:
return False
dep_node = self.nodes[dep_name]
if expected_hash and expected_hash != dep_node.content_hash:
return False
return True
def to_dict(self) -> dict:
return {
"root": self.root.to_dict(),
"nodes": {k: v.to_dict() for k, v in self.nodes.items()},
"valid": self.is_valid,
}
def compute_tool_hash(tool: Tool) -> str:
"""Compute a content hash for a tool definition."""
tool_dict = tool.to_dict()
# Remove path (not part of content identity)
tool_dict.pop("path", None)
content = json.dumps(tool_dict, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
def build_integrity_chain(tool: Tool, max_depth: int = 10) -> IntegrityChain:
"""Build a transitive integrity chain for a tool.
Traverses all ToolStep dependencies and computes hashes for each.
"""
chain = IntegrityChain(root=_build_node(tool))
chain.nodes[tool.name] = chain.root
_traverse_deps(tool, chain, depth=0, max_depth=max_depth)
return chain
def _build_node(tool: Tool) -> IntegrityNode:
"""Build an integrity node from a tool."""
deps = []
dep_hashes = {}
for step in tool.steps:
if hasattr(step, "tool") and step.tool:
deps.append(step.tool)
# Resolve dependency hashes
for dep_name in deps:
dep_tool = load_tool(dep_name)
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

45
tests/test_attestation.py Normal file
View File

@ -0,0 +1,45 @@
"""Tests for M9.5 supply chain attestation."""
from cmdforge.attestation import (
Attestation,
sign_tool,
verify_attestation,
verify_content_hash,
)
from cmdforge.tool import Tool
class TestSignTool:
def test_creates_attestation(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key")
assert att.tool_name == "mytool"
assert att.version == "1.0.0"
assert att.content_hash == "abc123"
assert att.signer == "alice"
assert len(att.signature) == 64 # SHA256 hex
assert att.algorithm == "hmac-sha256"
def test_different_keys_different_signatures(self):
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
class TestVerifyAttestation:
def test_valid_signature(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key")
assert verify_attestation(att, "secret-key")
def test_wrong_key_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key")
assert not verify_attestation(att, "wrong-key")
def test_tampered_content_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key")
att.content_hash = "tampered"
assert not verify_attestation(att, "secret-key")
def test_tampered_signer_fails(self):
att = sign_tool("mytool", "1.0.0", "abc123", "alice", "secret-key")
att.signer = "eve"
assert not verify_attestation(att, "secret-key")

80
tests/test_community.py Normal file
View File

@ -0,0 +1,80 @@
"""Tests for M9.3 community improvement workflow."""
from cmdforge.community import (
ImprovementSubmission,
create_submission,
run_submission_tests,
review_submission,
)
from cmdforge.tool import Tool, PromptStep, CodeStep
class TestCreateSubmission:
def test_create_for_prompt_step(self):
tool = Tool(
name="test",
version="1.0.0",
steps=[PromptStep(prompt="Summarize", provider="mock", output_var="x")],
output="{x}",
)
sub = create_submission(tool, 0, "Summarize the text thoroughly", "alice", "More detail")
assert sub.tool_name == "test"
assert sub.step_type == "prompt"
assert sub.original == "Summarize"
assert sub.proposed == "Summarize the text thoroughly"
assert sub.submitter == "alice"
assert sub.status == "pending"
def test_create_for_code_step(self):
tool = Tool(
name="test",
steps=[CodeStep(code="x = 1", output_var="x")],
output="{x}",
)
sub = create_submission(tool, 0, "x = 42", "bob")
assert sub.step_type == "code"
assert sub.original == "x = 1"
def test_invalid_step_index(self):
tool = Tool(name="test")
try:
create_submission(tool, 5, "new", "alice")
assert False, "Should have raised"
except ValueError:
pass
class TestReviewSubmission:
def test_review_approved(self):
sub = ImprovementSubmission(
tool_name="test", tool_version="1.0.0", submitter="alice",
step_index=0, step_type="prompt", original="a", proposed="b",
status="tested",
)
review = review_submission(sub, "approve", "admin", "Good improvement")
assert review.decision == "approve"
assert sub.status == "approve"
def test_review_pending_rejected(self):
sub = ImprovementSubmission(
tool_name="test", tool_version="1.0.0", submitter="alice",
step_index=0, step_type="prompt", original="a", proposed="b",
status="pending",
)
try:
review_submission(sub, "approve", "admin")
assert False, "Should have raised"
except ValueError:
pass
def test_invalid_decision(self):
sub = ImprovementSubmission(
tool_name="test", tool_version="1.0.0", submitter="alice",
step_index=0, step_type="prompt", original="a", proposed="b",
status="tested",
)
try:
review_submission(sub, "maybe", "admin")
assert False, "Should have raised"
except ValueError:
pass

109
tests/test_improvement.py Normal file
View File

@ -0,0 +1,109 @@
"""Tests for scrutiny-driven improvement pipeline (M9.2)."""
from cmdforge.improvement import (
ImprovementSuggestion,
ImprovementReport,
generate_improvements,
)
from cmdforge.tool import Tool, PromptStep, CodeStep
class TestImprovementReport:
def test_empty_report(self):
report = ImprovementReport(tool_name="test")
assert not report.has_high_severity
assert report.by_category == {}
def test_has_high_severity(self):
report = ImprovementReport(
tool_name="test",
suggestions=[
ImprovementSuggestion("honesty", "high", "Bad", "desc"),
ImprovementSuggestion("efficiency", "low", "Minor", "desc"),
],
)
assert report.has_high_severity
def test_by_category(self):
report = ImprovementReport(
tool_name="test",
suggestions=[
ImprovementSuggestion("honesty", "high", "A", "d"),
ImprovementSuggestion("honesty", "low", "B", "d"),
ImprovementSuggestion("efficiency", "medium", "C", "d"),
],
)
assert len(report.by_category["honesty"]) == 2
assert len(report.by_category["efficiency"]) == 1
class TestGenerateImprovements:
def test_missing_description(self):
tool = Tool(name="nodesc")
report = generate_improvements(tool)
honesty = [s for s in report.suggestions if s.category == "honesty"]
assert any("Missing description" in s.title for s in honesty)
def test_short_description(self):
tool = Tool(name="short", description="Does stuff")
report = generate_improvements(tool)
honesty = [s for s in report.suggestions if s.category == "honesty"]
assert any("short" in s.title.lower() for s in honesty)
def test_echo_prompt_detected(self):
tool = Tool(
name="echo",
steps=[PromptStep(prompt="{input}", provider="mock", output_var="out")],
output="{out}",
)
report = generate_improvements(tool)
efficiency = [s for s in report.suggestions if s.category == "efficiency"]
assert any("echo" in s.title.lower() for s in efficiency)
def test_multiple_same_provider_prompts(self):
tool = Tool(
name="multi",
steps=[
PromptStep(prompt="Step 1: {input}", provider="claude", output_var="a"),
PromptStep(prompt="Step 2: {a}", provider="claude", output_var="b"),
],
output="{b}",
)
report = generate_improvements(tool)
efficiency = [s for s in report.suggestions if s.category == "efficiency"]
assert any("same provider" in s.title.lower() for s in efficiency)
def test_exec_detected(self):
tool = Tool(
name="dangerous",
steps=[CodeStep(code="exec('print(1)')", output_var="x")],
output="{x}",
)
report = generate_improvements(tool)
transparency = [s for s in report.suggestions if s.category == "transparency"]
assert any("exec" in s.title.lower() for s in transparency)
def test_short_prompt_detected(self):
tool = Tool(
name="vague",
steps=[PromptStep(prompt="Do it", provider="mock", output_var="x")],
output="{x}",
)
report = generate_improvements(tool)
transparency = [s for s in report.suggestions if s.category == "transparency"]
assert any("short" in s.title.lower() for s in transparency)
def test_clean_tool_no_suggestions(self):
tool = Tool(
name="clean",
description="A well-documented tool that summarizes text input concisely",
steps=[PromptStep(
prompt="Summarize the following text in 2-3 sentences. Focus on key points.\n\nText: {input}",
provider="mock",
output_var="summary",
)],
output="{summary}",
)
report = generate_improvements(tool)
high = [s for s in report.suggestions if s.severity == "high"]
assert len(high) == 0

97
tests/test_integrity.py Normal file
View File

@ -0,0 +1,97 @@
"""Tests for M9.4 transitive integrity verification."""
from unittest.mock import patch
from cmdforge.integrity import (
IntegrityNode,
IntegrityChain,
compute_tool_hash,
build_integrity_chain,
verify_integrity,
)
from cmdforge.tool import Tool, PromptStep, ToolStep
class TestComputeToolHash:
def test_same_tool_same_hash(self):
tool = Tool(name="test", version="1.0.0")
assert compute_tool_hash(tool) == compute_tool_hash(tool)
def test_different_tools_different_hash(self):
a = Tool(name="a", version="1.0.0")
b = Tool(name="b", version="1.0.0")
assert compute_tool_hash(a) != compute_tool_hash(b)
def test_version_change_changes_hash(self):
v1 = Tool(name="test", version="1.0.0")
v2 = Tool(name="test", version="2.0.0")
assert compute_tool_hash(v1) != compute_tool_hash(v2)
class TestIntegrityChain:
def test_single_tool_chain(self):
tool = Tool(name="solo", version="1.0.0")
chain = build_integrity_chain(tool)
assert chain.root.name == "solo"
assert chain.is_valid
def test_chain_with_unresolved_dep(self):
tool = Tool(
name="parent",
version="1.0.0",
steps=[ToolStep(tool="missing-dep", output_var="x")],
output="{x}",
)
chain = build_integrity_chain(tool)
assert "missing-dep" not in chain.nodes
# Chain is still valid because the dependency hash is "unresolved"
# and there's no node to compare against
assert chain.is_valid
def test_chain_valid_with_resolved_dep(self, tmp_path):
with patch("cmdforge.tool.TOOLS_DIR", tmp_path / ".cmdforge"):
from cmdforge.tool import save_tool
child = Tool(name="child", version="1.0.0")
save_tool(child)
parent = Tool(
name="parent",
version="1.0.0",
steps=[ToolStep(tool="child", output_var="x")],
output="{x}",
)
save_tool(parent)
chain = build_integrity_chain(parent)
assert "child" in chain.nodes
assert chain.is_valid
class TestVerifyIntegrity:
def test_valid_chain(self):
chain = IntegrityChain(
root=IntegrityNode(name="root", content_hash="abc"),
nodes={"root": IntegrityNode(name="root", content_hash="abc")},
)
assert verify_integrity(chain)
def test_tampered_chain(self):
chain = IntegrityChain(
root=IntegrityNode(
name="root",
content_hash="abc",
dependencies=["dep"],
dependency_hashes={"dep": "expected_hash"},
),
nodes={
"root": IntegrityNode(
name="root",
content_hash="abc",
dependencies=["dep"],
dependency_hashes={"dep": "expected_hash"},
),
"dep": IntegrityNode(name="dep", content_hash="different_hash"),
},
)
assert not chain.is_valid