diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e9c8e95..7da405a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -50,11 +50,25 @@ invart real-agent report --run-dir .invart/real-agent --out .invart/real-agent/r Use `--require-live` when you want missing local agent binaries to fail the run instead of being recorded as blocked evidence. Fixture-backed runs validate the Invart adapter contract; live runs validate the installed product surface. +### Claude Code reference adapter + +```bash +invart adapter claude-code \ + --target . \ + --out-dir .invart/claude-reference \ + --hook-events .invart/claude-hooks.jsonl \ + --policy-mode managed \ + -- +``` + +This reference adapter records Claude-style hook events, mediates the child command, and exports an adapter package containing ledger, proof, replay, path graph, coverage, audit, and evidence manifest. In managed/ci mode, deterministic risky actions pause or block before the child command is launched; advisory mode preserves autonomy and records evidence. Portable subprocess supervision is explicitly marked as degraded process-tree coverage unless native supervision is enabled. + ### Evaluation ```bash invart eval list invart eval benchmark --suite full-product-readiness invart eval benchmark --suite v0.9.3-agent-adapter-contract +invart eval benchmark --suite v0.9.4-claude-reference-adapter invart roadmap status --require-full ``` diff --git a/docs/html/cli-reference.html b/docs/html/cli-reference.html index 95c8693..9749ece 100644 --- a/docs/html/cli-reference.html +++ b/docs/html/cli-reference.html @@ -18,9 +18,16 @@

Real agent conformance

invart adapter profile --kind claude-code
 invart real-agent check --agent claude-code --out-dir .invart/real-agent
 invart real-agent report --run-dir .invart/real-agent --out .invart/real-agent/report.html

Use --require-live when missing local binaries should fail instead of being recorded as blocked evidence.

+

Claude Code reference adapter

invart adapter claude-code \
+  --target . \
+  --out-dir .invart/claude-reference \
+  --hook-events .invart/claude-hooks.jsonl \
+  --policy-mode managed \
+  -- <claude-or-harness-command>

Records Claude-style hook events, mediates the child command, and exports ledger, proof, replay, path graph, coverage, audit, and evidence manifest. Portable subprocess supervision is marked as degraded process-tree coverage unless native supervision is enabled.

Evaluation

invart eval list
 invart eval benchmark --suite full-product-readiness
 invart eval benchmark --suite v0.9.3-agent-adapter-contract
+invart eval benchmark --suite v0.9.4-claude-reference-adapter
 invart roadmap status --require-full
diff --git a/docs/html/release-history.html b/docs/html/release-history.html index 53b41ae..2222d51 100644 --- a/docs/html/release-history.html +++ b/docs/html/release-history.html @@ -31,6 +31,7 @@

0.9 Patch Track

