diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 083782c..7ae187c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -239,7 +239,9 @@ this arrive through something the operator set up?* 1. **Signed intent ledger** (`~/.aegis/intent.jsonl`). The agent harness calls `aegis.py intent hook ` after each file-writing tool call; Aegis - appends one HMAC'd `{ts, path, sha256, tool}` record. A change whose content + appends one HMAC'd `{ts, path, sha256, tool}` record per written file — the + payload's `file_path`/`path`, or every Add/Update/Move-to target of a V4A + patch (Codex `apply_patch`, Hermes `patch`). A change whose content hash matches a valid record is `self-attested` → **LOW**. This covers what git cannot: untracked files and binaries outside any repo. 2. **Git self-vs-foreign**. A commit is `self-committed` → **LOW** only when two diff --git a/aegis.py b/aegis.py index d82a8a2..ee1f263 100755 --- a/aegis.py +++ b/aegis.py @@ -17405,6 +17405,47 @@ def cmd_learn(argv): return 1 +_V4A_PATH = re.compile(r"\*\*\*\s*(Add File|Update File|Move to):\s*(.+?)\s*$") + + +def _v4a_patch_paths(text, cwd): + """Files a V4A patch (Codex apply_patch, Hermes patch mode=patch) leaves + on disk: Add/Update targets, with an Update that is followed by a Move to + replaced by its destination. Delete File has no content to attest. Only + line-start markers count, so prose that quotes one is not a write.""" + if not isinstance(text, str): + return [] + out = [] + for line in text.splitlines(): + m = _V4A_PATH.match(line) + if not m: + continue + if m.group(1) == "Move to" and out: + out.pop() + out.append(os.path.normpath(os.path.join(cwd, m.group(2)))) + return out + + +def _intent_hook_paths(payload): + """The written file(s) named by an agent's post-tool-call JSON: a direct + path (Claude Code, Hermes write_file / patch mode=replace), else every + file a V4A patch in tool_input writes (Codex, Hermes patch mode=patch).""" + ti = payload.get("tool_input") or {} + if isinstance(ti, dict): + p = ti.get("file_path") or ti.get("path") + if p: + return [p] + texts = list(ti.values()) + else: + texts = [ti] + cwd = payload.get("cwd") or os.getcwd() + out = [] + for t in texts: + for s in (t if isinstance(t, list) else [t]): + out.extend(_v4a_patch_paths(s, cwd)) + return out + + def cmd_intent(argv): """CLI: `intent record [tool]` | `intent hook ` | `intent list [n]`. Hook mode reads the harness's tool-call JSON on stdin, @@ -17419,10 +17460,9 @@ def cmd_intent(argv): tool = argv[3] if len(argv) > 3 else "agent" try: payload = json.loads(sys.stdin.read(1 << 20) or "{}") - ti = payload.get("tool_input") or {} - p = ti.get("file_path") or ti.get("path") or "" - if p and _intent_worthy(p): - intent_record(p, tool) + for p in _intent_hook_paths(payload): + if _intent_worthy(p): + intent_record(p, tool) except Exception: pass return 0 diff --git a/tests/test_custody.py b/tests/test_custody.py index ed03267..0caadc2 100644 --- a/tests/test_custody.py +++ b/tests/test_custody.py @@ -288,6 +288,60 @@ def test_hook_mode_attests_stdin_payload_and_never_fails(self): sys.stdin = saved self.assertTrue(aegis._intent_attested(p, aegis.sha256(p))) + def _hook(self, payload, tool): + import io + saved = sys.stdin + try: + sys.stdin = io.StringIO(json.dumps(payload)) + return aegis.cmd_intent(["aegis.py", "intent", "hook", tool]) + finally: + sys.stdin = saved + + def test_hook_mode_attests_every_file_a_v4a_patch_writes(self): + """Codex's apply_patch and Hermes's patch(mode=patch) carry no + file_path: the paths live inside the patch text, relative to the + payload's cwd. Until 2026-09-24 neither agent's edits produced one + intent record, so their agent-config writes graded as unexplained.""" + root = os.path.join(self.tmp, "agentroot") + upd = os.path.join(root, "config.toml") + add = os.path.join(root, "AGENTS.md") + moved = os.path.join(root, "hooks.json") + for p, body in ((upd, "[x]\n"), (add, "# a\n"), (moved, "{}")): + with open(p, "w") as f: + f.write(body) + patch = ("*** Begin Patch\n" + "*** Update File: config.toml\n@@\n-[y]\n+[x]\n" + "***Add File: %s\n+# a\n" + "*** Update File: hooks.old.json\n*** Move to: hooks.json\n" + "*** Delete File: old.json\n" + "*** End Patch" % add) + # Codex: patch text as a tool_input value, relative to cwd. + self.assertEqual(self._hook({"tool_name": "apply_patch", "cwd": root, + "tool_input": {"command": patch}}, + "codex"), 0) + for p in (upd, add, moved): + self.assertTrue(aegis._intent_attested(p, aegis.sha256(p)), p) + # Delete File has no content to attest; the source of a move is gone. + self.assertEqual(aegis._v4a_patch_paths(patch, root), + [upd, add, moved]) + # Codex freeform: tool_input may be the bare patch string. + with open(upd, "w") as f: + f.write("[z]\n") + self._hook({"tool_name": "apply_patch", "cwd": root, + "tool_input": patch}, "codex") + self.assertTrue(aegis._intent_attested(upd, aegis.sha256(upd))) + # Hermes: patch mode carries the text under "patch". + with open(add, "w") as f: + f.write("# b\n") + self._hook({"tool_name": "patch", "tool_input": + {"mode": "patch", "patch": patch}}, "hermes") + self.assertTrue(aegis._intent_attested(add, aegis.sha256(add))) + + def test_v4a_paths_ignores_prose_that_merely_mentions_a_marker(self): + self.assertEqual(aegis._v4a_patch_paths( + "see *** Update File: x in the docs", "/r"), []) + self.assertEqual(aegis._v4a_patch_paths(12, "/r"), []) + def test_oversized_ledger_prunes_but_keeps_fresh_records(self): p = os.path.join(self.tmp, "agentroot", "settings.json") with open(p, "w") as f: