diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd6ef8..65b0c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.3.0] β€” 2026-08-14 + +### Fixed +- **Verdicts now reach the exit code.** Every CLI path exited 0, so + `am verify && …` proceeded on a tampered ledger β€” the πŸ”΄ FAIL was print-only. + Found live, not hypothetically: a commit-binding tool trusted the exit code + and its own tamper demo (a byte-flipped ledger) came back green. + Now: `verify` / `verify-peer` / `verify-sig` exit 1 on FAIL (0 on OK/WARN); + `attest` exits 0 only on ATTESTED (1 on CONTENT-MISMATCH and NOT-FOUND). + Commands with no verdict (`record`, `history`, `witness`, `cross`, `keygen`) + keep exiting 0. Scripts that already parsed the output are unaffected; + scripts that trusted the exit code were getting a constant β€” any change is + strictly more information. + +### Added +- `tests/test_cli_exit_codes.py` β€” subprocess-level checks that each verdict + maps to the documented exit code, including the exact tampered-ledger case + the defect shipped. + +--- + ## [0.2.0] β€” 2026-07-17 ### Security diff --git a/actmirror/__init__.py b/actmirror/__init__.py index e25a6c7..d6115e5 100644 --- a/actmirror/__init__.py +++ b/actmirror/__init__.py @@ -10,4 +10,4 @@ "witness_peer", "verify_peer", "cross_witness", "family_round", "family_verify", "report", "Finding", ] -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/actmirror/am.py b/actmirror/am.py index 20ea5aa..6e69afd 100644 --- a/actmirror/am.py +++ b/actmirror/am.py @@ -376,7 +376,9 @@ def family_verify(ledgers: dict[str, str]) -> list[Finding]: # ───────────────────────────────────────────────────────────── # Report printer (family-standard) # ───────────────────────────────────────────────────────────── -def report(title: str, findings: list[Finding]) -> None: +def report(title: str, findings: list[Finding]) -> str: + """Print the family-standard report; return the worst level so the CLI can + turn a printed πŸ”΄ into a non-zero exit code instead of a silent 0.""" icon = {"OK": "βœ…", "WARN": "⚠️ ", "FAIL": "πŸ”΄"} worst = "FAIL" if any(f.level == "FAIL" for f in findings) else \ "WARN" if any(f.level == "WARN" for f in findings) else "OK" @@ -384,12 +386,20 @@ def report(title: str, findings: list[Finding]) -> None: print(f" Overall: {icon[worst]} {worst}") for f in findings: print(f" {icon[f.level]} [{f.probe}] {f.msg}") + return worst # ───────────────────────────────────────────────────────────── # CLI # ───────────────────────────────────────────────────────────── -def _cli() -> None: +def _cli() -> int: + """Exit codes: 0 β€” command ran and any verdict was OK/WARN (or the command + has no verdict); 1 β€” a verdict-bearing command answered negatively + (chain/signature/peer FAIL, attest CONTENT-MISMATCH or NOT-FOUND). + Before 0.3.0 every path exited 0, so `am verify && …` shipped a tampered + ledger β€” the verdict was print-only. Found when a commit-binding tool's + tamper demo passed its ledger-mutation case. + """ import argparse p = argparse.ArgumentParser( prog="am", description="πŸͺͺ Action Mirror β€” agent action provenance + mutual witness") @@ -444,11 +454,12 @@ def _cli() -> None: from . import identity pub = identity.generate(args.out) print(f"πŸ”‘ keypair written: {args.out} (keep private!)\n pubkey: {pub}") - return + return 0 if args.cmd == "verify-sig": - for f in verify_signatures(args.ledger): + fs = verify_signatures(args.ledger) + for f in fs: print(f" {f}") - return + return 1 if any(f.level == "FAIL" for f in fs) else 0 if args.cmd == "record": content = None if args.content_file: @@ -478,20 +489,25 @@ def _cli() -> None: print(f"{icon[res['verdict']]} {res['verdict']}: {res['note']}") for e in res["matches"]: print(f" {e['ts']} {e['agent']} {e['action']} seal={e['seal']}") + return 0 if res["verdict"] == "ATTESTED" else 1 elif args.cmd == "verify": - report("chain integrity", verify_chain(args.ledger)) + return 1 if report("chain integrity", + verify_chain(args.ledger)) == "FAIL" else 0 elif args.cmd == "witness": e = witness_peer(args.ledger, args.peer_ledger, peer_name=args.name) print(f"πŸ‘ Witnessed '{args.name}': {e['peer_entries']} entries, " f"head={e['peer_head_seal']} seal={e['seal']}") elif args.cmd == "verify-peer": - report(f"peer '{args.name}'", - [verify_peer(args.ledger, args.peer_ledger, peer_name=args.name)]) + worst = report(f"peer '{args.name}'", + [verify_peer(args.ledger, args.peer_ledger, + peer_name=args.name)]) + return 1 if worst == "FAIL" else 0 elif args.cmd == "cross": na, nb = args.names cross_witness(args.ledger_a, args.ledger_b, name_a=na, name_b=nb) print(f"πŸ‘πŸ‘ Mutual witness sealed: {na} ⇄ {nb}") + return 0 if __name__ == "__main__": - _cli() + raise SystemExit(_cli()) diff --git a/pyproject.toml b/pyproject.toml index 5017fb5..c4f2cdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "action-mirror" -version = "0.2.0" +version = "0.3.0" description = "Agent action provenance + mutual witness network β€” who did what, provably." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py new file mode 100644 index 0000000..3f4aa3b --- /dev/null +++ b/tests/test_cli_exit_codes.py @@ -0,0 +1,95 @@ +"""The CLI's verdicts must reach the exit code. + +Before 0.3.0 every path exited 0: `am verify && deploy` shipped a tampered +ledger β€” the πŸ”΄ FAIL was print-only. Found live, not hypothetically: a +commit-binding tool trusted the exit code, and its own tamper demo (ledger +byte-flip) came back green. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + +from actmirror import am + +# Make `-m actmirror.am` resolvable both from a repo-root pytest run and from +# the clean-wheel CI job (installed package; PYTHONPATH entry is then inert). +_ENV = {**os.environ, + "PYTHONPATH": os.pathsep.join(filter(None, [ + os.path.dirname(os.path.dirname(os.path.abspath(am.__file__))), + os.environ.get("PYTHONPATH", "")]))} + + +def cli(*args): + return subprocess.run([sys.executable, "-m", "actmirror.am", *args], + capture_output=True, text=True, env=_ENV) + + +def _tamper_field(ledger: str, field: str, value): + lines = [json.loads(x) for x in open(ledger, encoding="utf-8")] + lines[0][field] = value + with open(ledger, "w", encoding="utf-8") as f: + for e in lines: + f.write(json.dumps(e) + "\n") + + +def test_verify_intact_exits_0(tmp_path): + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x") + r = cli("--ledger", led, "verify") + assert r.returncode == 0 and "OK" in r.stdout + + +def test_verify_tampered_exits_1(tmp_path): + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x") + am.record(led, agent="a", action="y") + _tamper_field(led, "agent", "someone-else") + r = cli("--ledger", led, "verify") + assert "FAIL" in r.stdout # the verdict was already right… + assert r.returncode == 1 # …now the exit code agrees with it + + +def test_attest_attested_exits_0(tmp_path): + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x", target="t") + r = cli("--ledger", led, "attest", "--agent", "a", "--action", "x") + assert r.returncode == 0 and "ATTESTED" in r.stdout + + +def test_attest_not_found_exits_1(tmp_path): + led = str(tmp_path / "l.jsonl") + am.record(led, agent="a", action="x") + r = cli("--ledger", led, "attest", "--agent", "nobody") + assert r.returncode == 1 and "NOT-FOUND" in r.stdout + + +def test_attest_content_mismatch_exits_1(tmp_path): + led = str(tmp_path / "l.jsonl") + blob = tmp_path / "artifact.bin" + blob.write_bytes(b"honest bytes") + am.record(led, agent="a", action="produced", target="artifact.bin", + content=b"honest bytes") + blob.write_bytes(b"swapped bytes") + r = cli("--ledger", led, "attest", "--agent", "a", + "--content-file", str(blob)) + assert r.returncode == 1 and "CONTENT-MISMATCH" in r.stdout + + +def test_verify_peer_rewrite_exits_1(tmp_path): + mine, peer = str(tmp_path / "mine.jsonl"), str(tmp_path / "peer.jsonl") + am.record(peer, agent="peer", action="x") + r = cli("--ledger", mine, "witness", peer, "--name", "peer") + assert r.returncode == 0 + os.remove(peer) # peer rewrites history from scratch + am.record(peer, agent="peer", action="rewritten") + r = cli("--ledger", mine, "verify-peer", peer, "--name", "peer") + assert "FAIL" in r.stdout and r.returncode == 1 + + +def test_record_still_exits_0(tmp_path): + led = str(tmp_path / "l.jsonl") + r = cli("--ledger", led, "record", "--agent", "a", "--action", "x") + assert r.returncode == 0 and "Sealed" in r.stdout