From 8690a4b259c2ca17f675d73d8606465703f45baf Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 31 Jul 2026 11:33:56 -0700 Subject: [PATCH 1/2] feat(baseline): tag the baseline so a rewrite is detectable The baseline is what every drift comparison is made against, and it was an unsigned plain file. Anything able to write ~/.claude/agentrust could add a rogue skill to the approved set and the check would report "nothing added, nothing subtracted" from then on, indefinitely and quietly. The evidence shared a fate with the adversary, which is the failure this project argues against elsewhere. baseline.json now carries an HMAC-SHA256 tag over a canonical digest of its content. approve writes it, and hook and verify check it. A broken tag is reported ahead of the drift section, because if the baseline was altered then a reassuring "nothing changed" below it is worse than no result at all. HMAC rather than the existing Ed25519 signing key: verifying a signature would pull the crypto packages into the SessionStart hook, and the hook is deliberately standard-library only. The tag secret is separate, 32 random bytes, chmod 600. Three verdicts, not two. A baseline written before tagging existed, or one whose secret has been deleted, reports as untagged rather than broken. Neither is evidence of tampering, and raising a tamper alarm on a benign state teaches the user to dismiss the real one. On the limit, stated plainly in the code, the report, and the README rather than left for a reader to discover: the secret sits in the same directory as the baseline, so this stops someone who can write the file without reading the directory. It does not stop someone who owns the directory, because they can retag whatever they rewrite. Calling that tamper-proof would make it theatre. What does survive that adversary is off-box comparison, so approve now prints a baseline digest and verify prints the digest of the baseline it read. A silent re-baseline changes the digest even when the attacker can forge a valid tag. Anchoring the digest in an append-only log would automate the comparison and is the natural next step. 15 new tests: a rewritten baseline is detected, a forged tag is detected, a stripped tag and a missing secret read as untagged, the digest excludes the block carrying it, and the report states the limit alongside the claim. Suite: 41 passed, up from 26. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Imran Siddique --- claude-code/README.md | 16 +++ claude-code/engine/capture.py | 155 +++++++++++++++++++++++++++++- claude-code/tests/test_capture.py | 121 +++++++++++++++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) diff --git a/claude-code/README.md b/claude-code/README.md index e14eb29..399467e 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -182,6 +182,22 @@ This plugin is honest about what it is. On a normal developer machine: the live tool roster, so the SessionStart check compares skills, permissions, and the instruction layer. The full tool and MCP diff runs in `/manifest verify`, where the agent supplies the live roster. +- **The live roster is self-reported.** In `/manifest verify` the agent supplies + its own model, tools, and MCP servers. Nothing verifies that report, so an + agent motivated to hide a connected server could simply omit it. Closing this + needs an observer outside the agent and cannot be done from inside it. +- **The baseline tag stops a writer, not a reader.** `baseline.json` carries an + HMAC tag, so corruption, a hand-edit, or anyone who can write the file without + reading `~/.claude/agentrust` is caught. The secret lives in that same + directory, so anyone who can read it can retag a baseline they rewrote. This is + tamper *evidence* against a limited adversary, not tamper *proofing* against a + full home-directory compromise, and it is not presented as the latter. + + The mitigation that does survive that adversary is off-box: `approve` prints a + baseline digest and `verify` prints the digest of the baseline it read. Record + the first somewhere else and compare. A silent re-baseline changes the digest + even when the attacker can produce a valid tag. An anchor in an append-only log + would automate that comparison and is the natural next step. ## Layout diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py index f5e76a9..e885a68 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -32,8 +32,10 @@ import argparse import base64 import hashlib +import hmac import json import os +import secrets import stat import sys import time @@ -447,7 +449,13 @@ def _load_or_create_manifest_keypair(): _ENRICH_HINT = "run /manifest verify to include model, tools and MCP" -def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str: +def render_report( + cur: dict, + changes: list[dict] | None, + signed: bool, + integrity: str | None = None, + baseline_digest: str | None = None, +) -> str: m = cur["model"] observed = set(cur.get("observed", [])) # The hook runs in a shell and cannot introspect the live tool roster or the @@ -506,6 +514,27 @@ def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str: if not (tools_seen and mcp_seen and model_seen): L += [" Categories marked \"not measured\" are NOT part of this comparison.", " They are unchecked, not verified as empty.", ""] + if integrity is not None and changes is not None: + L += [" IS THE BASELINE ITSELF INTACT?", " " + "-" * 62] + if integrity == INTEGRITY_BROKEN: + # Stated before the drift section on purpose: if the baseline was + # altered, a "nothing changed" result below is meaningless, and + # letting the reader see it first would be actively misleading. + L += [" !! baseline.json FAILED its integrity check. It was modified", + " outside this tool, so the comparison below is unreliable.", + " Re-approve only once you are satisfied the current setup is", + " what you intend."] + elif integrity == INTEGRITY_UNTAGGED: + L.append(" ~ baseline carries no integrity tag (written by an older " + "version). Re-approve to tag it.") + else: + L.append(" >> baseline integrity tag verified.") + if baseline_digest: + L.append(f" digest: {baseline_digest}") + L += [" A co-located tag stops anyone who cannot read this directory,", + " not someone who can. Compare the digest above against the one", + " you recorded off-box to catch a silent re-baseline.", ""] + if changes is not None: L += [" NOTHING ADDED, NOTHING SUBTRACTED? (vs approved baseline)", " " + "-" * 62] @@ -530,6 +559,96 @@ def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str: return "\n".join(L) +# --------------------------------------------------------------------------- # +# baseline integrity +# --------------------------------------------------------------------------- # +#: Local secret used to tag the baseline. Separate from SIGNING_KEY, which needs +#: the crypto packages: the SessionStart hook is deliberately standard-library +#: only, so the tag has to be verifiable without them. Hence HMAC, not Ed25519. +BASELINE_TAG_KEY = STATE_DIR / "baseline_tag_key" + +#: Excluded from the digest it carries, since including it would be circular. +_INTEGRITY_FIELD = "integrity" + +#: Integrity verdicts for a loaded baseline. +INTEGRITY_OK = "ok" +INTEGRITY_UNTAGGED = "untagged" # no tag: written before tagging existed +INTEGRITY_BROKEN = "broken" # tag present and wrong: edited outside this tool + + +def _tag_key() -> bytes: + """Return the baseline tag secret, creating it on first use.""" + existing = _load(BASELINE_TAG_KEY) + if existing and isinstance(existing.get("secret_b64"), str): + try: + return base64.urlsafe_b64decode(existing["secret_b64"]) + except (ValueError, TypeError): + pass # corrupt secret: mint a fresh one below + secret = secrets.token_bytes(32) + BASELINE_TAG_KEY.parent.mkdir(parents=True, exist_ok=True) + BASELINE_TAG_KEY.write_text( + json.dumps({ + "alg": "HMAC-SHA256", + "secret_b64": base64.urlsafe_b64encode(secret).decode(), + "created_at": _now_iso(), + }, indent=2), + encoding="utf-8", + ) + try: # best-effort owner-only permissions + os.chmod(BASELINE_TAG_KEY, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + return secret + + +def state_digest(snap: dict) -> str: + """Digest of a snapshot's content, ignoring any integrity block. + + Deterministic, so the value ``approve`` prints can be compared by eye against + the value ``verify`` prints later. That comparison is the only thing here that + survives an attacker who owns the state directory. + """ + body = {k: v for k, v in snap.items() if k != _INTEGRITY_FIELD} + return _sha_bytes(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()) + + +def attach_integrity(snap: dict) -> dict: + """Return a copy of ``snap`` carrying its digest and an HMAC tag over it.""" + digest = state_digest(snap) + return {**snap, _INTEGRITY_FIELD: { + "alg": "HMAC-SHA256", + "digest": digest, + "tag": hmac.new(_tag_key(), digest.encode(), hashlib.sha256).hexdigest(), + "tagged_at": _now_iso(), + }} + + +def check_integrity(snap: dict | None) -> str: + """Verify a baseline's tag. Never raises. + + What this catches: accidental corruption, a hand-edit, and anyone who can + write baseline.json without being able to read the tag secret. + + What it does not catch: anyone who can read ``~/.claude/agentrust``, because + the secret lives there and they can retag whatever they like. Defending + against that needs the digest recorded off-box, which is why ``approve`` + prints it. Claiming more than this would make the check theatre. + """ + if snap is None: + return INTEGRITY_UNTAGGED + block = snap.get(_INTEGRITY_FIELD) + if not isinstance(block, dict) or not isinstance(block.get("tag"), str): + return INTEGRITY_UNTAGGED + if not BASELINE_TAG_KEY.is_file(): + # Without the secret the tag cannot be checked. Report untagged rather + # than broken: a missing secret is not evidence of tampering, and raising + # a tamper alarm on a benign state teaches the user to dismiss the real + # one. + return INTEGRITY_UNTAGGED + expected = hmac.new(_tag_key(), state_digest(snap).encode(), hashlib.sha256).hexdigest() + return INTEGRITY_OK if hmac.compare_digest(expected, block["tag"]) else INTEGRITY_BROKEN + + # --------------------------------------------------------------------------- # # state helpers # --------------------------------------------------------------------------- # @@ -538,6 +657,13 @@ def _save(path: Path, obj: dict) -> None: path.write_text(json.dumps(obj, indent=2), encoding="utf-8") +def _save_baseline(snap: dict) -> dict: + """Write the approved baseline with an integrity tag. Returns what was written.""" + tagged = attach_integrity(snap) + _save(BASELINE, tagged) + return tagged + + def _load(path: Path) -> dict | None: """Load a state file, or None if it is absent, unreadable, or corrupt. @@ -608,10 +734,19 @@ def _hook_body() -> None: base = _load(BASELINE) if base is None: - _save(BASELINE, snap) + _save_baseline(snap) msg = ("AgenTrust: baseline established for this Claude agent " f"({len(snap['skills'])} skills, {len(snap['mcp_servers'])} MCP on disk). " "Future sessions are checked against it. Run /manifest approve to re-baseline.") + elif check_integrity(base) == INTEGRITY_BROKEN: + # Report this ahead of any drift. If the baseline was altered, the + # comparison against it means nothing, so a "no changes" result would be + # worse than no result at all. + msg = ("AgenTrust WARNING: your approved baseline failed its integrity " + "check. baseline.json was modified outside this tool, so any drift " + "comparison against it is unreliable. Run /manifest verify, and " + "re-approve only once you are satisfied the current setup is what " + "you intend.") else: changes = diff(base, snap) if not changes: @@ -653,16 +788,26 @@ def cmd_verify(args) -> int: if base is None: print("No approved baseline yet. Run /manifest approve to establish one.") return 0 - print(render_report(snap, diff(base, snap), False)) + integrity = check_integrity(base) + print(render_report(snap, diff(base, snap), False, integrity=integrity, + baseline_digest=state_digest(base))) return 0 def cmd_approve(args) -> int: snap = snapshot(_live_from(args)) _save(LATEST, snap) - _save(BASELINE, snap) - print(render_report(snap, [], False)) + tagged = _save_baseline(snap) + digest = tagged[_INTEGRITY_FIELD]["digest"] + print(render_report(snap, [], False, integrity=INTEGRITY_OK, baseline_digest=digest)) print(f"\nApproved baseline updated: {BASELINE}") + # The tag secret sits in the same directory as the baseline, so it only stops + # someone who cannot read that directory. Recording this digest somewhere off + # the machine is what makes a silent re-baseline detectable by a human. + print(f"Baseline digest: {digest}") + print("Record that digest somewhere off this machine. `verify` prints the") + print("digest of the baseline it read, so a mismatch tells you the baseline") + print("was replaced even by someone who could retag it.") if args.sign: _, _ = sign_all(snap, Path(args.out)) print(f"Signed manifest + trace written to {args.out}") diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py index b563142..fe3858d 100644 --- a/claude-code/tests/test_capture.py +++ b/claude-code/tests/test_capture.py @@ -351,3 +351,124 @@ def test_manifest_is_externally_verifiable_and_tamper_evident(tmp_path, monkeypa tampered["artifacts"]["policy_bundle"]["hash"] = "sha256:" + "0" * 64 bad = verify_manifest(tampered, ctx, RevocationStore()) assert bad.signature_verified is False + + +# --------------------------------------------------------------------------- +# Baseline integrity: the baseline is what every comparison is made against, so +# a baseline that can be rewritten unnoticed makes the drift check pass forever. +# --------------------------------------------------------------------------- +def _isolate_tagging(tmp_path, monkeypatch): + """Point baseline, latest and the tag secret at a temp dir.""" + state = tmp_path / "agentrust" + monkeypatch.setattr(capture, "STATE_DIR", state) + monkeypatch.setattr(capture, "BASELINE", state / "baseline.json") + monkeypatch.setattr(capture, "LATEST", state / "session-latest.json") + monkeypatch.setattr(capture, "BASELINE_TAG_KEY", state / "baseline_tag_key") + return state + + +class TestBaselineIntegrity: + def test_a_freshly_written_baseline_verifies(self, tmp_path, monkeypatch): + _isolate_tagging(tmp_path, monkeypatch) + written = capture._save_baseline(_base()) + assert capture.check_integrity(written) == capture.INTEGRITY_OK + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_OK + + def test_editing_the_baseline_is_detected(self, tmp_path, monkeypatch): + """The whole point: a rewritten baseline must not read as intact.""" + _isolate_tagging(tmp_path, monkeypatch) + capture._save_baseline(_base()) + tampered = capture._load(capture.BASELINE) + # An attacker adds their skill to the approved set so drift goes quiet. + tampered["skills"]["exfil"] = "sha256:" + "e" * 64 + capture._save(capture.BASELINE, tampered) + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_BROKEN + + def test_stripping_the_tag_reads_as_untagged_not_intact(self, tmp_path, monkeypatch): + _isolate_tagging(tmp_path, monkeypatch) + capture._save_baseline(_base()) + stripped = capture._load(capture.BASELINE) + del stripped["integrity"] + capture._save(capture.BASELINE, stripped) + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNTAGGED + + def test_a_forged_tag_is_detected(self, tmp_path, monkeypatch): + _isolate_tagging(tmp_path, monkeypatch) + capture._save_baseline(_base()) + forged = capture._load(capture.BASELINE) + forged["skills"]["exfil"] = "sha256:" + "e" * 64 + forged["integrity"]["tag"] = "0" * 64 + capture._save(capture.BASELINE, forged) + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_BROKEN + + def test_older_untagged_baseline_is_not_reported_as_tampering(self, tmp_path, monkeypatch): + """A baseline predating tagging is benign. Crying tamper over it would + teach the user to dismiss the real alarm.""" + _isolate_tagging(tmp_path, monkeypatch) + capture._save(capture.BASELINE, _base()) # untagged, as an old version wrote it + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNTAGGED + + def test_missing_secret_reads_as_untagged_not_broken(self, tmp_path, monkeypatch): + """A deleted secret is not evidence of tampering.""" + state = _isolate_tagging(tmp_path, monkeypatch) + capture._save_baseline(_base()) + loaded = capture._load(capture.BASELINE) + (state / "baseline_tag_key").unlink() + assert capture.check_integrity(loaded) == capture.INTEGRITY_UNTAGGED + + def test_none_reads_as_untagged(self): + assert capture.check_integrity(None) == capture.INTEGRITY_UNTAGGED + + def test_digest_ignores_the_integrity_block(self, tmp_path, monkeypatch): + """Otherwise the digest would have to cover a tag computed over itself.""" + _isolate_tagging(tmp_path, monkeypatch) + snap = _base() + assert capture.state_digest(capture.attach_integrity(snap)) == capture.state_digest(snap) + + def test_digest_changes_when_content_changes(self, tmp_path, monkeypatch): + _isolate_tagging(tmp_path, monkeypatch) + assert capture.state_digest(_base()) != capture.state_digest( + _base(skills={"other": "sha256:" + "d" * 64}) + ) + + def test_tag_secret_is_not_the_manifest_signing_key(self): + """The hook is stdlib-only, so the tag must be checkable without the + crypto packages the Ed25519 signing key needs.""" + assert capture.BASELINE_TAG_KEY != capture.SIGNING_KEY + + +class TestIntegrityIsSurfacedBeforeDrift: + """A broken baseline makes the drift comparison meaningless, so it is stated + first rather than buried under a reassuring result.""" + + def _snap(self): + return _report_snap(["skills", "policy", "prompt", "mcp", "tools"]) + + def test_broken_baseline_is_called_out_before_the_drift_section(self): + out = capture.render_report(self._snap(), [], False, + integrity=capture.INTEGRITY_BROKEN) + assert "FAILED its integrity check" in out + assert out.index("FAILED its integrity check") < out.index("NOTHING ADDED") + + def test_clean_verdict_still_prints_but_is_qualified(self): + out = capture.render_report(self._snap(), [], False, + integrity=capture.INTEGRITY_BROKEN) + assert "nothing added, nothing subtracted" in out + assert "unreliable" in out + + def test_untagged_baseline_prompts_a_re_approve(self): + out = capture.render_report(self._snap(), [], False, + integrity=capture.INTEGRITY_UNTAGGED) + assert "no integrity tag" in out + assert "FAILED" not in out + + def test_verified_tag_is_reported_together_with_its_limit(self): + out = capture.render_report(self._snap(), [], False, integrity=capture.INTEGRITY_OK, + baseline_digest="sha256:" + "a" * 64) + assert "integrity tag verified" in out + # The limit must travel with the claim, or the claim is theatre. + assert "not someone who can" in out + assert "sha256:" + "a" * 64 in out + + def test_section_is_omitted_when_integrity_was_not_checked(self): + assert "BASELINE ITSELF INTACT" not in capture.render_report(self._snap(), None, False) From 756623c5615ac2290f966d3c11e0f5a990bf03e6 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 31 Jul 2026 11:41:04 -0700 Subject: [PATCH 2/2] fix(baseline): drop the HMAC secret, seal with a bare digest CodeQL flagged the stored tag secret as clear-text storage of sensitive information, and the flag was worth more than a suppression. Working through it: the only adversary an HMAC defeats here is one who can WRITE ~/.claude/agentrust without being able to READ it. On a developer machine that adversary barely exists, because anything that can write your home directory can read it and would simply retag. So the secret bought almost no coverage while adding a credential to leak, a file to manage, and a claim inviting a reader to assume more protection than exists. A bare SHA-256 digest over the baseline content gives the same real coverage with nothing to steal: corruption, truncation and a hand-edit that does not recompute it are all caught, and neither scheme catches an attacker who owns the directory. The control that does survive that attacker is unchanged and is where the security actually lives: approve prints the digest, verify prints the digest of the baseline it read, and a human who recorded the first sees a silent re-baseline. There is now a test that makes this limit executable rather than prose, asserting that a resealed rewrite passes the local check while its digest no longer matches what was approved. Removes the hmac and secrets imports and writes no credential at all; a test asserts the state directory contains only baseline.json. INTEGRITY_UNTAGGED is kept as an alias of INTEGRITY_UNSEALED so an older caller keeps working. Suite: 42 passed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Imran Siddique --- claude-code/README.md | 30 ++++---- claude-code/engine/capture.py | 116 +++++++++++++----------------- claude-code/tests/test_capture.py | 76 ++++++++++++-------- 3 files changed, 114 insertions(+), 108 deletions(-) diff --git a/claude-code/README.md b/claude-code/README.md index 399467e..4731625 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -186,18 +186,24 @@ This plugin is honest about what it is. On a normal developer machine: its own model, tools, and MCP servers. Nothing verifies that report, so an agent motivated to hide a connected server could simply omit it. Closing this needs an observer outside the agent and cannot be done from inside it. -- **The baseline tag stops a writer, not a reader.** `baseline.json` carries an - HMAC tag, so corruption, a hand-edit, or anyone who can write the file without - reading `~/.claude/agentrust` is caught. The secret lives in that same - directory, so anyone who can read it can retag a baseline they rewrote. This is - tamper *evidence* against a limited adversary, not tamper *proofing* against a - full home-directory compromise, and it is not presented as the latter. - - The mitigation that does survive that adversary is off-box: `approve` prints a - baseline digest and `verify` prints the digest of the baseline it read. Record - the first somewhere else and compare. A silent re-baseline changes the digest - even when the attacker can produce a valid tag. An anchor in an append-only log - would automate that comparison and is the natural next step. +- **The baseline seal catches accidents, not adversaries.** `baseline.json` + carries a SHA-256 digest of its own content, so corruption, truncation, and a + hand-edit that does not recompute it are all caught. Anyone who owns + `~/.claude/agentrust` can recompute the digest as easily as the tool can, so the + local check is tamper *evidence* against accident and carelessness, not tamper + *proofing* against a compromised home directory. It is not presented as the + latter. + + We tried the stronger-looking version first, an HMAC with a local secret, and + removed it. The only adversary an HMAC defeats here is one who can write that + directory without being able to read it, which barely exists on a developer + machine, and the stored secret was a credential to leak in exchange. + + The mitigation that does survive a real adversary is off-box: `approve` prints + the baseline digest and `verify` prints the digest of the baseline it read. + Record the first elsewhere and compare. A silent re-baseline changes the digest + even when the attacker resealed it perfectly. Anchoring the digest in an + append-only log would automate that comparison and is the natural next step. ## Layout diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py index e885a68..e13443d 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -32,10 +32,8 @@ import argparse import base64 import hashlib -import hmac import json import os -import secrets import stat import sys import time @@ -524,16 +522,17 @@ def render_report( " outside this tool, so the comparison below is unreliable.", " Re-approve only once you are satisfied the current setup is", " what you intend."] - elif integrity == INTEGRITY_UNTAGGED: - L.append(" ~ baseline carries no integrity tag (written by an older " - "version). Re-approve to tag it.") + elif integrity == INTEGRITY_UNSEALED: + L.append(" ~ baseline carries no digest (written by an older version). " + "Re-approve to seal it.") else: - L.append(" >> baseline integrity tag verified.") + L.append(" >> baseline digest verified.") if baseline_digest: L.append(f" digest: {baseline_digest}") - L += [" A co-located tag stops anyone who cannot read this directory,", - " not someone who can. Compare the digest above against the one", - " you recorded off-box to catch a silent re-baseline.", ""] + L += [" A digest stored beside the content catches corruption and a", + " hand-edit, not an attacker who owns this directory and can", + " recompute it. Compare the digest above against the one you", + " recorded off-box: that is what catches a silent re-baseline.", ""] if changes is not None: L += [" NOTHING ADDED, NOTHING SUBTRACTED? (vs approved baseline)", @@ -562,43 +561,36 @@ def render_report( # --------------------------------------------------------------------------- # # baseline integrity # --------------------------------------------------------------------------- # -#: Local secret used to tag the baseline. Separate from SIGNING_KEY, which needs -#: the crypto packages: the SessionStart hook is deliberately standard-library -#: only, so the tag has to be verifiable without them. Hence HMAC, not Ed25519. -BASELINE_TAG_KEY = STATE_DIR / "baseline_tag_key" +# A note on what this is, because the obvious design is worse than it looks. +# +# The first version of this used an HMAC over the baseline with a 32-byte secret +# stored beside it. CodeQL flagged the stored secret, correctly, and the flag was +# worth more than a suppression: the only adversary an HMAC defeats here is one +# who can WRITE ~/.claude/agentrust without being able to READ it. On a developer +# machine that adversary is close to fictional, since anything that can write your +# home directory can read it and would simply retag. So the secret bought almost +# no coverage while adding a credential to leak, a file to manage, and a claim +# that invites a reader to assume more protection than exists. +# +# A bare digest gives the same real coverage with nothing to steal: it catches +# corruption, truncation and a hand-edit that does not recompute it. Neither a +# digest nor an HMAC catches an attacker who owns the directory. +# +# The control that does survive that attacker is off-box: `approve` prints the +# digest, `verify` prints the digest of the baseline it read, and a human who +# recorded the first sees a silent re-baseline. That is where the security lives, +# so the code keeps the cheap local check and points at the real one. #: Excluded from the digest it carries, since including it would be circular. _INTEGRITY_FIELD = "integrity" #: Integrity verdicts for a loaded baseline. INTEGRITY_OK = "ok" -INTEGRITY_UNTAGGED = "untagged" # no tag: written before tagging existed -INTEGRITY_BROKEN = "broken" # tag present and wrong: edited outside this tool +INTEGRITY_UNSEALED = "unsealed" # no digest: written before sealing existed +INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside this tool - -def _tag_key() -> bytes: - """Return the baseline tag secret, creating it on first use.""" - existing = _load(BASELINE_TAG_KEY) - if existing and isinstance(existing.get("secret_b64"), str): - try: - return base64.urlsafe_b64decode(existing["secret_b64"]) - except (ValueError, TypeError): - pass # corrupt secret: mint a fresh one below - secret = secrets.token_bytes(32) - BASELINE_TAG_KEY.parent.mkdir(parents=True, exist_ok=True) - BASELINE_TAG_KEY.write_text( - json.dumps({ - "alg": "HMAC-SHA256", - "secret_b64": base64.urlsafe_b64encode(secret).decode(), - "created_at": _now_iso(), - }, indent=2), - encoding="utf-8", - ) - try: # best-effort owner-only permissions - os.chmod(BASELINE_TAG_KEY, stat.S_IRUSR | stat.S_IWUSR) - except OSError: - pass - return secret +#: Retained so an older caller keeps working; UNSEALED is the current name. +INTEGRITY_UNTAGGED = INTEGRITY_UNSEALED def state_digest(snap: dict) -> str: @@ -613,40 +605,28 @@ def state_digest(snap: dict) -> str: def attach_integrity(snap: dict) -> dict: - """Return a copy of ``snap`` carrying its digest and an HMAC tag over it.""" - digest = state_digest(snap) + """Return a copy of ``snap`` sealed with a digest over its content.""" return {**snap, _INTEGRITY_FIELD: { - "alg": "HMAC-SHA256", - "digest": digest, - "tag": hmac.new(_tag_key(), digest.encode(), hashlib.sha256).hexdigest(), - "tagged_at": _now_iso(), + "alg": "SHA-256", + "digest": state_digest(snap), + "sealed_at": _now_iso(), }} def check_integrity(snap: dict | None) -> str: - """Verify a baseline's tag. Never raises. - - What this catches: accidental corruption, a hand-edit, and anyone who can - write baseline.json without being able to read the tag secret. + """Recompute a baseline's digest and compare. Never raises. - What it does not catch: anyone who can read ``~/.claude/agentrust``, because - the secret lives there and they can retag whatever they like. Defending - against that needs the digest recorded off-box, which is why ``approve`` - prints it. Claiming more than this would make the check theatre. + Catches accidental corruption, truncation, and a hand-edit that does not + recompute the digest. Does not catch an attacker who owns + ``~/.claude/agentrust``, who can recompute it as easily as this function can. + Off-box comparison of the printed digest is what covers that case. """ if snap is None: - return INTEGRITY_UNTAGGED + return INTEGRITY_UNSEALED block = snap.get(_INTEGRITY_FIELD) - if not isinstance(block, dict) or not isinstance(block.get("tag"), str): - return INTEGRITY_UNTAGGED - if not BASELINE_TAG_KEY.is_file(): - # Without the secret the tag cannot be checked. Report untagged rather - # than broken: a missing secret is not evidence of tampering, and raising - # a tamper alarm on a benign state teaches the user to dismiss the real - # one. - return INTEGRITY_UNTAGGED - expected = hmac.new(_tag_key(), state_digest(snap).encode(), hashlib.sha256).hexdigest() - return INTEGRITY_OK if hmac.compare_digest(expected, block["tag"]) else INTEGRITY_BROKEN + if not isinstance(block, dict) or not isinstance(block.get("digest"), str): + return INTEGRITY_UNSEALED + return INTEGRITY_OK if block["digest"] == state_digest(snap) else INTEGRITY_BROKEN # --------------------------------------------------------------------------- # @@ -801,13 +781,13 @@ def cmd_approve(args) -> int: digest = tagged[_INTEGRITY_FIELD]["digest"] print(render_report(snap, [], False, integrity=INTEGRITY_OK, baseline_digest=digest)) print(f"\nApproved baseline updated: {BASELINE}") - # The tag secret sits in the same directory as the baseline, so it only stops - # someone who cannot read that directory. Recording this digest somewhere off - # the machine is what makes a silent re-baseline detectable by a human. + # The digest sits beside the content it describes, so on its own it catches + # accidents rather than adversaries. Recorded off this machine, it is what + # makes a silent re-baseline visible to a human. print(f"Baseline digest: {digest}") print("Record that digest somewhere off this machine. `verify` prints the") print("digest of the baseline it read, so a mismatch tells you the baseline") - print("was replaced even by someone who could retag it.") + print("was replaced even by someone who could recompute the digest locally.") if args.sign: _, _ = sign_all(snap, Path(args.out)) print(f"Signed manifest + trace written to {args.out}") diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py index fe3858d..2bb984d 100644 --- a/claude-code/tests/test_capture.py +++ b/claude-code/tests/test_capture.py @@ -358,12 +358,11 @@ def test_manifest_is_externally_verifiable_and_tamper_evident(tmp_path, monkeypa # a baseline that can be rewritten unnoticed makes the drift check pass forever. # --------------------------------------------------------------------------- def _isolate_tagging(tmp_path, monkeypatch): - """Point baseline, latest and the tag secret at a temp dir.""" + """Point baseline and latest at a temp dir.""" state = tmp_path / "agentrust" monkeypatch.setattr(capture, "STATE_DIR", state) monkeypatch.setattr(capture, "BASELINE", state / "baseline.json") monkeypatch.setattr(capture, "LATEST", state / "session-latest.json") - monkeypatch.setattr(capture, "BASELINE_TAG_KEY", state / "baseline_tag_key") return state @@ -384,40 +383,60 @@ def test_editing_the_baseline_is_detected(self, tmp_path, monkeypatch): capture._save(capture.BASELINE, tampered) assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_BROKEN - def test_stripping_the_tag_reads_as_untagged_not_intact(self, tmp_path, monkeypatch): + def test_stripping_the_digest_reads_as_unsealed_not_intact(self, tmp_path, monkeypatch): _isolate_tagging(tmp_path, monkeypatch) capture._save_baseline(_base()) stripped = capture._load(capture.BASELINE) del stripped["integrity"] capture._save(capture.BASELINE, stripped) - assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNTAGGED + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNSEALED - def test_a_forged_tag_is_detected(self, tmp_path, monkeypatch): + def test_a_wrong_digest_is_detected(self, tmp_path, monkeypatch): _isolate_tagging(tmp_path, monkeypatch) capture._save_baseline(_base()) forged = capture._load(capture.BASELINE) forged["skills"]["exfil"] = "sha256:" + "e" * 64 - forged["integrity"]["tag"] = "0" * 64 + forged["integrity"]["digest"] = "sha256:" + "0" * 64 capture._save(capture.BASELINE, forged) assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_BROKEN - def test_older_untagged_baseline_is_not_reported_as_tampering(self, tmp_path, monkeypatch): - """A baseline predating tagging is benign. Crying tamper over it would + def test_older_unsealed_baseline_is_not_reported_as_tampering(self, tmp_path, monkeypatch): + """A baseline predating sealing is benign. Crying tamper over it would teach the user to dismiss the real alarm.""" _isolate_tagging(tmp_path, monkeypatch) - capture._save(capture.BASELINE, _base()) # untagged, as an old version wrote it - assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNTAGGED + capture._save(capture.BASELINE, _base()) # unsealed, as an old version wrote it + assert capture.check_integrity(capture._load(capture.BASELINE)) == capture.INTEGRITY_UNSEALED - def test_missing_secret_reads_as_untagged_not_broken(self, tmp_path, monkeypatch): - """A deleted secret is not evidence of tampering.""" + def test_an_attacker_who_recomputes_the_digest_is_not_caught(self, tmp_path, monkeypatch): + """Documents the limit as executable fact rather than prose. + + Anyone who owns the state directory can reseal what they rewrote, so the + local check reports ok. This is why `approve` prints the digest for + off-box recording: the digest itself changes, even though the seal is + self-consistent. + """ + _isolate_tagging(tmp_path, monkeypatch) + approved = capture._save_baseline(_base()) + rewritten = capture._load(capture.BASELINE) + rewritten["skills"]["exfil"] = "sha256:" + "e" * 64 + capture._save(capture.BASELINE, capture.attach_integrity(rewritten)) + reloaded = capture._load(capture.BASELINE) + assert capture.check_integrity(reloaded) == capture.INTEGRITY_OK # not caught locally + # ...but the digest a human recorded off-box no longer matches. + assert capture.state_digest(reloaded) != approved["integrity"]["digest"] + + def test_none_reads_as_unsealed(self): + assert capture.check_integrity(None) == capture.INTEGRITY_UNSEALED + + def test_no_secret_is_written_to_disk(self, tmp_path, monkeypatch): + """The design deliberately stores no credential. A stored secret would + only defeat an adversary who can write this directory without reading it, + which is close to fictional on a dev box, while being a thing to leak.""" state = _isolate_tagging(tmp_path, monkeypatch) capture._save_baseline(_base()) - loaded = capture._load(capture.BASELINE) - (state / "baseline_tag_key").unlink() - assert capture.check_integrity(loaded) == capture.INTEGRITY_UNTAGGED - - def test_none_reads_as_untagged(self): - assert capture.check_integrity(None) == capture.INTEGRITY_UNTAGGED + written = {p.name for p in state.iterdir()} + assert written == {"baseline.json"} + assert not hasattr(capture, "BASELINE_TAG_KEY") def test_digest_ignores_the_integrity_block(self, tmp_path, monkeypatch): """Otherwise the digest would have to cover a tag computed over itself.""" @@ -431,10 +450,10 @@ def test_digest_changes_when_content_changes(self, tmp_path, monkeypatch): _base(skills={"other": "sha256:" + "d" * 64}) ) - def test_tag_secret_is_not_the_manifest_signing_key(self): - """The hook is stdlib-only, so the tag must be checkable without the - crypto packages the Ed25519 signing key needs.""" - assert capture.BASELINE_TAG_KEY != capture.SIGNING_KEY + def test_the_check_needs_no_crypto_packages(self): + """The SessionStart hook is stdlib-only, so sealing must be verifiable + without the packages the Ed25519 signing key needs.""" + assert capture.attach_integrity(_base())["integrity"]["alg"] == "SHA-256" class TestIntegrityIsSurfacedBeforeDrift: @@ -456,18 +475,19 @@ def test_clean_verdict_still_prints_but_is_qualified(self): assert "nothing added, nothing subtracted" in out assert "unreliable" in out - def test_untagged_baseline_prompts_a_re_approve(self): + def test_unsealed_baseline_prompts_a_re_approve(self): out = capture.render_report(self._snap(), [], False, - integrity=capture.INTEGRITY_UNTAGGED) - assert "no integrity tag" in out + integrity=capture.INTEGRITY_UNSEALED) + assert "no digest" in out assert "FAILED" not in out - def test_verified_tag_is_reported_together_with_its_limit(self): + def test_verified_digest_is_reported_together_with_its_limit(self): out = capture.render_report(self._snap(), [], False, integrity=capture.INTEGRITY_OK, baseline_digest="sha256:" + "a" * 64) - assert "integrity tag verified" in out + assert "baseline digest verified" in out # The limit must travel with the claim, or the claim is theatre. - assert "not someone who can" in out + assert "not an attacker who owns this directory" in out + assert "recorded off-box" in out assert "sha256:" + "a" * 64 in out def test_section_is_omitted_when_integrity_was_not_checked(self):