+
VersionStatusFocus
v0.9.3ImplementedAgent adapter contract registry and fixture-backed real-agent conformance foundation. The live mode can be strict, but missing local agent binaries are not reported as successful live validation.
v0.9.4ImplementedClaude Code reference full adapter: hook events and child command mediation, managed risk pause/block before launch, full L5 evidence package, and truthful degraded process-tree coverage when only portable subprocess supervision is active.
diff --git a/docs/release-history.md b/docs/release-history.md index baeb0fb..8e33184 100644 --- a/docs/release-history.md +++ b/docs/release-history.md @@ -31,6 +31,7 @@ Invart has a long local implementation history. The public docs summarize capabi | Version | Status | Focus | | --- | --- | --- | | v0.9.3 | Implemented | Agent adapter contract registry and fixture-backed real-agent conformance foundation. The live mode can be strict, but missing local agent binaries are not reported as successful live validation. | +| v0.9.4 | Implemented | Claude Code reference full adapter: hook events and child command mediation, managed risk pause/block before launch, full L5 evidence package, and truthful degraded process-tree coverage when only portable subprocess supervision is active. | ## Internal History diff --git a/src/invart/benchmarks/registry.py b/src/invart/benchmarks/registry.py index 7038a4c..3488436 100644 --- a/src/invart/benchmarks/registry.py +++ b/src/invart/benchmarks/registry.py @@ -55,7 +55,7 @@ run_product_control_matrix_benchmark, run_reviewer_ablation_cost_benchmark, ) -from .releases_v52_v57 import run_agent_adapter_contract_benchmark +from .releases_v52_v57 import run_agent_adapter_contract_benchmark, run_claude_reference_adapter_benchmark BenchmarkRunner = Callable[[], dict[str, Any]] @@ -111,6 +111,7 @@ def benchmark_runner_registry() -> dict[str, BenchmarkRunner]: "v0.50-product-control-matrix": run_product_control_matrix_benchmark, "v0.51-pre-1.0-research-ready-gate": run_pre_1_0_research_ready_gate_benchmark, "v0.9.3-agent-adapter-contract": run_agent_adapter_contract_benchmark, + "v0.9.4-claude-reference-adapter": run_claude_reference_adapter_benchmark, "progressive-external-validation": run_progressive_external_validation_benchmark, "real-world-agent-risk-demo": run_real_world_risk_benchmark, "containerized-risk-demo": run_container_risk_demo_benchmark, diff --git a/src/invart/benchmarks/releases_v52_v57.py b/src/invart/benchmarks/releases_v52_v57.py index 7790fcd..49b793c 100644 --- a/src/invart/benchmarks/releases_v52_v57.py +++ b/src/invart/benchmarks/releases_v52_v57.py @@ -1,10 +1,14 @@ from __future__ import annotations import tempfile +import sys from pathlib import Path from .common import _suite_result +from invart.assurance.evidence_bundle import verify_evidence_bundle +from invart.core.ledger import load_ledger_entries from invart.evaluation.real_agent_conformance import run_real_agent_conformance +from invart.surfaces.claude_adapter import run_claude_code_adapter from invart.surfaces.adapter_profiles import list_adapter_profiles, validate_adapter_profile_truthfulness @@ -38,4 +42,72 @@ def run_agent_adapter_contract_benchmark() -> dict[str, object]: ) -__all__ = ["run_agent_adapter_contract_benchmark"] +def run_claude_reference_adapter_benchmark() -> dict[str, object]: + with tempfile.TemporaryDirectory(prefix="invart_v094_") as tmp: + root = Path(tmp) + hooks = root / "hooks.jsonl" + hooks.write_text( + '{"type":"file_read","path":".env","metadata":{"source":"claude_code_hook"}}\n', + encoding="utf-8", + ) + package_run = run_claude_code_adapter( + target=root, + command=[sys.executable, "-c", "pass"], + hook_events=hooks, + out_dir=root / "package", + session_id="ks_v094_benchmark_package", + policy_mode="advisory", + ) + managed_marker = root / "managed_should_not_exist.txt" + managed_run = run_claude_code_adapter( + target=root, + command=["sh", "-c", f"touch {managed_marker}; rm -rf ."], + out_dir=root / "managed", + session_id="ks_v094_benchmark_managed", + policy_mode="managed", + ) + benign_marker = root / "benign.txt" + benign_run = run_claude_code_adapter( + target=root, + command=[sys.executable, "-c", f"from pathlib import Path; Path({str(benign_marker)!r}).write_text('ok')"], + out_dir=root / "benign", + session_id="ks_v094_benchmark_benign", + policy_mode="advisory", + ) + entries, _warnings = load_ledger_entries(Path(package_run["ledger"])) + mediation_surfaces = { + entry.result.get("request", {}).get("surface") + for entry in entries + if entry.entry_type == "mediation" and isinstance(entry.result, dict) + } + verification = verify_evidence_bundle(Path(package_run["adapter_package"]["manifest_path"])) + checks = { + "hook_event_mediated": "file" in mediation_surfaces, + "adapter_package_verified": verification.get("status") == "pass", + "l5_artifacts_present": { + "ledger", + "proof", + "replay", + "path_graph_json", + "path_graph_html", + "coverage", + "audit_html", + }.issubset(set(package_run["adapter_package"]["artifacts"])), + "managed_risk_stopped_before_side_effect": managed_run.get("returncode") == 126 and not managed_marker.exists(), + "managed_status_is_explicit": managed_run.get("status") in {"blocked", "requires_approval"}, + "benign_advisory_kept_autonomy": benign_run.get("status") == "passed" and benign_marker.exists(), + "supervision_truthful_degraded": package_run.get("supervision", {}).get("coverage_grade") == "mediated_without_process_tree", + } + return _suite_result( + "v0.9.4-claude-reference-adapter", + checks, + artifacts={ + "package_manifest": package_run["adapter_package"]["manifest_path"], + "package_ledger": package_run["ledger"], + "managed_ledger": managed_run["ledger"], + "benign_ledger": benign_run["ledger"], + }, + ) + + +__all__ = ["run_agent_adapter_contract_benchmark", "run_claude_reference_adapter_benchmark"] diff --git a/src/invart/commands/integrations.py b/src/invart/commands/integrations.py index f77ceb5..1f396b2 100644 --- a/src/invart/commands/integrations.py +++ b/src/invart/commands/integrations.py @@ -64,6 +64,7 @@ def handle_adapter(args: argparse.Namespace) -> int: session_id=args.session_id, create_preflight=not args.no_preflight, enforcement=args.enforcement, + policy_mode=args.policy_mode, ) print(json.dumps({"claude_code_adapter": result}, ensure_ascii=False, indent=2, sort_keys=True)) return int(result.get("returncode", 1)) diff --git a/src/invart/commands/parser_integrations.py b/src/invart/commands/parser_integrations.py index 3c6809a..8355ee2 100644 --- a/src/invart/commands/parser_integrations.py +++ b/src/invart/commands/parser_integrations.py @@ -40,6 +40,7 @@ def register_integration_commands(subparsers: argparse._SubParsersAction[argpars adapter_claude.add_argument("--hook-events", default=None) adapter_claude.add_argument("--session-id", default=None) adapter_claude.add_argument("--no-preflight", action="store_true") + adapter_claude.add_argument("--policy-mode", choices=("audit", "advisory", "managed", "ci"), default="advisory") adapter_claude.add_argument("--enforcement", choices=("off", "file-write"), default="off") adapter_claude.add_argument("cmd", nargs=argparse.REMAINDER) diff --git a/src/invart/evaluation/benchmark_registry.py b/src/invart/evaluation/benchmark_registry.py index a6afd94..4a7ffa2 100644 --- a/src/invart/evaluation/benchmark_registry.py +++ b/src/invart/evaluation/benchmark_registry.py @@ -32,6 +32,7 @@ {"suite": "v0.50-product-control-matrix", "version": "v0.50", "category": "product-boundary", "optional_heavy": False, "claim_scope": "product_comparison", "evidence_level": "documented_capability_matrix"}, {"suite": "v0.51-pre-1.0-research-ready-gate", "version": "v0.51", "category": "release", "optional_heavy": False, "claim_scope": "research_gate", "evidence_level": "local_research_artifact_bundle"}, {"suite": "v0.9.3-agent-adapter-contract", "version": "v0.9.3", "category": "agent-adapter", "optional_heavy": False, "claim_scope": "local_agent_adapter_contract", "evidence_level": "fixture_backed_conformance"}, + {"suite": "v0.9.4-claude-reference-adapter", "version": "v0.9.4", "category": "agent-adapter", "optional_heavy": False, "claim_scope": "local_claude_reference_adapter", "evidence_level": "local_runtime_fixture"}, {"suite": "progressive-external-validation", "version": "pre-release", "category": "external-validation", "optional_heavy": False, "claim_scope": "progressive_external_validation", "evidence_level": "external_progressive_sample"}, {"suite": "real-world-agent-risk-demo", "version": "pre-release", "category": "demo", "optional_heavy": False, "claim_scope": "public_source_mapping", "evidence_level": "public_source_seed_plus_local_demo"}, {"suite": "containerized-risk-demo", "version": "pre-release", "category": "demo", "optional_heavy": False, "claim_scope": "containerized_local_demo", "evidence_level": "per_case_container_artifact_bundle"}, diff --git a/src/invart/evaluation/release_candidate.py b/src/invart/evaluation/release_candidate.py index ec02d57..60fc1c5 100644 --- a/src/invart/evaluation/release_candidate.py +++ b/src/invart/evaluation/release_candidate.py @@ -48,6 +48,7 @@ "v0.50-product-control-matrix", "v0.51-pre-1.0-research-ready-gate", "v0.9.3-agent-adapter-contract", + "v0.9.4-claude-reference-adapter", "progressive-external-validation", "pre-v1-control-plane", ) diff --git a/src/invart/evaluation/roadmap.py b/src/invart/evaluation/roadmap.py index 78dc425..92f3830 100644 --- a/src/invart/evaluation/roadmap.py +++ b/src/invart/evaluation/roadmap.py @@ -782,6 +782,21 @@ def internal_docs_for_capability(docs: list[str]) -> list[str]: external_validation="not_run_optional", next_step="Harden Claude Code as the first full reference adapter and attach live conformance traces for each priority product.", ), + RoadmapCapability( + version="v0.9.4", + capability_id="claude_reference_full_adapter", + title="Claude Code reference full adapter", + target="Run Claude Code style wrapper and hook events through unified mediation, managed pause/block behavior, truthful degraded supervision evidence, and a full L5 artifact package.", + status="implemented", + implementation=["src/invart/surfaces/claude_adapter.py", "src/invart/control/mediation.py", "src/invart/assurance/evidence_bundle.py", "src/invart/commands/parser_integrations.py", "src/invart/commands/integrations.py"], + tests=["test_v094_claude_adapter_exports_full_package_and_mediates_hooks", "test_v094_claude_adapter_managed_risk_pauses_before_side_effect", "test_v094_claude_adapter_cli_and_benchmark_are_registered", "v0.9.4-claude-reference-adapter"], + docs=["docs/cli-reference.md", "docs/html/cli-reference.html", "docs/release-history.md", "docs/html/release-history.html"], + product_boundaries=["Local fixture-backed validation proves the Claude reference adapter contract, artifact package, and managed pause/block behavior; it is not a claim that every live Claude Code installation has been exercised.", "The reference adapter uses a portable subprocess wrapper plus Claude hook JSONL ingestion; process-tree coverage is explicitly degraded unless native supervision is enabled.", "Managed mode pauses or blocks risky actions before launching the child command, while advisory mode preserves autonomy and records evidence."], + claim_scope="local_claude_reference_adapter", + evidence_level="local_runtime_fixture", + external_validation="not_run_optional", + next_step="Add priority agent tracks and product-specific adapter coverage claims for Codex, Gemini CLI, Cursor, OpenClaw, Hermes, OpenCode, and related agent products.", + ), ) diff --git a/src/invart/surfaces/claude_adapter.py b/src/invart/surfaces/claude_adapter.py index b546f68..f3778fb 100644 --- a/src/invart/surfaces/claude_adapter.py +++ b/src/invart/surfaces/claude_adapter.py @@ -7,8 +7,11 @@ from pathlib import Path from typing import Any +from invart.assurance.evidence_bundle import export_evidence_bundle from invart.control.daemon import RuntimeAuthority +from invart.control.mediation import mediate_event from invart.assurance.postruntime import export_proof_report +from invart.core.artifacts import sha256_file, write_json_artifact from invart.core.models import utc_now from invart.core.env import child_env from invart.surfaces.adapter_profiles import build_adapter_profile @@ -24,6 +27,7 @@ def run_claude_code_adapter( session_id: str | None = None, create_preflight: bool = False, enforcement: str = "off", + policy_mode: str = "advisory", ) -> dict[str, Any]: if not command: raise ValueError("claude-code adapter requires a child command") @@ -38,61 +42,137 @@ def run_claude_code_adapter( session_id=session_id, ledger_path=ledger, create_preflight=create_preflight, - metadata={"adapter": "claude-code", "bridge": "wrapper+hook-jsonl"}, + metadata={"adapter": "claude-code", "bridge": "wrapper+hook-jsonl", "policy_mode": policy_mode}, + ) + permission_inventory = _permission_inventory(target) + authority.record_event( + session.session_id, + { + "type": "adapter_inventory", + "target": str(target), + "metadata": { + "adapter": "claude-code-inventory", + "operation": "permission_config_inventory", + "permission_inventory": permission_inventory, + "coverage_layer": "agent_log", + }, + }, + review_mode="off", + policy_mode="advisory", + reviewer="heuristic", ) ingested = 0 + blocking_mediation: dict[str, Any] | None = None if hook_events and hook_events.exists(): for payload in _load_hook_events(hook_events): + payload = _normalize_claude_hook_event(payload) metadata = dict(payload.get("metadata") or {}) metadata.setdefault("adapter", "claude-code-hook") metadata.setdefault("hook_source", str(hook_events)) + metadata.setdefault("coverage_layer", "native_hook") payload["metadata"] = metadata - authority.record_event(session.session_id, payload, review_mode="auto", policy_mode="advisory", reviewer="heuristic") + mediation = mediate_event( + ledger, + session_id=session.session_id, + surface=_surface_for_event(payload), + event=payload, + mode=_mediation_mode(policy_mode), + ) ingested += 1 - process_event = { - "type": "shell", - "command": " ".join(command), - "target": str(target), - "metadata": { - "adapter": "claude-code-process", - "operation": "child_command", - "process_supervision": { - "mode": "subprocess", - "started_at": utc_now(), - "strong_consistency": False, - "degraded_reason": "portable Python wrapper; native process-tree supervision not enabled", - }, - }, - } - if enforcement == "file-write": - enforced = run_file_write_intercepted(command, ledger_path=ledger, session_id=session.session_id, target=target) - returncode = int(enforced.get("returncode") if enforced.get("returncode") is not None else 1) - child_status = str(enforced.get("status")) - else: - plan = authority.record_event(session.session_id, process_event, review_mode="auto", policy_mode="advisory", reviewer="heuristic") - env = child_env(os.environ, session_id=session.session_id, ledger=str(ledger), adapter="claude-code") - completed = subprocess.run(command, cwd=str(target), env=env, check=False) - returncode = completed.returncode - child_status = "executed" if completed.returncode == 0 else "failed" + if _should_stop_for_mediation(mediation, policy_mode=policy_mode): + blocking_mediation = mediation + break + + supervision = _portable_supervision_evidence() + if blocking_mediation is not None: + decision = blocking_mediation["decision"] + outcome = blocking_mediation["outcome"] authority.outcome( session.session_id, - "executed" if completed.returncode == 0 else "failed", - decision_id=plan["decision"].get("decision_id"), + "blocked" if decision.get("effect") == "deny" else "requires_approval", + decision_id=outcome.get("decision_id"), + invocation_id=outcome.get("invocation_id"), actor="claude-code-adapter", - reason=f"child exited with {completed.returncode}", + reason=str(decision.get("reason") or "hook mediation stopped execution"), + ) + returncode = 126 + child_status = "blocked" if decision.get("effect") == "deny" else "requires_approval" + process_mediation = None + else: + process_event = { + "type": "shell", + "command": " ".join(command), + "target": str(target), + "metadata": { + "adapter": "claude-code-process", + "operation": "child_command", + "coverage_layer": "shell_wrapper", + "process_supervision": supervision, + }, + } + process_mediation = mediate_event( + ledger, + session_id=session.session_id, + surface="command", + event=process_event, + mode=_mediation_mode(policy_mode), ) + if _should_stop_for_mediation(process_mediation, policy_mode=policy_mode): + decision = process_mediation["decision"] + outcome = process_mediation["outcome"] + authority.outcome( + session.session_id, + "blocked" if decision.get("effect") == "deny" else "requires_approval", + decision_id=outcome.get("decision_id"), + invocation_id=outcome.get("invocation_id"), + actor="claude-code-adapter", + reason=str(decision.get("reason") or "managed mediation stopped child execution"), + ) + returncode = 126 + child_status = "blocked" if decision.get("effect") == "deny" else "requires_approval" + elif enforcement == "file-write": + enforced = run_file_write_intercepted(command, ledger_path=ledger, session_id=session.session_id, target=target) + returncode = int(enforced.get("returncode") if enforced.get("returncode") is not None else 1) + child_status = str(enforced.get("status")) + else: + env = child_env(os.environ, session_id=session.session_id, ledger=str(ledger), adapter="claude-code") + completed = subprocess.run(command, cwd=str(target), env=env, check=False) + returncode = completed.returncode + child_status = "executed" if completed.returncode == 0 else "failed" + outcome = process_mediation["outcome"] + authority.outcome( + session.session_id, + "executed" if completed.returncode == 0 else "failed", + decision_id=outcome.get("decision_id"), + invocation_id=outcome.get("invocation_id"), + actor="claude-code-adapter", + reason=f"child exited with {completed.returncode}", + ) authority.transition_session(session.session_id, "stopped", reason="claude-code adapter completed") - proof = ledger.with_name("proof.json") - export_proof_report(ledger, proof) + proof, adapter_package = _export_adapter_package( + ledger=ledger, + out_dir=resolved_out, + policy_mode=policy_mode, + permission_inventory=permission_inventory, + supervision=supervision, + ) return { - "schema_version": "invart.claude_adapter.v0.13", + "schema_version": "invart.claude_adapter.v0.9.4", "session_id": session.session_id, "returncode": returncode, "hook_events_ingested": ingested, "ledger": str(ledger), "proof": str(proof), - "status": "blocked" if child_status in {"blocked", "requires_approval"} else "passed" if returncode == 0 else "failed", + "status": "blocked" if child_status == "blocked" else "requires_approval" if child_status == "requires_approval" else "passed" if returncode == 0 else "failed", "enforcement": enforcement, + "policy_mode": policy_mode, + "permission_inventory": permission_inventory, + "supervision": supervision, + "mediation": { + "blocking": blocking_mediation, + "process": process_mediation, + }, + "adapter_package": adapter_package, } @@ -108,6 +188,132 @@ def _load_hook_events(path: Path) -> list[dict[str, Any]]: return events +def _normalize_claude_hook_event(payload: dict[str, Any]) -> dict[str, Any]: + normalized = dict(payload) + tool_name = normalized.get("tool_name") or normalized.get("tool") + tool_input = normalized.get("tool_input") if isinstance(normalized.get("tool_input"), dict) else {} + if tool_name == "Bash" and tool_input.get("command"): + normalized["type"] = "shell" + normalized["command"] = str(tool_input["command"]) + normalized["tool"] = "Bash" + metadata = dict(normalized.get("metadata") or {}) + metadata.setdefault("operation", "pre_tool_use") + metadata.setdefault("source", "claude_code_hook") + normalized["metadata"] = metadata + elif tool_name in {"Read", "Edit", "Write", "MultiEdit"} and (tool_input.get("file_path") or tool_input.get("path")): + normalized["type"] = "file_read" if tool_name == "Read" else "file_write" + normalized["path"] = str(tool_input.get("file_path") or tool_input.get("path")) + normalized["tool"] = str(tool_name) + metadata = dict(normalized.get("metadata") or {}) + metadata.setdefault("operation", "pre_tool_use") + metadata.setdefault("source", "claude_code_hook") + normalized["metadata"] = metadata + return normalized + + +def _surface_for_event(payload: dict[str, Any]) -> str: + event_type = str(payload.get("type") or "") + if event_type in {"shell", "command"} or payload.get("command"): + return "command" + if event_type.startswith("file") or payload.get("path"): + return "file" + if payload.get("url"): + return "network" + if payload.get("tool"): + return "tool" + return "agent_hook" + + +def _mediation_mode(policy_mode: str) -> str: + return "managed" if policy_mode in {"managed", "ci"} else "audit" if policy_mode == "audit" else "advisory" + + +def _should_stop_for_mediation(mediation: dict[str, Any], *, policy_mode: str) -> bool: + if policy_mode not in {"managed", "ci"}: + return False + effect = str(mediation.get("decision", {}).get("effect", "")) + status = str(mediation.get("outcome", {}).get("status", "")) + return effect in {"deny", "require_approval"} or status in {"blocked", "paused"} + + +def _portable_supervision_evidence() -> dict[str, Any]: + return { + "mode": "subprocess", + "started_at": utc_now(), + "strong_consistency": False, + "coverage_grade": "mediated_without_process_tree", + "degraded_reason": "portable Python wrapper; native process-tree supervision not enabled", + } + + +def _permission_inventory(target: Path) -> dict[str, Any]: + candidates = [ + target / ".claude" / "settings.json", + target / ".claude" / "settings.local.json", + target / ".mcp.json", + target / "mcp.json", + target / "CLAUDE.md", + ] + files = [] + for path in candidates: + item: dict[str, Any] = {"path": str(path), "exists": path.exists()} + if path.exists() and path.is_file(): + item["sha256"] = sha256_file(path) + item["bytes"] = path.stat().st_size + files.append(item) + return { + "status": "recorded", + "recorded_at": utc_now(), + "permission_sources": files, + "credential_boundary": { + "env_keys_recorded": True, + "env_values_recorded": False, + "reason": "Claude adapter records permission/config files and env key inventory via child environment without storing secret values.", + }, + } + + +def _export_adapter_package( + *, + ledger: Path, + out_dir: Path, + policy_mode: str, + permission_inventory: dict[str, Any], + supervision: dict[str, Any], +) -> tuple[Path, dict[str, Any]]: + proof = ledger.with_name("proof.json") + export_proof_report(ledger, proof) + bundle = export_evidence_bundle( + ledger, + out_dir / "adapter-package", + profile={ + "name": "claude-code-reference-adapter", + "mode": policy_mode, + "adapter": "claude-code", + "permission_inventory_status": permission_inventory.get("status"), + "supervision": { + "mode": supervision.get("mode"), + "strong_consistency": supervision.get("strong_consistency"), + "coverage_grade": supervision.get("coverage_grade"), + }, + }, + ) + package = { + "schema_version": "invart.claude_adapter_package.v0.9.4", + "status": bundle.get("status", "fail"), + "manifest_path": bundle.get("manifest_path"), + "artifacts": bundle.get("artifacts", {}), + "summary": bundle.get("summary", {}), + "coverage_truthfulness": { + "hook_mediation": "mediated", + "process_tree": "degraded", + "enforcement": "enforced" if any("enforcement" in str(item).lower() for item in bundle.get("artifacts", {}).values()) else "mediated", + }, + } + write_json_artifact(out_dir / "adapter-package.json", package) + return proof, package + + def check_claude_code_environment(binary: str = "claude") -> dict[str, Any]: resolved = shutil.which(binary) or (binary if Path(binary).exists() else None) profile = build_adapter_profile("claude-code") diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 32a548b..890ebb0 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -17,6 +17,7 @@ from invart.evaluation.harness import compare_harness_runs, run_official_swe_bench_full_validation, run_official_swe_bench_lite_check, run_swe_bench_lite_check from invart.surfaces.adapter_profiles import build_adapter_profile from invart.surfaces.claude_adapter import check_claude_code_environment, run_claude_code_adapter +from invart.assurance.evidence_bundle import verify_evidence_bundle from invart.governance.profiles import resolve_profile from invart.governance.teamrun import create_handoff, create_teamrun, declare_agent_identity from invart.surfaces.enforcement import check_enforcement, run_file_write_intercepted, rust_shim_decision @@ -542,6 +543,103 @@ def test_v10_claude_code_adapter_ingests_hook_events_and_runs_child(tmp_path: Pa assert any(event.get("metadata", {}).get("process_supervision", {}).get("mode") == "subprocess" for event in action_events) +def test_v094_claude_adapter_exports_full_package_and_mediates_hooks(tmp_path: Path) -> None: + hooks = tmp_path / "hooks.jsonl" + hooks.write_text( + json.dumps({"type": "file_read", "path": str(tmp_path / ".env"), "metadata": {"source": "claude_code_hook"}}) + "\n", + encoding="utf-8", + ) + out_dir = tmp_path / "claude-v094" + result = run_claude_code_adapter( + target=tmp_path, + command=[sys.executable, "-c", "print('ok')"], + hook_events=hooks, + out_dir=out_dir, + session_id="ks_v094_claude_package", + policy_mode="advisory", + ) + assert result["status"] == "passed" + assert result["adapter_package"]["status"] == "pass" + assert result["adapter_package"]["manifest_path"].endswith("manifest.json") + verification = verify_evidence_bundle(Path(result["adapter_package"]["manifest_path"])) + assert verification["status"] == "pass" + for artifact in ("ledger", "proof", "replay", "path_graph_json", "path_graph_html", "coverage", "audit_html"): + assert Path(result["adapter_package"]["artifacts"][artifact]).exists() + assert result["supervision"]["strong_consistency"] is False + assert result["supervision"]["coverage_grade"] == "mediated_without_process_tree" + assert result["permission_inventory"]["status"] == "recorded" + + entries, _warnings = load_ledger_entries(Path(result["ledger"])) + mediation_entries = [entry for entry in entries if entry.entry_type == "mediation"] + assert any(entry.result["request"]["surface"] == "file" for entry in mediation_entries) + assert any(entry.result["decision"]["effect"] in {"allow", "require_approval"} for entry in mediation_entries) + + +def test_v094_claude_adapter_managed_risk_pauses_before_side_effect(tmp_path: Path) -> None: + marker = tmp_path / "should_not_exist.txt" + result = run_claude_code_adapter( + target=tmp_path, + command=["sh", "-c", f"touch {marker}; rm -rf ."], + out_dir=tmp_path / "claude-managed", + session_id="ks_v094_claude_managed", + policy_mode="managed", + ) + assert result["status"] in {"blocked", "requires_approval"} + assert result["returncode"] == 126 + assert marker.exists() is False + entries, _warnings = load_ledger_entries(Path(result["ledger"])) + process_mediations = [ + entry.result for entry in entries + if entry.entry_type == "mediation" and entry.result.get("request", {}).get("surface") == "command" + ] + assert process_mediations + assert process_mediations[-1]["decision"]["effect"] in {"deny", "require_approval"} + assert process_mediations[-1]["outcome"]["status"] in {"blocked", "paused"} + + +def test_v094_claude_adapter_advisory_benign_keeps_autonomy(tmp_path: Path) -> None: + marker = tmp_path / "safe.txt" + result = run_claude_code_adapter( + target=tmp_path, + command=[sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).write_text('ok')"], + out_dir=tmp_path / "claude-advisory", + session_id="ks_v094_claude_advisory", + policy_mode="advisory", + ) + assert result["status"] == "passed" + assert result["returncode"] == 0 + assert marker.read_text(encoding="utf-8") == "ok" + entries, _warnings = load_ledger_entries(Path(result["ledger"])) + action_events = [entry for entry in entries if entry.entry_type == "action" and entry.event] + process_actions = [entry for entry in action_events if entry.event.get("metadata", {}).get("adapter") == "claude-code-process"] + assert process_actions + assert process_actions[-1].decision["effect"] == "allow" + + +def test_v094_claude_adapter_cli_and_benchmark_are_registered(tmp_path: Path) -> None: + marker = tmp_path / "cli_should_not_exist.txt" + out_dir = tmp_path / "claude-cli" + assert main([ + "adapter", + "claude-code", + "--target", + str(tmp_path), + "--out-dir", + str(out_dir), + "--session-id", + "ks_v094_claude_cli", + "--policy-mode", + "managed", + "--", + "sh", + "-c", + f"touch {marker}; rm -rf .", + ]) == 126 + assert marker.exists() is False + assert (out_dir / "adapter-package.json").exists() + assert main(["eval", "benchmark", "--suite", "v0.9.4-claude-reference-adapter"]) == 0 + + def test_v09_swe_bench_lite_runner_skips_cleanly_without_dependencies(tmp_path: Path) -> None: out = tmp_path / "swebench-report.json" assert main([