Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion actmirror/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
"witness_peer", "verify_peer", "cross_witness", "family_round", "family_verify",
"report", "Finding",
]
__version__ = "0.2.0"
__version__ = "0.3.0"
34 changes: 25 additions & 9 deletions actmirror/am.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,20 +376,30 @@ 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"
print(f"\n🪪 Action Mirror: {title}")
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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
95 changes: 95 additions & 0 deletions tests/test_cli_exit_codes.py
Original file line number Diff line number Diff line change
@@ -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
Loading