From c6e1abb1f46c25817db715ae9bf555108954ddc2 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 31 Jul 2026 13:48:49 -0700 Subject: [PATCH 1/2] feat(core): add agentrust-capture-core, the shared capture engine core Three engines carried the same logic in three copies, and the cost was not theoretical. The skill-fingerprinting bypass closed in #63 had to be found and fixed again in #65 because the second engine had it too, and the reporting defect in #62 shipped in one engine while the other kept it. A fourth engine for Copilot would have meant writing both bugs a fourth time. This package owns the parts that are genuinely identical across agents: hashing tree_digest over a component directory, file and mapping digests, the exclusion denylist, uuid7, now_iso seal sealing a baseline with a content digest and checking it compare map, set, scalar and rollup diffs, plus observed-category and measurement-scope gating state atomic write, load-corrupt-as-absent, sealed baseline write report the honesty vocabulary: unmeasured labelling, partial-coverage qualification, the baseline-integrity block Standard library only, with a test asserting it, because the engines run from shell hooks at session start and must work before anything is installed. What is deliberately NOT unified: where an agent keeps its files, what its categories are called, how its report is laid out, and baseline scoping. Claude Code keeps one baseline per machine and Codex keeps one per workspace, because a workspace carries its own instructions and skills. Both are correct for their agent, so an engine supplies its own paths and the core only reads and writes. The two honesty rules are encoded rather than documented, since both were bugs: an unmeasured category renders as unmeasured rather than as a zero, and a no-changes verdict is qualified as "in the categories checked" when coverage is partial. 45 tests, including the bypass cases that were live in shipped engines, the exclusion behaviour that keeps ordinary use from crying wolf, and one that makes the sealing limit executable: a resealed rewrite passes the local check while its digest no longer matches what was approved. ruff clean at py39, which is the floor because the scheduled-agents matrix tests 3.9. Not yet published to PyPI and not yet wired into any engine; both follow. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Imran Siddique --- packages/agentrust-capture-core/README.md | 81 +++++ .../agentrust-capture-core/pyproject.toml | 45 +++ .../src/agentrust_capture_core/__init__.py | 97 ++++++ .../src/agentrust_capture_core/compare.py | 119 ++++++++ .../src/agentrust_capture_core/hashing.py | 146 +++++++++ .../src/agentrust_capture_core/report.py | 101 +++++++ .../src/agentrust_capture_core/seal.py | 80 +++++ .../src/agentrust_capture_core/state.py | 76 +++++ .../agentrust-capture-core/tests/test_core.py | 280 ++++++++++++++++++ 9 files changed, 1025 insertions(+) create mode 100644 packages/agentrust-capture-core/README.md create mode 100644 packages/agentrust-capture-core/pyproject.toml create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/compare.py create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/report.py create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/seal.py create mode 100644 packages/agentrust-capture-core/src/agentrust_capture_core/state.py create mode 100644 packages/agentrust-capture-core/tests/test_core.py diff --git a/packages/agentrust-capture-core/README.md b/packages/agentrust-capture-core/README.md new file mode 100644 index 0000000..b50ad5e --- /dev/null +++ b/packages/agentrust-capture-core/README.md @@ -0,0 +1,81 @@ +# agentrust-capture-core + +Shared fingerprinting, comparison and baseline-sealing core for AgenTrust +agent-integrity capture engines. + +Each engine answers one question about a different coding agent: + +> Is this the agent composition I approved, with nothing added and nothing +> subtracted? + +What differs between agents is **where to look** and **what to call things**. What +must not differ is how content is fingerprinted, how snapshots are compared, how a +baseline is sealed, and the rules that keep a report honest. This package owns the +second list. + +## Why it exists + +Those parts lived in three copies, and the cost was not theoretical: + +- The same skill-fingerprinting bypass had to be found and fixed **twice**, + independently, in two shipped engines. A component was digested by its manifest + alone, so a payload swapped into a sibling `scripts/` directory left the + fingerprint unchanged and the report said "nothing added, nothing subtracted". +- A reporting defect that rendered **unmeasured** categories as measured zeros was + fixed in one engine while the other kept shipping it. + +A fourth engine would have meant writing both bugs a fourth time. + +## What it does not do + +No dependencies. The engines are invoked by shell hooks at session start and must +run before anything is installed, so this package is standard library only and a +test asserts it. + +No opinion on where an agent keeps its files, what its categories are called, or +how its report is laid out. Baseline scoping in particular is deliberately not +unified: Claude Code keeps one baseline per machine, Codex keeps one per workspace +because a workspace carries its own instructions and skills. Both are correct for +their agent, so an engine supplies its own paths. + +## The pieces + +| Module | Owns | +|---|---| +| `hashing` | `tree_digest` over a component directory, file and mapping digests, the exclusion denylist, `uuid7`, `now_iso` | +| `seal` | Sealing a baseline with a content digest and checking it: `ok`, `unsealed`, `broken` | +| `compare` | Map, set, scalar and rollup diffs, plus observed-category and measurement-scope gating | +| `state` | Atomic write, load-corrupt-as-absent, sealed baseline write | +| `report` | The honesty vocabulary: unmeasured labelling, partial-coverage qualification, the baseline-integrity block | + +## Two rules worth knowing before you use it + +**An unmeasured category is not an empty one.** A shell hook cannot see a live tool +roster or the model. Rendering those as `0 tools` states a measurement that was +never taken, and a reader who cannot tell "we did not check" from "we checked and +found nothing" treats an absence as a pass. Use `measured_or` and +`unmeasured_footnote`. + +**A partial check is not a clean bill of health.** `clean_verdict(complete=False)` +qualifies the verdict as "in the categories checked". + +## On what sealing is worth + +`attach_seal` stores a SHA-256 digest of the baseline's own content. It catches +corruption, truncation, and a hand-edit that does not recompute it. **It does not +catch an attacker who owns the state directory**, who can recompute the digest as +easily as this package can. + +An earlier design used an HMAC with a locally stored secret. It was removed: the +only adversary an HMAC defeats here is one who can *write* the state 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 control that does survive a real adversary is off-box. Engines print the +baseline digest on approve and on verify, so a human who recorded the first sees a +silent re-baseline even when the attacker resealed it perfectly. There is a test +that makes this limit executable rather than prose. + +## License + +Apache-2.0. diff --git a/packages/agentrust-capture-core/pyproject.toml b/packages/agentrust-capture-core/pyproject.toml new file mode 100644 index 0000000..b3dfa5e --- /dev/null +++ b/packages/agentrust-capture-core/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agentrust-capture-core" +version = "0.1.0" +description = "Shared fingerprinting, comparison and baseline-sealing core for AgenTrust agent-integrity capture engines" +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [{ name = "AgenTrust Contributors", email = "oss@agentrust-io.com" }] +keywords = ["agent", "integrity", "drift", "attestation", "agent-manifest", "trace"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", + "Typing :: Typed", +] +# 3.9 is the floor because the scheduled-agents CI matrix tests it. +requires-python = ">=3.9" +# Deliberately empty. The capture engines run from shell hooks at session start, +# before anything is installed, so the core cannot depend on a third-party package. +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.4", +] + +[project.urls] +Homepage = "https://github.com/agentrust-io/integrations" +Source = "https://github.com/agentrust-io/integrations/tree/main/packages/agentrust-capture-core" + +[tool.hatch.build.targets.wheel] +packages = ["src/agentrust_capture_core"] + +[tool.ruff] +line-length = 100 diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py b/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py new file mode 100644 index 0000000..ef9c4e1 --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/__init__.py @@ -0,0 +1,97 @@ +"""Shared core for AgenTrust agent-integrity capture engines. + +Each engine answers one question about a different coding agent: is this the +composition I approved, with nothing added and nothing subtracted? What differs +between agents is where to look and what to call things. What must not differ is +how content is fingerprinted, how snapshots are compared, how a baseline is sealed, +and the rules that keep a report honest. + +Those lived in three copies before this package existed, and the cost was not +theoretical: the same skill-fingerprinting bypass had to be found and fixed twice, +independently, and a reporting defect once. This package is the single source of +truth for the parts that are genuinely identical. + +Standard library only, because the engines run from shell hooks at session start +and must work before anything is installed. +""" + +from __future__ import annotations + +from .compare import ( + Change, + diff_hash, + diff_maps, + diff_scalar, + diff_sets, + observed_categories, + scope_change, +) +from .hashing import ( + EXCLUDE_DIRS, + EXCLUDE_SUFFIXES, + now_iso, + safe_sha_file, + sha_bytes, + sha_file, + sha_mapping, + tree_digest, + uuid7, +) +from .report import ( + UNMEASURED, + change_lines, + clean_verdict, + measured_or, + seal_section, + unmeasured_footnote, +) +from .seal import ( + INTEGRITY_BROKEN, + INTEGRITY_OK, + INTEGRITY_UNSEALED, + SEAL_FIELD, + attach_seal, + check_seal, + state_digest, +) +from .state import StatePaths, atomic_write, load_state, save_baseline, save_state + +__version__ = "0.1.0" + +__all__ = [ + "Change", + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "StatePaths", + "UNMEASURED", + "__version__", + "atomic_write", + "attach_seal", + "change_lines", + "check_seal", + "clean_verdict", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "load_state", + "measured_or", + "now_iso", + "observed_categories", + "safe_sha_file", + "save_baseline", + "save_state", + "scope_change", + "seal_section", + "sha_bytes", + "sha_file", + "sha_mapping", + "state_digest", + "tree_digest", + "unmeasured_footnote", + "uuid7", +] diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/compare.py b/packages/agentrust-capture-core/src/agentrust_capture_core/compare.py new file mode 100644 index 0000000..67ddebe --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/compare.py @@ -0,0 +1,119 @@ +"""Comparison primitives, plus the two gates that keep a comparison honest. + +Every engine's diff reduces to four shapes: a map of name to digest (components, +instruction files, policy files), a set of names (tools, MCP servers), a scalar +(model, permission mode), and a rollup hash. What differs between engines is which +categories exist and what they are called, so those stay with the engine and the +shapes live here. + +Two gates matter more than the shapes. + +**Observed gating.** A snapshot records which categories it actually measured. A +shell hook cannot enumerate a live tool roster, so comparing a hook snapshot +against a richer baseline would report the baseline's tools as removed. Only +categories that BOTH sides measured are compared. + +**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints +become incomparable. Without handling, an upgrade reports every affected component +as changed. That is an alarm the user knows is false, which is worse than no alarm +because it teaches them to dismiss the next one. So a scope mismatch is reported +once, as a re-approval prompt, and the affected categories are dropped from the +comparison rather than compared wrongly. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence + +__all__ = [ + "Change", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "observed_categories", + "scope_change", +] + +#: A single finding. ``change`` is one of added, removed, changed. +Change = dict + + +def _change(change: str, what: str, detail: str) -> Change: + return {"change": change, "what": what, "detail": detail} + + +def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]: + """Compare two name-to-digest maps. Names are reported, digests are not. + + A digest in a report tells the reader nothing they can act on; the name of the + component that moved does. + """ + out: list[Change] = [] + for name in sorted(set(current) - set(base)): + out.append(_change("added", what, name)) + for name in sorted(set(base) - set(current)): + out.append(_change("removed", what, name)) + for name in sorted(set(base) & set(current)): + if base[name] != current[name]: + out.append(_change("changed", what, name)) + return out + + +def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]: + """Compare two name sets, for categories with no per-item digest.""" + before, after = set(base), set(current) + out: list[Change] = [] + for name in sorted(after - before): + out.append(_change("added", what, name)) + for name in sorted(before - after): + out.append(_change("removed", what, name)) + return out + + +def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]: + """Compare a single value, reporting the transition rather than just the fact.""" + if before == after: + return [] + return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))] + + +def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]: + """Compare a rollup hash, where only the fact of change is available.""" + if before == after: + return [] + return [_change("changed", what, detail)] + + +def observed_categories( + base: Mapping[str, object], + current: Mapping[str, object], + default: Sequence[str] = (), +) -> set[str]: + """Categories both snapshots measured, and therefore may be compared.""" + return set(base.get("observed", list(default))) & set(current.get("observed", list(default))) + + +def scope_change( + base: Mapping[str, object], + current_scope: int, + *, + affected: Sequence[str], + reason: str, +) -> Change | None: + """Report a widened measurement scope, or None when the scopes agree. + + ``affected`` names the categories the caller must drop from its comparison, + and is included in the message so the reader knows what was not checked rather + than assuming everything was. + """ + base_scope = base.get("scope", 1) + if base_scope == current_scope: + return None + dropped = ", ".join(affected) if affected else "none" + return _change( + "changed", + "measurement scope", + "widened from %s to %s; %s Not compared this run: %s. Re-approve once to " + "compare on the new scope." % (base_scope, current_scope, reason, dropped), + ) diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py b/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py new file mode 100644 index 0000000..0b60284 --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/hashing.py @@ -0,0 +1,146 @@ +"""Content fingerprinting shared by every AgenTrust capture engine. + +Every engine answers the same question about a different agent: is this the +composition I approved, with nothing added and nothing subtracted? The parts that +differ between agents are *where to look* and *what to call things*. Hashing is +not one of them, so it lives here. + +Standard library only. The engines are invoked by shell hooks at session start and +must run before any dependency is installed. +""" + +from __future__ import annotations + +import hashlib +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +__all__ = [ + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "now_iso", + "sha_bytes", + "sha_file", + "sha_mapping", + "safe_sha_file", + "tree_digest", + "uuid7", +] + +#: Directory names skipped when fingerprinting a component tree. These hold state +#: a component writes as it runs, so hashing them would report drift on ordinary +#: use, and a tool that cries wolf on every run trains its user to ignore it. +#: +#: Controlled here rather than by a file inside the component on purpose. A +#: per-component ignore file would let the thing being measured decide what gets +#: measured, so a hostile component could ship a rule covering its own payload. +#: Adding a name here is a reviewed change to this package. +EXCLUDE_DIRS = frozenset({ + "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", +}) + +#: File suffixes skipped for the same reason: run artifacts, not behaviour. +EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) + + +def sha_bytes(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def sha_file(path: Path) -> str: + return sha_bytes(path.read_bytes()) + + +def safe_sha_file(path: Path) -> str | None: + """Digest a file, or None if it is missing or unreadable. + + Used on the discovery path, where a file vanishing between listing and + reading is ordinary rather than exceptional. + """ + try: + return sha_file(path) + except OSError: + return None + + +def sha_mapping(value: dict) -> str: + """Digest a mapping by canonical JSON, so key order cannot change the result.""" + import json + + return sha_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def tree_digest( + root: Path, + *, + exclude_dirs: frozenset[str] = EXCLUDE_DIRS, + exclude_suffixes: frozenset[str] = EXCLUDE_SUFFIXES, + pattern: str = "*", +) -> str | None: + """Digest every behavioural file under ``root``, or None if nothing was read. + + Covers the whole tree rather than a single manifest file. A component is not + just its manifest: these directories carry scripts, tools, templates and + reference material that decide what the component actually does. Digesting one + manifest let a payload be swapped into a sibling ``scripts/`` directory while + the report said nothing added, nothing subtracted. That was a live bypass in + two shipped engines before this function existed, which is the reason it is + shared rather than reimplemented. + + Relative paths are bound into the digest alongside contents, so a rename or a + move is drift. Traversal is sorted so the digest is stable across platforms. + Symlinks are skipped so a link out of the tree cannot pull unrelated content + into the fingerprint, and so a cycle cannot hang the hook. + """ + digest = hashlib.sha256() + try: + paths = sorted(root.rglob(pattern)) + except OSError: + return None + saw_file = False + for path in paths: + if path.is_symlink(): + continue + try: + if not path.is_file(): + continue + relative = path.relative_to(root) + except (OSError, ValueError): + continue + if exclude_dirs & set(relative.parts[:-1]): + continue + if path.suffix in exclude_suffixes: + continue + digest.update(relative.as_posix().encode("utf-8")) + try: + body = path.read_bytes() + except OSError: + # An unreadable file is itself worth recording: its path is already + # bound in, so the file appearing or vanishing still moves the digest + # instead of being silently skipped. + digest.update(b"\0\0") + saw_file = True + continue + digest.update(b"\0") + digest.update(body) + digest.update(b"\0") + saw_file = True + if not saw_file: + return None + return "sha256:" + digest.hexdigest() + + +def uuid7() -> str: + """RFC 9562 UUID v7 (time-ordered), required by agent-manifest.""" + ms = int(time.time() * 1000) + raw = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) + raw[6] = 0x70 | (raw[6] & 0x0F) + raw[8] = 0x80 | (raw[8] & 0x3F) + return str(uuid.UUID(bytes=bytes(raw))) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/report.py b/packages/agentrust-capture-core/src/agentrust_capture_core/report.py new file mode 100644 index 0000000..e2629cc --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/report.py @@ -0,0 +1,101 @@ +"""Report vocabulary shared across engines. + +The engines render different reports on purpose: they name different things and a +Codex user should not read Claude Code labels. What must not differ is the honesty +rules, because those drifted once already and each engine had to be fixed +separately. + +Two rules live here. + +**An unmeasured category is not an empty one.** A shell hook cannot see a live tool +roster or the model, so those arrive only from a caller-supplied live context. +Rendering them as ``0 tools`` or ``model: unknown`` states a measurement that was +never taken, and a reader who cannot tell "we did not check" from "we checked and +found nothing" will treat an absence as a pass. + +**A partial check is not a clean bill of health.** "Nothing added, nothing +subtracted" is only true of what was compared, so it is qualified whenever coverage +is incomplete. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .seal import INTEGRITY_BROKEN, INTEGRITY_OK, INTEGRITY_UNSEALED + +__all__ = [ + "UNMEASURED", + "clean_verdict", + "measured_or", + "seal_section", + "unmeasured_footnote", +] + +#: Shown wherever a category was not measured. +UNMEASURED = "not measured this run" + + +def measured_or(value: object, measured: bool, hint: str | None = None) -> str: + """Render ``value`` when it was measured, and say so plainly when it was not.""" + if measured: + return str(value) + return "%s (%s)" % (UNMEASURED, hint) if hint else UNMEASURED + + +def unmeasured_footnote(complete: bool) -> list[str]: + """The line that stops an absent measurement reading as a verified absence.""" + if complete: + return [] + return [ + ' Categories marked "%s" are NOT part of this comparison.' % UNMEASURED, + " They are unchecked, not verified as empty.", + "", + ] + + +def clean_verdict(complete: bool, phrasing: str = "nothing added, nothing subtracted") -> str: + """A no-changes verdict, qualified when coverage was partial.""" + scope = "" if complete else " in the categories checked" + return " >> Verified: %s%s." % (phrasing, scope) + + +def seal_section(integrity: str, digest: str | None = None) -> list[str]: + """The baseline-integrity block, stated before any drift result. + + Ordering is the point. If the baseline was altered, a reassuring "nothing + changed" underneath it is worse than no result at all, so a caller renders this + above its drift section. + """ + lines = [" IS THE BASELINE ITSELF INTACT?", " " + "-" * 62] + if integrity == INTEGRITY_BROKEN: + lines += [ + " !! the baseline 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_UNSEALED: + lines.append(" ~ baseline carries no digest (written by an older version). " + "Re-approve to seal it.") + elif integrity == INTEGRITY_OK: + lines.append(" >> baseline digest verified.") + if digest: + lines.append(" digest: %s" % digest) + lines += [ + " 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.", + "", + ] + return lines + + +def change_lines(changes: Sequence[dict]) -> list[str]: + """Render findings with a stable symbol per kind.""" + symbol = {"added": "+", "removed": "-", "changed": "~"} + return [ + " %s %s %s: %s" % (symbol.get(c["change"], "?"), c["change"].upper(), + c["what"], c["detail"]) + for c in changes + ] diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/seal.py b/packages/agentrust-capture-core/src/agentrust_capture_core/seal.py new file mode 100644 index 0000000..7b0f641 --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/seal.py @@ -0,0 +1,80 @@ +"""Baseline sealing: is the thing we compare against still what we wrote? + +The baseline is what every drift comparison is made against. An unsealed baseline +means anyone able to write it can add a component to the *approved* set, after +which the check reports "nothing added, nothing subtracted" indefinitely and +quietly. The evidence would share a fate with the adversary, which is the failure +this project exists to argue against. + +A note on what this is, because the obvious design is worse than it looks. The +first version used an HMAC with a secret stored beside the baseline. A scanner +flagged the stored secret, and the flag was worth more than a suppression: the +only adversary an HMAC defeats here is one who can WRITE the state directory +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. The secret bought almost no coverage while adding a credential to +leak and a claim inviting a reader to assume more protection than exists. + +So: a bare digest. Same real coverage, 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 +this module keeps the cheap local check and the engines point at the real one. +""" + +from __future__ import annotations + +from .hashing import now_iso, sha_mapping + +__all__ = [ + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "attach_seal", + "check_seal", + "state_digest", +] + +#: Excluded from the digest it carries, since including it would be circular. +SEAL_FIELD = "integrity" + +INTEGRITY_OK = "ok" +INTEGRITY_UNSEALED = "unsealed" # no digest: written before sealing existed +INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside the tool + + +def state_digest(snapshot: dict) -> str: + """Digest of a snapshot's content, ignoring any seal it carries. + + Deterministic, so the value ``approve`` prints can be compared by eye against + the value ``verify`` prints later. + """ + return sha_mapping({k: v for k, v in snapshot.items() if k != SEAL_FIELD}) + + +def attach_seal(snapshot: dict) -> dict: + """Return a copy of ``snapshot`` sealed with a digest over its content.""" + return {**snapshot, SEAL_FIELD: { + "alg": "SHA-256", + "digest": state_digest(snapshot), + "sealed_at": now_iso(), + }} + + +def check_seal(snapshot: dict | None) -> str: + """Recompute the seal and compare. Never raises. + + Catches accidental corruption, truncation, and a hand-edit that does not + recompute the digest. Does not catch an attacker who owns the state directory, + who can recompute it as easily as this function can. + """ + if snapshot is None: + return INTEGRITY_UNSEALED + seal = snapshot.get(SEAL_FIELD) + if not isinstance(seal, dict) or not isinstance(seal.get("digest"), str): + return INTEGRITY_UNSEALED + return INTEGRITY_OK if seal["digest"] == state_digest(snapshot) else INTEGRITY_BROKEN diff --git a/packages/agentrust-capture-core/src/agentrust_capture_core/state.py b/packages/agentrust-capture-core/src/agentrust_capture_core/state.py new file mode 100644 index 0000000..c5e87c9 --- /dev/null +++ b/packages/agentrust-capture-core/src/agentrust_capture_core/state.py @@ -0,0 +1,76 @@ +"""Reading and writing engine state, and the paths it lives at. + +Baseline scoping differs by design and is not unified here. Claude Code keeps one +baseline per machine; Codex keeps one per workspace, because a workspace can carry +its own instructions and skills and a single baseline would blend them. Both are +correct for their agent, so an engine supplies its own paths and this module only +handles the reading and writing. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from .seal import attach_seal + +__all__ = ["StatePaths", "atomic_write", "load_state", "save_state", "save_baseline"] + + +@dataclass(frozen=True) +class StatePaths: + """Where one engine keeps its approved baseline and its latest snapshot.""" + + baseline: Path + latest: Path + + +def atomic_write(path: Path, content: str) -> None: + """Write via a temporary file and replace, so a crash cannot truncate state. + + A half-written baseline is worse than a missing one: the engine would treat it + as corrupt on every future session, and a user who sees a broken check often + enough stops reading it. + """ + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save_state(path: Path, value: dict) -> None: + atomic_write(path, json.dumps(value, indent=2)) + + +def save_baseline(path: Path, snapshot: dict) -> dict: + """Seal a snapshot and write it as the approved baseline. Returns what was written.""" + sealed = attach_seal(snapshot) + save_state(path, sealed) + return sealed + + +def load_state(path: Path) -> dict | None: + """Load a state file, or None if it is absent, unreadable, or corrupt. + + A truncated baseline (crash mid-write, disk full, racing sessions) must not + brick the hook on every future session. Treating corrupt state as absent lets + the next run re-establish it instead of failing forever. + """ + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None diff --git a/packages/agentrust-capture-core/tests/test_core.py b/packages/agentrust-capture-core/tests/test_core.py new file mode 100644 index 0000000..25e5205 --- /dev/null +++ b/packages/agentrust-capture-core/tests/test_core.py @@ -0,0 +1,280 @@ +"""Tests for the shared capture core. + +These encode the behaviours that were bugs in shipped engines before the core +existed, so a regression here is a regression in every engine at once. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +import agentrust_capture_core as core # noqa: E402 + + +# --------------------------------------------------------------------------- +# tree_digest: the bypass this package exists to prevent +# --------------------------------------------------------------------------- +def _component(tmp_path: Path) -> Path: + root = tmp_path / "deploy" + (root / "scripts").mkdir(parents=True) + (root / "SKILL.md").write_text("---\nname: deploy\n---\nRun scripts/run.sh\n", encoding="utf-8") + (root / "scripts" / "run.sh").write_text("echo ok\n", encoding="utf-8") + return root + + +class TestTreeDigest: + def test_payload_swapped_into_a_script_is_detected(self, tmp_path): + root = _component(tmp_path) + before = core.tree_digest(root) + (root / "scripts" / "run.sh").write_text("curl http://attacker.example\n", encoding="utf-8") + assert core.tree_digest(root) != before + + def test_manifest_change_is_detected(self, tmp_path): + root = _component(tmp_path) + before = core.tree_digest(root) + (root / "SKILL.md").write_text("---\nname: deploy\n---\nOther\n", encoding="utf-8") + assert core.tree_digest(root) != before + + def test_new_file_is_detected(self, tmp_path): + root = _component(tmp_path) + before = core.tree_digest(root) + (root / "scripts" / "extra.sh").write_text("whoami\n", encoding="utf-8") + assert core.tree_digest(root) != before + + def test_rename_is_detected(self, tmp_path): + """Paths are bound in alongside contents, so a move is drift.""" + root = _component(tmp_path) + before = core.tree_digest(root) + (root / "scripts" / "run.sh").rename(root / "scripts" / "renamed.sh") + assert core.tree_digest(root) != before + + def test_identical_trees_agree(self, tmp_path): + a, b = _component(tmp_path / "a"), _component(tmp_path / "b") + assert core.tree_digest(a) == core.tree_digest(b) + + @pytest.mark.parametrize("junk", ["run.log", "cached.pyc", "scratch.tmp", "x.pyo"]) + def test_run_artifacts_are_excluded(self, tmp_path, junk): + root = _component(tmp_path) + before = core.tree_digest(root) + (root / junk).write_text("noise", encoding="utf-8") + assert core.tree_digest(root) == before + + @pytest.mark.parametrize("directory", ["state", ".cache", "__pycache__", "node_modules"]) + def test_state_directories_are_excluded(self, tmp_path, directory): + root = _component(tmp_path) + (root / directory).mkdir() + before = core.tree_digest(root) + (root / directory / "progress.json").write_text('{"runs": 2}', encoding="utf-8") + assert core.tree_digest(root) == before + + def test_nested_state_directory_is_excluded(self, tmp_path): + root = _component(tmp_path) + nested = root / "scripts" / "state" + nested.mkdir() + before = core.tree_digest(root) + (nested / "cursor").write_text("42", encoding="utf-8") + assert core.tree_digest(root) == before + + def test_empty_or_missing_tree_is_none_not_a_digest_of_nothing(self, tmp_path): + """None distinguishes "no component here" from "a component with no files", + so a caller does not record a fingerprint for something absent.""" + assert core.tree_digest(tmp_path / "missing") is None + (tmp_path / "empty").mkdir() + assert core.tree_digest(tmp_path / "empty") is None + + def test_exclusions_are_engine_controlled(self): + """A per-component ignore file would let the measured thing decide what + gets measured. The denylist lives in this package.""" + assert "state" in core.EXCLUDE_DIRS + assert ".log" in core.EXCLUDE_SUFFIXES + + +# --------------------------------------------------------------------------- +# seal +# --------------------------------------------------------------------------- +class TestSeal: + def test_a_sealed_snapshot_verifies(self): + assert core.check_seal(core.attach_seal({"skills": {"a": "1"}})) == core.INTEGRITY_OK + + def test_an_edited_snapshot_is_broken(self): + sealed = core.attach_seal({"skills": {"a": "1"}}) + sealed["skills"]["exfil"] = "2" + assert core.check_seal(sealed) == core.INTEGRITY_BROKEN + + def test_an_unsealed_snapshot_is_not_broken(self): + """A snapshot predating sealing is benign. Crying tamper over it would + teach the user to dismiss the real alarm.""" + assert core.check_seal({"skills": {}}) == core.INTEGRITY_UNSEALED + assert core.check_seal(None) == core.INTEGRITY_UNSEALED + + def test_digest_excludes_the_seal_it_carries(self): + snap = {"skills": {"a": "1"}} + assert core.state_digest(core.attach_seal(snap)) == core.state_digest(snap) + + def test_resealing_a_rewrite_passes_locally_but_changes_the_digest(self): + """The limit, as executable fact. Anyone who owns the state directory can + reseal what they rewrote, which is why the digest is meant to be recorded + off-box.""" + approved = core.attach_seal({"skills": {"a": "1"}}) + rewritten = core.attach_seal({"skills": {"a": "1", "exfil": "2"}}) + assert core.check_seal(rewritten) == core.INTEGRITY_OK + assert core.state_digest(rewritten) != approved["integrity"]["digest"] + + def test_key_order_does_not_change_the_digest(self): + assert core.state_digest({"a": 1, "b": 2}) == core.state_digest({"b": 2, "a": 1}) + + +# --------------------------------------------------------------------------- +# compare +# --------------------------------------------------------------------------- +class TestCompare: + def test_map_diff_reports_names_not_digests(self): + out = core.diff_maps({"keep": "1", "gone": "1"}, {"keep": "2", "new": "1"}, "skill") + assert {"change": "changed", "what": "skill", "detail": "keep"} in out + assert {"change": "removed", "what": "skill", "detail": "gone"} in out + assert {"change": "added", "what": "skill", "detail": "new"} in out + assert not any("sha256" in c["detail"] for c in out) + + def test_set_diff(self): + out = core.diff_sets(["a"], ["a", "b"], "tool") + assert out == [{"change": "added", "what": "tool", "detail": "b"}] + + def test_scalar_diff_shows_the_transition(self): + out = core.diff_scalar("default", "bypassPermissions", "permission mode") + assert out[0]["detail"] == "default -> bypassPermissions" + + def test_scalar_diff_is_silent_when_equal(self): + assert core.diff_scalar("a", "a", "model") == [] + + def test_observed_gating_only_compares_shared_categories(self): + """A hook snapshot must not report a richer baseline's tools as removed.""" + base = {"observed": ["skills", "tools"]} + hook = {"observed": ["skills"]} + assert core.observed_categories(base, hook) == {"skills"} + + def test_scope_change_is_none_when_scopes_agree(self): + assert core.scope_change({"scope": 2}, 2, affected=["skills"], reason="x") is None + + def test_scope_change_names_what_was_not_compared(self): + change = core.scope_change({}, 2, affected=["skills"], reason="digests widened.") + assert change["what"] == "measurement scope" + assert "widened from 1 to 2" in change["detail"] + assert "Not compared this run: skills" in change["detail"] + assert "re-approve" in change["detail"].lower() + + +# --------------------------------------------------------------------------- +# report honesty rules +# --------------------------------------------------------------------------- +class TestReportHonesty: + def test_unmeasured_is_labelled_not_zeroed(self): + assert core.measured_or(0, measured=False) == core.UNMEASURED + assert core.measured_or(12, measured=True) == "12" + + def test_hint_travels_with_the_label(self): + assert "run /manifest verify" in core.measured_or(0, False, "run /manifest verify") + + def test_footnote_only_appears_when_coverage_is_partial(self): + assert core.unmeasured_footnote(complete=True) == [] + assert "unchecked, not verified as empty" in "\n".join( + core.unmeasured_footnote(complete=False) + ) + + def test_clean_verdict_is_qualified_when_coverage_is_partial(self): + assert "in the categories checked" in core.clean_verdict(complete=False) + assert "in the categories checked" not in core.clean_verdict(complete=True) + + def test_seal_section_states_the_limit_with_the_claim(self): + out = "\n".join(core.seal_section(core.INTEGRITY_OK, "sha256:" + "a" * 64)) + assert "baseline digest verified" in out + assert "not an attacker who owns this directory" in out + assert "recorded off-box" in out + + def test_broken_seal_is_unmistakable(self): + out = "\n".join(core.seal_section(core.INTEGRITY_BROKEN)) + assert "FAILED its integrity check" in out + assert "unreliable" in out + + def test_change_lines_use_a_stable_symbol_per_kind(self): + lines = core.change_lines([ + {"change": "added", "what": "skill", "detail": "x"}, + {"change": "removed", "what": "tool", "detail": "y"}, + {"change": "changed", "what": "model", "detail": "a -> b"}, + ]) + assert lines[0].strip().startswith("+") + assert lines[1].strip().startswith("-") + assert lines[2].strip().startswith("~") + + +# --------------------------------------------------------------------------- +# state +# --------------------------------------------------------------------------- +class TestState: + def test_round_trip(self, tmp_path): + p = tmp_path / "s" / "baseline.json" + core.save_state(p, {"a": 1}) + assert core.load_state(p) == {"a": 1} + + def test_corrupt_state_reads_as_absent(self, tmp_path): + """A truncated baseline must not brick the hook on every future session.""" + p = tmp_path / "baseline.json" + p.write_text('{"a": ', encoding="utf-8") + assert core.load_state(p) is None + + def test_non_object_state_reads_as_absent(self, tmp_path): + p = tmp_path / "baseline.json" + p.write_text("[1, 2]", encoding="utf-8") + assert core.load_state(p) is None + + def test_missing_state_reads_as_absent(self, tmp_path): + assert core.load_state(tmp_path / "nope.json") is None + + def test_save_baseline_seals_what_it_writes(self, tmp_path): + p = tmp_path / "baseline.json" + written = core.save_baseline(p, {"skills": {}}) + assert core.check_seal(core.load_state(p)) == core.INTEGRITY_OK + assert written["integrity"]["digest"] == core.state_digest({"skills": {}}) + + def test_atomic_write_leaves_no_temp_files_behind(self, tmp_path): + p = tmp_path / "baseline.json" + core.save_state(p, {"a": 1}) + assert [f.name for f in tmp_path.iterdir()] == ["baseline.json"] + + def test_atomic_write_replaces_rather_than_truncating(self, tmp_path): + p = tmp_path / "baseline.json" + core.save_state(p, {"generation": 1}) + core.save_state(p, {"generation": 2}) + assert json.loads(p.read_text(encoding="utf-8"))["generation"] == 2 + + def test_state_paths_is_hashable_and_frozen(self): + paths = core.StatePaths(baseline=Path("b"), latest=Path("l")) + assert {paths} + with pytest.raises(Exception): + paths.baseline = Path("other") + + +def test_core_has_no_third_party_dependencies(): + """The engines run from shell hooks before anything is installed, so the core + must import with the standard library alone.""" + src = Path(__file__).resolve().parent.parent / "src" / "agentrust_capture_core" + stdlib_ok = { + "hashlib", "json", "os", "tempfile", "time", "uuid", "datetime", "pathlib", + "dataclasses", "collections", "collections.abc", "__future__", + } + for module in sorted(src.glob("*.py")): + for line in module.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("from .") or line.startswith("import ."): + continue + if line.startswith("import "): + name = line[len("import "):].split()[0].split(".")[0] + assert name in stdlib_ok, "%s imports %s" % (module.name, name) + elif line.startswith("from ") and " import " in line: + name = line[len("from "):].split()[0] + assert name in stdlib_ok, "%s imports from %s" % (module.name, name) From bc63be8e78d34e2d1881a78295abea01483d7587 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 31 Jul 2026 13:54:08 -0700 Subject: [PATCH 2/2] feat(core): vendor the core into each engine and migrate claude-code onto it You chose PyPI as the sharing mechanism. That conflicts with the stated promise that the SessionStart hook needs nothing installed, so both mechanisms ship: the package is the source of truth, and each engine carries a pinned vendored copy it falls back to when the package is absent. A bare plugin install therefore still gets drift detection, and a user who pip installs gets the shared package. Vendored copies are free to rot, which is exactly the failure this package exists to end, so they are generated by scripts/sync_vendored_core.py and never hand-edited. CI fails if a copy and the package disagree, and the check was verified to catch a one-line edit. claude-code is migrated. Hashing, the skill tree digest, the exclusion denylist, sealing, and state read/write now delegate to the core. Public names are retained as aliases so the 59 existing tests run unchanged, which is the point: behaviour is proven identical by not touching the contract. 973 lines to 854. Two behaviours deliberately did not move: _sha_tree keeps its own semantics, returning the digest of the empty string for a missing tree where the core returns None. The core's distinction between "no component here" and "a component with no files" is right for components, but adopting it for the instruction rollup would move system_prompt for every existing baseline. State writes are now atomic via the core, replacing an in-place write. That is a strict improvement: a crash mid-write previously left a truncated baseline that read as corrupt on every future session. Codex and scheduled-agents get the vendored copy and are covered by the bare-install job, but still use their own implementations; migrating them is the remaining work and is mechanical now that the core exists and one engine proves it fits. New CI workflow: core tests on 3.9 through 3.13, the vendored-in-sync check, and a bare-install job that asserts the core is NOT installed and then imports all three engines. That last one matters because the fallback is the path most users are on, and nothing else would notice it rotting behind the installed path. Suites: claude-code 59, core 45, scheduled-agents 21, codex 25. The one codex failure is the pre-existing TRACE conformance check that fix/codex-trace-pins addresses. Not published to PyPI. That needs credentials and is an irreversible claim on a package name, so it stays a human step. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Imran Siddique --- .github/workflows/capture-core-tests.yml | 86 +++++++ .../agentrust_capture_core/VENDORED.md | 7 + .../agentrust_capture_core/__init__.py | 97 ++++++++ .../_vendor/agentrust_capture_core/compare.py | 119 ++++++++++ .../_vendor/agentrust_capture_core/hashing.py | 146 ++++++++++++ .../_vendor/agentrust_capture_core/report.py | 101 ++++++++ .../_vendor/agentrust_capture_core/seal.py | 80 +++++++ .../_vendor/agentrust_capture_core/state.py | 76 ++++++ claude-code/engine/capture.py | 223 ++++-------------- .../agentrust_capture_core/VENDORED.md | 7 + .../agentrust_capture_core/__init__.py | 97 ++++++++ .../_vendor/agentrust_capture_core/compare.py | 119 ++++++++++ .../_vendor/agentrust_capture_core/hashing.py | 146 ++++++++++++ .../_vendor/agentrust_capture_core/report.py | 101 ++++++++ .../_vendor/agentrust_capture_core/seal.py | 80 +++++++ .../_vendor/agentrust_capture_core/state.py | 76 ++++++ .../agentrust_capture_core/VENDORED.md | 7 + .../agentrust_capture_core/__init__.py | 97 ++++++++ .../_vendor/agentrust_capture_core/compare.py | 119 ++++++++++ .../_vendor/agentrust_capture_core/hashing.py | 146 ++++++++++++ .../_vendor/agentrust_capture_core/report.py | 101 ++++++++ .../_vendor/agentrust_capture_core/seal.py | 80 +++++++ .../_vendor/agentrust_capture_core/state.py | 76 ++++++ scripts/sync_vendored_core.py | 106 +++++++++ 24 files changed, 2122 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/capture-core-tests.yml create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/__init__.py create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/compare.py create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/hashing.py create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/report.py create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/seal.py create mode 100644 claude-code/engine/_vendor/agentrust_capture_core/state.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/__init__.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/compare.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/hashing.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/report.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/seal.py create mode 100644 plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/__init__.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/compare.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/hashing.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/report.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/seal.py create mode 100644 scheduled-agents/engine/_vendor/agentrust_capture_core/state.py create mode 100644 scripts/sync_vendored_core.py diff --git a/.github/workflows/capture-core-tests.yml b/.github/workflows/capture-core-tests.yml new file mode 100644 index 0000000..3f72d25 --- /dev/null +++ b/.github/workflows/capture-core-tests.yml @@ -0,0 +1,86 @@ +name: capture-core tests + +on: + pull_request: + paths: + - "packages/agentrust-capture-core/**" + - "scripts/sync_vendored_core.py" + - "**/_vendor/agentrust_capture_core/**" + - ".github/workflows/capture-core-tests.yml" + push: + branches: [main] + paths: + - "packages/agentrust-capture-core/**" + - "scripts/sync_vendored_core.py" + - "**/_vendor/agentrust_capture_core/**" + - ".github/workflows/capture-core-tests.yml" + +permissions: + contents: read + +jobs: + # The engines run from shell hooks at session start, before anything is + # installed, so the core must work on the standard library alone. 3.9 is the + # floor because the scheduled-agents matrix tests it. + core: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Run core tests + working-directory: packages/agentrust-capture-core + run: | + pip install pytest + python -m pytest tests -q + + # Each engine keeps a pinned copy of the core so a bare plugin install still + # gets drift detection. Copies are free to rot, which is the failure this whole + # package exists to end, so they are generated and checked rather than trusted. + vendored-in-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Vendored copies must match the package + run: python scripts/sync_vendored_core.py --check + + # The fallback is the path most users are on, since it is what runs before any + # pip install. Exercising it explicitly stops it rotting behind the installed + # path, which nothing else would catch. + bare-install-fallback: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Engines must import with the core NOT installed + run: | + python - <<'PY' + import importlib.util, sys + assert importlib.util.find_spec("agentrust_capture_core") is None, ( + "the core is installed; this job must test the vendored fallback" + ) + for path in ( + "claude-code/engine/capture.py", + "plugins/agentrust-codex/engine/capture.py", + "scheduled-agents/engine/capture.py", + ): + spec = importlib.util.spec_from_file_location("cap_" + path.replace("/", "_"), path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + print("ok:", path) + PY diff --git a/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md b/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md new file mode 100644 index 0000000..2c8b669 --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md @@ -0,0 +1,7 @@ +# Generated by scripts/sync_vendored_core.py. Do not edit. +# +# Pinned copy of agentrust-capture-core, used when the package is not installed. +# The engines run from shell hooks before anything is installed, so this fallback +# is what makes drift detection work on a bare plugin install. Edit +# packages/agentrust-capture-core and re-run the sync script; CI fails if this +# copy and the package disagree. diff --git a/claude-code/engine/_vendor/agentrust_capture_core/__init__.py b/claude-code/engine/_vendor/agentrust_capture_core/__init__.py new file mode 100644 index 0000000..ef9c4e1 --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/__init__.py @@ -0,0 +1,97 @@ +"""Shared core for AgenTrust agent-integrity capture engines. + +Each engine answers one question about a different coding agent: is this the +composition I approved, with nothing added and nothing subtracted? What differs +between agents is where to look and what to call things. What must not differ is +how content is fingerprinted, how snapshots are compared, how a baseline is sealed, +and the rules that keep a report honest. + +Those lived in three copies before this package existed, and the cost was not +theoretical: the same skill-fingerprinting bypass had to be found and fixed twice, +independently, and a reporting defect once. This package is the single source of +truth for the parts that are genuinely identical. + +Standard library only, because the engines run from shell hooks at session start +and must work before anything is installed. +""" + +from __future__ import annotations + +from .compare import ( + Change, + diff_hash, + diff_maps, + diff_scalar, + diff_sets, + observed_categories, + scope_change, +) +from .hashing import ( + EXCLUDE_DIRS, + EXCLUDE_SUFFIXES, + now_iso, + safe_sha_file, + sha_bytes, + sha_file, + sha_mapping, + tree_digest, + uuid7, +) +from .report import ( + UNMEASURED, + change_lines, + clean_verdict, + measured_or, + seal_section, + unmeasured_footnote, +) +from .seal import ( + INTEGRITY_BROKEN, + INTEGRITY_OK, + INTEGRITY_UNSEALED, + SEAL_FIELD, + attach_seal, + check_seal, + state_digest, +) +from .state import StatePaths, atomic_write, load_state, save_baseline, save_state + +__version__ = "0.1.0" + +__all__ = [ + "Change", + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "StatePaths", + "UNMEASURED", + "__version__", + "atomic_write", + "attach_seal", + "change_lines", + "check_seal", + "clean_verdict", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "load_state", + "measured_or", + "now_iso", + "observed_categories", + "safe_sha_file", + "save_baseline", + "save_state", + "scope_change", + "seal_section", + "sha_bytes", + "sha_file", + "sha_mapping", + "state_digest", + "tree_digest", + "unmeasured_footnote", + "uuid7", +] diff --git a/claude-code/engine/_vendor/agentrust_capture_core/compare.py b/claude-code/engine/_vendor/agentrust_capture_core/compare.py new file mode 100644 index 0000000..67ddebe --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/compare.py @@ -0,0 +1,119 @@ +"""Comparison primitives, plus the two gates that keep a comparison honest. + +Every engine's diff reduces to four shapes: a map of name to digest (components, +instruction files, policy files), a set of names (tools, MCP servers), a scalar +(model, permission mode), and a rollup hash. What differs between engines is which +categories exist and what they are called, so those stay with the engine and the +shapes live here. + +Two gates matter more than the shapes. + +**Observed gating.** A snapshot records which categories it actually measured. A +shell hook cannot enumerate a live tool roster, so comparing a hook snapshot +against a richer baseline would report the baseline's tools as removed. Only +categories that BOTH sides measured are compared. + +**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints +become incomparable. Without handling, an upgrade reports every affected component +as changed. That is an alarm the user knows is false, which is worse than no alarm +because it teaches them to dismiss the next one. So a scope mismatch is reported +once, as a re-approval prompt, and the affected categories are dropped from the +comparison rather than compared wrongly. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence + +__all__ = [ + "Change", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "observed_categories", + "scope_change", +] + +#: A single finding. ``change`` is one of added, removed, changed. +Change = dict + + +def _change(change: str, what: str, detail: str) -> Change: + return {"change": change, "what": what, "detail": detail} + + +def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]: + """Compare two name-to-digest maps. Names are reported, digests are not. + + A digest in a report tells the reader nothing they can act on; the name of the + component that moved does. + """ + out: list[Change] = [] + for name in sorted(set(current) - set(base)): + out.append(_change("added", what, name)) + for name in sorted(set(base) - set(current)): + out.append(_change("removed", what, name)) + for name in sorted(set(base) & set(current)): + if base[name] != current[name]: + out.append(_change("changed", what, name)) + return out + + +def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]: + """Compare two name sets, for categories with no per-item digest.""" + before, after = set(base), set(current) + out: list[Change] = [] + for name in sorted(after - before): + out.append(_change("added", what, name)) + for name in sorted(before - after): + out.append(_change("removed", what, name)) + return out + + +def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]: + """Compare a single value, reporting the transition rather than just the fact.""" + if before == after: + return [] + return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))] + + +def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]: + """Compare a rollup hash, where only the fact of change is available.""" + if before == after: + return [] + return [_change("changed", what, detail)] + + +def observed_categories( + base: Mapping[str, object], + current: Mapping[str, object], + default: Sequence[str] = (), +) -> set[str]: + """Categories both snapshots measured, and therefore may be compared.""" + return set(base.get("observed", list(default))) & set(current.get("observed", list(default))) + + +def scope_change( + base: Mapping[str, object], + current_scope: int, + *, + affected: Sequence[str], + reason: str, +) -> Change | None: + """Report a widened measurement scope, or None when the scopes agree. + + ``affected`` names the categories the caller must drop from its comparison, + and is included in the message so the reader knows what was not checked rather + than assuming everything was. + """ + base_scope = base.get("scope", 1) + if base_scope == current_scope: + return None + dropped = ", ".join(affected) if affected else "none" + return _change( + "changed", + "measurement scope", + "widened from %s to %s; %s Not compared this run: %s. Re-approve once to " + "compare on the new scope." % (base_scope, current_scope, reason, dropped), + ) diff --git a/claude-code/engine/_vendor/agentrust_capture_core/hashing.py b/claude-code/engine/_vendor/agentrust_capture_core/hashing.py new file mode 100644 index 0000000..0b60284 --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/hashing.py @@ -0,0 +1,146 @@ +"""Content fingerprinting shared by every AgenTrust capture engine. + +Every engine answers the same question about a different agent: is this the +composition I approved, with nothing added and nothing subtracted? The parts that +differ between agents are *where to look* and *what to call things*. Hashing is +not one of them, so it lives here. + +Standard library only. The engines are invoked by shell hooks at session start and +must run before any dependency is installed. +""" + +from __future__ import annotations + +import hashlib +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +__all__ = [ + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "now_iso", + "sha_bytes", + "sha_file", + "sha_mapping", + "safe_sha_file", + "tree_digest", + "uuid7", +] + +#: Directory names skipped when fingerprinting a component tree. These hold state +#: a component writes as it runs, so hashing them would report drift on ordinary +#: use, and a tool that cries wolf on every run trains its user to ignore it. +#: +#: Controlled here rather than by a file inside the component on purpose. A +#: per-component ignore file would let the thing being measured decide what gets +#: measured, so a hostile component could ship a rule covering its own payload. +#: Adding a name here is a reviewed change to this package. +EXCLUDE_DIRS = frozenset({ + "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", +}) + +#: File suffixes skipped for the same reason: run artifacts, not behaviour. +EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) + + +def sha_bytes(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def sha_file(path: Path) -> str: + return sha_bytes(path.read_bytes()) + + +def safe_sha_file(path: Path) -> str | None: + """Digest a file, or None if it is missing or unreadable. + + Used on the discovery path, where a file vanishing between listing and + reading is ordinary rather than exceptional. + """ + try: + return sha_file(path) + except OSError: + return None + + +def sha_mapping(value: dict) -> str: + """Digest a mapping by canonical JSON, so key order cannot change the result.""" + import json + + return sha_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def tree_digest( + root: Path, + *, + exclude_dirs: frozenset[str] = EXCLUDE_DIRS, + exclude_suffixes: frozenset[str] = EXCLUDE_SUFFIXES, + pattern: str = "*", +) -> str | None: + """Digest every behavioural file under ``root``, or None if nothing was read. + + Covers the whole tree rather than a single manifest file. A component is not + just its manifest: these directories carry scripts, tools, templates and + reference material that decide what the component actually does. Digesting one + manifest let a payload be swapped into a sibling ``scripts/`` directory while + the report said nothing added, nothing subtracted. That was a live bypass in + two shipped engines before this function existed, which is the reason it is + shared rather than reimplemented. + + Relative paths are bound into the digest alongside contents, so a rename or a + move is drift. Traversal is sorted so the digest is stable across platforms. + Symlinks are skipped so a link out of the tree cannot pull unrelated content + into the fingerprint, and so a cycle cannot hang the hook. + """ + digest = hashlib.sha256() + try: + paths = sorted(root.rglob(pattern)) + except OSError: + return None + saw_file = False + for path in paths: + if path.is_symlink(): + continue + try: + if not path.is_file(): + continue + relative = path.relative_to(root) + except (OSError, ValueError): + continue + if exclude_dirs & set(relative.parts[:-1]): + continue + if path.suffix in exclude_suffixes: + continue + digest.update(relative.as_posix().encode("utf-8")) + try: + body = path.read_bytes() + except OSError: + # An unreadable file is itself worth recording: its path is already + # bound in, so the file appearing or vanishing still moves the digest + # instead of being silently skipped. + digest.update(b"\0\0") + saw_file = True + continue + digest.update(b"\0") + digest.update(body) + digest.update(b"\0") + saw_file = True + if not saw_file: + return None + return "sha256:" + digest.hexdigest() + + +def uuid7() -> str: + """RFC 9562 UUID v7 (time-ordered), required by agent-manifest.""" + ms = int(time.time() * 1000) + raw = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) + raw[6] = 0x70 | (raw[6] & 0x0F) + raw[8] = 0x80 | (raw[8] & 0x3F) + return str(uuid.UUID(bytes=bytes(raw))) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/claude-code/engine/_vendor/agentrust_capture_core/report.py b/claude-code/engine/_vendor/agentrust_capture_core/report.py new file mode 100644 index 0000000..e2629cc --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/report.py @@ -0,0 +1,101 @@ +"""Report vocabulary shared across engines. + +The engines render different reports on purpose: they name different things and a +Codex user should not read Claude Code labels. What must not differ is the honesty +rules, because those drifted once already and each engine had to be fixed +separately. + +Two rules live here. + +**An unmeasured category is not an empty one.** A shell hook cannot see a live tool +roster or the model, so those arrive only from a caller-supplied live context. +Rendering them as ``0 tools`` or ``model: unknown`` states a measurement that was +never taken, and a reader who cannot tell "we did not check" from "we checked and +found nothing" will treat an absence as a pass. + +**A partial check is not a clean bill of health.** "Nothing added, nothing +subtracted" is only true of what was compared, so it is qualified whenever coverage +is incomplete. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .seal import INTEGRITY_BROKEN, INTEGRITY_OK, INTEGRITY_UNSEALED + +__all__ = [ + "UNMEASURED", + "clean_verdict", + "measured_or", + "seal_section", + "unmeasured_footnote", +] + +#: Shown wherever a category was not measured. +UNMEASURED = "not measured this run" + + +def measured_or(value: object, measured: bool, hint: str | None = None) -> str: + """Render ``value`` when it was measured, and say so plainly when it was not.""" + if measured: + return str(value) + return "%s (%s)" % (UNMEASURED, hint) if hint else UNMEASURED + + +def unmeasured_footnote(complete: bool) -> list[str]: + """The line that stops an absent measurement reading as a verified absence.""" + if complete: + return [] + return [ + ' Categories marked "%s" are NOT part of this comparison.' % UNMEASURED, + " They are unchecked, not verified as empty.", + "", + ] + + +def clean_verdict(complete: bool, phrasing: str = "nothing added, nothing subtracted") -> str: + """A no-changes verdict, qualified when coverage was partial.""" + scope = "" if complete else " in the categories checked" + return " >> Verified: %s%s." % (phrasing, scope) + + +def seal_section(integrity: str, digest: str | None = None) -> list[str]: + """The baseline-integrity block, stated before any drift result. + + Ordering is the point. If the baseline was altered, a reassuring "nothing + changed" underneath it is worse than no result at all, so a caller renders this + above its drift section. + """ + lines = [" IS THE BASELINE ITSELF INTACT?", " " + "-" * 62] + if integrity == INTEGRITY_BROKEN: + lines += [ + " !! the baseline 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_UNSEALED: + lines.append(" ~ baseline carries no digest (written by an older version). " + "Re-approve to seal it.") + elif integrity == INTEGRITY_OK: + lines.append(" >> baseline digest verified.") + if digest: + lines.append(" digest: %s" % digest) + lines += [ + " 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.", + "", + ] + return lines + + +def change_lines(changes: Sequence[dict]) -> list[str]: + """Render findings with a stable symbol per kind.""" + symbol = {"added": "+", "removed": "-", "changed": "~"} + return [ + " %s %s %s: %s" % (symbol.get(c["change"], "?"), c["change"].upper(), + c["what"], c["detail"]) + for c in changes + ] diff --git a/claude-code/engine/_vendor/agentrust_capture_core/seal.py b/claude-code/engine/_vendor/agentrust_capture_core/seal.py new file mode 100644 index 0000000..7b0f641 --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/seal.py @@ -0,0 +1,80 @@ +"""Baseline sealing: is the thing we compare against still what we wrote? + +The baseline is what every drift comparison is made against. An unsealed baseline +means anyone able to write it can add a component to the *approved* set, after +which the check reports "nothing added, nothing subtracted" indefinitely and +quietly. The evidence would share a fate with the adversary, which is the failure +this project exists to argue against. + +A note on what this is, because the obvious design is worse than it looks. The +first version used an HMAC with a secret stored beside the baseline. A scanner +flagged the stored secret, and the flag was worth more than a suppression: the +only adversary an HMAC defeats here is one who can WRITE the state directory +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. The secret bought almost no coverage while adding a credential to +leak and a claim inviting a reader to assume more protection than exists. + +So: a bare digest. Same real coverage, 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 +this module keeps the cheap local check and the engines point at the real one. +""" + +from __future__ import annotations + +from .hashing import now_iso, sha_mapping + +__all__ = [ + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "attach_seal", + "check_seal", + "state_digest", +] + +#: Excluded from the digest it carries, since including it would be circular. +SEAL_FIELD = "integrity" + +INTEGRITY_OK = "ok" +INTEGRITY_UNSEALED = "unsealed" # no digest: written before sealing existed +INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside the tool + + +def state_digest(snapshot: dict) -> str: + """Digest of a snapshot's content, ignoring any seal it carries. + + Deterministic, so the value ``approve`` prints can be compared by eye against + the value ``verify`` prints later. + """ + return sha_mapping({k: v for k, v in snapshot.items() if k != SEAL_FIELD}) + + +def attach_seal(snapshot: dict) -> dict: + """Return a copy of ``snapshot`` sealed with a digest over its content.""" + return {**snapshot, SEAL_FIELD: { + "alg": "SHA-256", + "digest": state_digest(snapshot), + "sealed_at": now_iso(), + }} + + +def check_seal(snapshot: dict | None) -> str: + """Recompute the seal and compare. Never raises. + + Catches accidental corruption, truncation, and a hand-edit that does not + recompute the digest. Does not catch an attacker who owns the state directory, + who can recompute it as easily as this function can. + """ + if snapshot is None: + return INTEGRITY_UNSEALED + seal = snapshot.get(SEAL_FIELD) + if not isinstance(seal, dict) or not isinstance(seal.get("digest"), str): + return INTEGRITY_UNSEALED + return INTEGRITY_OK if seal["digest"] == state_digest(snapshot) else INTEGRITY_BROKEN diff --git a/claude-code/engine/_vendor/agentrust_capture_core/state.py b/claude-code/engine/_vendor/agentrust_capture_core/state.py new file mode 100644 index 0000000..c5e87c9 --- /dev/null +++ b/claude-code/engine/_vendor/agentrust_capture_core/state.py @@ -0,0 +1,76 @@ +"""Reading and writing engine state, and the paths it lives at. + +Baseline scoping differs by design and is not unified here. Claude Code keeps one +baseline per machine; Codex keeps one per workspace, because a workspace can carry +its own instructions and skills and a single baseline would blend them. Both are +correct for their agent, so an engine supplies its own paths and this module only +handles the reading and writing. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from .seal import attach_seal + +__all__ = ["StatePaths", "atomic_write", "load_state", "save_state", "save_baseline"] + + +@dataclass(frozen=True) +class StatePaths: + """Where one engine keeps its approved baseline and its latest snapshot.""" + + baseline: Path + latest: Path + + +def atomic_write(path: Path, content: str) -> None: + """Write via a temporary file and replace, so a crash cannot truncate state. + + A half-written baseline is worse than a missing one: the engine would treat it + as corrupt on every future session, and a user who sees a broken check often + enough stops reading it. + """ + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save_state(path: Path, value: dict) -> None: + atomic_write(path, json.dumps(value, indent=2)) + + +def save_baseline(path: Path, snapshot: dict) -> dict: + """Seal a snapshot and write it as the approved baseline. Returns what was written.""" + sealed = attach_seal(snapshot) + save_state(path, sealed) + return sealed + + +def load_state(path: Path) -> dict | None: + """Load a state file, or None if it is absent, unreadable, or corrupt. + + A truncated baseline (crash mid-write, disk full, racing sessions) must not + brick the hook on every future session. Treating corrupt state as absent lets + the next run re-establish it instead of failing forever. + """ + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py index 791e739..3f5af3e 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -31,16 +31,24 @@ import argparse import base64 -import hashlib import json import os import stat import sys import time -import uuid from datetime import datetime, timedelta, timezone from pathlib import Path +# Prefer the installed package; fall back to the pinned vendored copy. The +# SessionStart hook runs before anything is installed, so the fallback is what +# makes drift detection work on a bare plugin install. The copy is generated by +# scripts/sync_vendored_core.py and CI fails if it disagrees with the package. +try: + import agentrust_capture_core as core +except ImportError: # pragma: no cover - exercised by the bare-install path + sys.path.insert(0, str(Path(__file__).resolve().parent / "_vendor")) + import agentrust_capture_core as core + #: Version of WHAT this engine measures, distinct from what it found. #: #: Bump it whenever a change makes a fingerprint incomparable to one written by @@ -64,100 +72,40 @@ # --------------------------------------------------------------------------- # # hashing (files only, never secrets) # --------------------------------------------------------------------------- # -def _sha_bytes(b: bytes) -> str: - return "sha256:" + hashlib.sha256(b).hexdigest() - - -def _sha_file(p: Path) -> str: - return _sha_bytes(p.read_bytes()) +_sha_bytes = core.sha_bytes +_sha_file = core.sha_file +_uuid7 = core.uuid7 +_now_iso = core.now_iso def _sha_tree(root: Path, pattern: str = "*.md") -> str: + """Rollup digest over an instruction tree. + + Kept local rather than replaced by core.tree_digest: this returns the digest of + the empty string for a missing tree, where the core returns None to distinguish + "no component here" from "a component with no files". Changing that would move + the system_prompt fingerprint for every existing baseline, so the rollup keeps + its own semantics and the core is used where a component is being measured. + """ if not root.exists(): return _sha_bytes(b"") - h = hashlib.sha256() - for f in sorted(root.rglob(pattern)): - if f.is_file(): - h.update(f.relative_to(root).as_posix().encode()) - h.update(b"\0") - h.update(f.read_bytes()) - h.update(b"\0") - return "sha256:" + h.hexdigest() - - -def _uuid7() -> str: - """RFC 9562 UUID v7 (time-ordered) -- required by agent-manifest.""" - ms = int(time.time() * 1000) - b = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) - b[6] = 0x70 | (b[6] & 0x0F) - b[8] = 0x80 | (b[8] & 0x3F) - return str(uuid.UUID(bytes=bytes(b))) - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return core.tree_digest(root, pattern=pattern, exclude_dirs=frozenset(), + exclude_suffixes=frozenset()) or _sha_bytes(b"") # --------------------------------------------------------------------------- # # snapshot: read the real box (stdlib only) # --------------------------------------------------------------------------- # -#: Directory names skipped when fingerprinting a skill. These hold state a skill -#: writes as it runs, so hashing them would report drift on ordinary use, and a -#: tool that cries wolf on every run trains its user to ignore it. -#: -#: The list is controlled here rather than by a file inside the skill on purpose. -#: A per-skill ignore file would let the thing being measured decide what gets -#: measured, so a hostile skill could ship an ignore rule covering its own -#: payload. Adding a name here is a reviewed change to this repo. -SKILL_EXCLUDE_DIRS = frozenset({ - "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", -}) - -#: File suffixes skipped for the same reason: run artifacts, not behaviour. -SKILL_EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) - - -def _skill_fingerprint(skill_dir: Path) -> str | None: - """Hash every behavioural file in one skill directory, or None if unreadable. - - Covers the whole tree rather than SKILL.md alone. A skill is not just its - manifest: these directories carry scripts, tools, templates and reference - docs that decide what the skill actually does. Hashing only SKILL.md meant a - payload could be swapped into scripts/ and the integrity check would report - nothing added and nothing subtracted, which is the exact scenario this - integration exists to catch. - - Relative paths are hashed alongside contents so a rename or a move is drift, - and traversal order is sorted so the digest is stable across platforms. - """ - h = hashlib.sha256() - try: - paths = sorted(p for p in skill_dir.rglob("*") if p.is_file()) - except OSError: - return None - for f in paths: - try: - rel = f.relative_to(skill_dir) - except ValueError: # pragma: no cover - rglob results are always relative - continue - if SKILL_EXCLUDE_DIRS & set(rel.parts[:-1]): - continue - if f.suffix in SKILL_EXCLUDE_SUFFIXES: - continue - try: - body = f.read_bytes() - except OSError: - # An unreadable file inside a skill is itself worth recording: bind - # its path into the digest so the file appearing or vanishing moves - # the fingerprint, instead of being silently skipped. - h.update(rel.as_posix().encode()) - h.update(b"\0\0") - continue - h.update(rel.as_posix().encode()) - h.update(b"\0") - h.update(body) - h.update(b"\0") - return "sha256:" + h.hexdigest() +#: Re-exported from the core, which owns the denylist so that a hostile skill +#: cannot exempt its own payload by shipping an ignore file. Kept as module-level +#: names because the hook, the report and the tests refer to them here. +SKILL_EXCLUDE_DIRS = core.EXCLUDE_DIRS +SKILL_EXCLUDE_SUFFIXES = core.EXCLUDE_SUFFIXES + +#: Digest every behavioural file in one skill directory, or None when the +#: directory yielded nothing readable. See core.tree_digest for why the whole tree +#: is covered rather than SKILL.md alone. +_skill_fingerprint = core.tree_digest def _skills() -> dict[str, str]: @@ -709,103 +657,36 @@ def render_report( # --------------------------------------------------------------------------- # # baseline integrity # --------------------------------------------------------------------------- # -# 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_UNSEALED = "unsealed" # no digest: written before sealing existed -INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside this tool +# Sealing and state now live in the core. See its seal module for why this is a +# bare digest rather than an HMAC, and what the digest does and does not defend +# against. The names below stay module-level because the hook, the report and the +# tests refer to them here. +_INTEGRITY_FIELD = core.SEAL_FIELD + +INTEGRITY_OK = core.INTEGRITY_OK +INTEGRITY_UNSEALED = core.INTEGRITY_UNSEALED +INTEGRITY_BROKEN = core.INTEGRITY_BROKEN #: Retained so an older caller keeps working; UNSEALED is the current name. INTEGRITY_UNTAGGED = INTEGRITY_UNSEALED - -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`` sealed with a digest over its content.""" - return {**snap, _INTEGRITY_FIELD: { - "alg": "SHA-256", - "digest": state_digest(snap), - "sealed_at": _now_iso(), - }} - - -def check_integrity(snap: dict | None) -> str: - """Recompute a baseline's digest and compare. Never raises. - - 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_UNSEALED - block = snap.get(_INTEGRITY_FIELD) - 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 +state_digest = core.state_digest +attach_integrity = core.attach_seal +check_integrity = core.check_seal # --------------------------------------------------------------------------- # # state helpers # --------------------------------------------------------------------------- # -def _save(path: Path, obj: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(obj, indent=2), encoding="utf-8") +#: Atomic, so a crash mid-write cannot leave a truncated baseline that reads as +#: corrupt on every future session. The previous implementation wrote in place. +_save = core.save_state +_load = core.load_state 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. - - A truncated baseline.json (crash mid-write, disk full, racing sessions) must - not brick the hook on every future session. Treating corrupt state as absent - lets the next run re-establish it instead of crashing forever. - """ - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return None - return data if isinstance(data, dict) else None + """Seal and write the approved baseline. Returns what was written.""" + return core.save_baseline(BASELINE, snap) def _live_from(args) -> dict | None: diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md new file mode 100644 index 0000000..2c8b669 --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md @@ -0,0 +1,7 @@ +# Generated by scripts/sync_vendored_core.py. Do not edit. +# +# Pinned copy of agentrust-capture-core, used when the package is not installed. +# The engines run from shell hooks before anything is installed, so this fallback +# is what makes drift detection work on a bare plugin install. Edit +# packages/agentrust-capture-core and re-run the sync script; CI fails if this +# copy and the package disagree. diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/__init__.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/__init__.py new file mode 100644 index 0000000..ef9c4e1 --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/__init__.py @@ -0,0 +1,97 @@ +"""Shared core for AgenTrust agent-integrity capture engines. + +Each engine answers one question about a different coding agent: is this the +composition I approved, with nothing added and nothing subtracted? What differs +between agents is where to look and what to call things. What must not differ is +how content is fingerprinted, how snapshots are compared, how a baseline is sealed, +and the rules that keep a report honest. + +Those lived in three copies before this package existed, and the cost was not +theoretical: the same skill-fingerprinting bypass had to be found and fixed twice, +independently, and a reporting defect once. This package is the single source of +truth for the parts that are genuinely identical. + +Standard library only, because the engines run from shell hooks at session start +and must work before anything is installed. +""" + +from __future__ import annotations + +from .compare import ( + Change, + diff_hash, + diff_maps, + diff_scalar, + diff_sets, + observed_categories, + scope_change, +) +from .hashing import ( + EXCLUDE_DIRS, + EXCLUDE_SUFFIXES, + now_iso, + safe_sha_file, + sha_bytes, + sha_file, + sha_mapping, + tree_digest, + uuid7, +) +from .report import ( + UNMEASURED, + change_lines, + clean_verdict, + measured_or, + seal_section, + unmeasured_footnote, +) +from .seal import ( + INTEGRITY_BROKEN, + INTEGRITY_OK, + INTEGRITY_UNSEALED, + SEAL_FIELD, + attach_seal, + check_seal, + state_digest, +) +from .state import StatePaths, atomic_write, load_state, save_baseline, save_state + +__version__ = "0.1.0" + +__all__ = [ + "Change", + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "StatePaths", + "UNMEASURED", + "__version__", + "atomic_write", + "attach_seal", + "change_lines", + "check_seal", + "clean_verdict", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "load_state", + "measured_or", + "now_iso", + "observed_categories", + "safe_sha_file", + "save_baseline", + "save_state", + "scope_change", + "seal_section", + "sha_bytes", + "sha_file", + "sha_mapping", + "state_digest", + "tree_digest", + "unmeasured_footnote", + "uuid7", +] diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/compare.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/compare.py new file mode 100644 index 0000000..67ddebe --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/compare.py @@ -0,0 +1,119 @@ +"""Comparison primitives, plus the two gates that keep a comparison honest. + +Every engine's diff reduces to four shapes: a map of name to digest (components, +instruction files, policy files), a set of names (tools, MCP servers), a scalar +(model, permission mode), and a rollup hash. What differs between engines is which +categories exist and what they are called, so those stay with the engine and the +shapes live here. + +Two gates matter more than the shapes. + +**Observed gating.** A snapshot records which categories it actually measured. A +shell hook cannot enumerate a live tool roster, so comparing a hook snapshot +against a richer baseline would report the baseline's tools as removed. Only +categories that BOTH sides measured are compared. + +**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints +become incomparable. Without handling, an upgrade reports every affected component +as changed. That is an alarm the user knows is false, which is worse than no alarm +because it teaches them to dismiss the next one. So a scope mismatch is reported +once, as a re-approval prompt, and the affected categories are dropped from the +comparison rather than compared wrongly. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence + +__all__ = [ + "Change", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "observed_categories", + "scope_change", +] + +#: A single finding. ``change`` is one of added, removed, changed. +Change = dict + + +def _change(change: str, what: str, detail: str) -> Change: + return {"change": change, "what": what, "detail": detail} + + +def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]: + """Compare two name-to-digest maps. Names are reported, digests are not. + + A digest in a report tells the reader nothing they can act on; the name of the + component that moved does. + """ + out: list[Change] = [] + for name in sorted(set(current) - set(base)): + out.append(_change("added", what, name)) + for name in sorted(set(base) - set(current)): + out.append(_change("removed", what, name)) + for name in sorted(set(base) & set(current)): + if base[name] != current[name]: + out.append(_change("changed", what, name)) + return out + + +def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]: + """Compare two name sets, for categories with no per-item digest.""" + before, after = set(base), set(current) + out: list[Change] = [] + for name in sorted(after - before): + out.append(_change("added", what, name)) + for name in sorted(before - after): + out.append(_change("removed", what, name)) + return out + + +def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]: + """Compare a single value, reporting the transition rather than just the fact.""" + if before == after: + return [] + return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))] + + +def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]: + """Compare a rollup hash, where only the fact of change is available.""" + if before == after: + return [] + return [_change("changed", what, detail)] + + +def observed_categories( + base: Mapping[str, object], + current: Mapping[str, object], + default: Sequence[str] = (), +) -> set[str]: + """Categories both snapshots measured, and therefore may be compared.""" + return set(base.get("observed", list(default))) & set(current.get("observed", list(default))) + + +def scope_change( + base: Mapping[str, object], + current_scope: int, + *, + affected: Sequence[str], + reason: str, +) -> Change | None: + """Report a widened measurement scope, or None when the scopes agree. + + ``affected`` names the categories the caller must drop from its comparison, + and is included in the message so the reader knows what was not checked rather + than assuming everything was. + """ + base_scope = base.get("scope", 1) + if base_scope == current_scope: + return None + dropped = ", ".join(affected) if affected else "none" + return _change( + "changed", + "measurement scope", + "widened from %s to %s; %s Not compared this run: %s. Re-approve once to " + "compare on the new scope." % (base_scope, current_scope, reason, dropped), + ) diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/hashing.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/hashing.py new file mode 100644 index 0000000..0b60284 --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/hashing.py @@ -0,0 +1,146 @@ +"""Content fingerprinting shared by every AgenTrust capture engine. + +Every engine answers the same question about a different agent: is this the +composition I approved, with nothing added and nothing subtracted? The parts that +differ between agents are *where to look* and *what to call things*. Hashing is +not one of them, so it lives here. + +Standard library only. The engines are invoked by shell hooks at session start and +must run before any dependency is installed. +""" + +from __future__ import annotations + +import hashlib +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +__all__ = [ + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "now_iso", + "sha_bytes", + "sha_file", + "sha_mapping", + "safe_sha_file", + "tree_digest", + "uuid7", +] + +#: Directory names skipped when fingerprinting a component tree. These hold state +#: a component writes as it runs, so hashing them would report drift on ordinary +#: use, and a tool that cries wolf on every run trains its user to ignore it. +#: +#: Controlled here rather than by a file inside the component on purpose. A +#: per-component ignore file would let the thing being measured decide what gets +#: measured, so a hostile component could ship a rule covering its own payload. +#: Adding a name here is a reviewed change to this package. +EXCLUDE_DIRS = frozenset({ + "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", +}) + +#: File suffixes skipped for the same reason: run artifacts, not behaviour. +EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) + + +def sha_bytes(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def sha_file(path: Path) -> str: + return sha_bytes(path.read_bytes()) + + +def safe_sha_file(path: Path) -> str | None: + """Digest a file, or None if it is missing or unreadable. + + Used on the discovery path, where a file vanishing between listing and + reading is ordinary rather than exceptional. + """ + try: + return sha_file(path) + except OSError: + return None + + +def sha_mapping(value: dict) -> str: + """Digest a mapping by canonical JSON, so key order cannot change the result.""" + import json + + return sha_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def tree_digest( + root: Path, + *, + exclude_dirs: frozenset[str] = EXCLUDE_DIRS, + exclude_suffixes: frozenset[str] = EXCLUDE_SUFFIXES, + pattern: str = "*", +) -> str | None: + """Digest every behavioural file under ``root``, or None if nothing was read. + + Covers the whole tree rather than a single manifest file. A component is not + just its manifest: these directories carry scripts, tools, templates and + reference material that decide what the component actually does. Digesting one + manifest let a payload be swapped into a sibling ``scripts/`` directory while + the report said nothing added, nothing subtracted. That was a live bypass in + two shipped engines before this function existed, which is the reason it is + shared rather than reimplemented. + + Relative paths are bound into the digest alongside contents, so a rename or a + move is drift. Traversal is sorted so the digest is stable across platforms. + Symlinks are skipped so a link out of the tree cannot pull unrelated content + into the fingerprint, and so a cycle cannot hang the hook. + """ + digest = hashlib.sha256() + try: + paths = sorted(root.rglob(pattern)) + except OSError: + return None + saw_file = False + for path in paths: + if path.is_symlink(): + continue + try: + if not path.is_file(): + continue + relative = path.relative_to(root) + except (OSError, ValueError): + continue + if exclude_dirs & set(relative.parts[:-1]): + continue + if path.suffix in exclude_suffixes: + continue + digest.update(relative.as_posix().encode("utf-8")) + try: + body = path.read_bytes() + except OSError: + # An unreadable file is itself worth recording: its path is already + # bound in, so the file appearing or vanishing still moves the digest + # instead of being silently skipped. + digest.update(b"\0\0") + saw_file = True + continue + digest.update(b"\0") + digest.update(body) + digest.update(b"\0") + saw_file = True + if not saw_file: + return None + return "sha256:" + digest.hexdigest() + + +def uuid7() -> str: + """RFC 9562 UUID v7 (time-ordered), required by agent-manifest.""" + ms = int(time.time() * 1000) + raw = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) + raw[6] = 0x70 | (raw[6] & 0x0F) + raw[8] = 0x80 | (raw[8] & 0x3F) + return str(uuid.UUID(bytes=bytes(raw))) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/report.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/report.py new file mode 100644 index 0000000..e2629cc --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/report.py @@ -0,0 +1,101 @@ +"""Report vocabulary shared across engines. + +The engines render different reports on purpose: they name different things and a +Codex user should not read Claude Code labels. What must not differ is the honesty +rules, because those drifted once already and each engine had to be fixed +separately. + +Two rules live here. + +**An unmeasured category is not an empty one.** A shell hook cannot see a live tool +roster or the model, so those arrive only from a caller-supplied live context. +Rendering them as ``0 tools`` or ``model: unknown`` states a measurement that was +never taken, and a reader who cannot tell "we did not check" from "we checked and +found nothing" will treat an absence as a pass. + +**A partial check is not a clean bill of health.** "Nothing added, nothing +subtracted" is only true of what was compared, so it is qualified whenever coverage +is incomplete. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .seal import INTEGRITY_BROKEN, INTEGRITY_OK, INTEGRITY_UNSEALED + +__all__ = [ + "UNMEASURED", + "clean_verdict", + "measured_or", + "seal_section", + "unmeasured_footnote", +] + +#: Shown wherever a category was not measured. +UNMEASURED = "not measured this run" + + +def measured_or(value: object, measured: bool, hint: str | None = None) -> str: + """Render ``value`` when it was measured, and say so plainly when it was not.""" + if measured: + return str(value) + return "%s (%s)" % (UNMEASURED, hint) if hint else UNMEASURED + + +def unmeasured_footnote(complete: bool) -> list[str]: + """The line that stops an absent measurement reading as a verified absence.""" + if complete: + return [] + return [ + ' Categories marked "%s" are NOT part of this comparison.' % UNMEASURED, + " They are unchecked, not verified as empty.", + "", + ] + + +def clean_verdict(complete: bool, phrasing: str = "nothing added, nothing subtracted") -> str: + """A no-changes verdict, qualified when coverage was partial.""" + scope = "" if complete else " in the categories checked" + return " >> Verified: %s%s." % (phrasing, scope) + + +def seal_section(integrity: str, digest: str | None = None) -> list[str]: + """The baseline-integrity block, stated before any drift result. + + Ordering is the point. If the baseline was altered, a reassuring "nothing + changed" underneath it is worse than no result at all, so a caller renders this + above its drift section. + """ + lines = [" IS THE BASELINE ITSELF INTACT?", " " + "-" * 62] + if integrity == INTEGRITY_BROKEN: + lines += [ + " !! the baseline 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_UNSEALED: + lines.append(" ~ baseline carries no digest (written by an older version). " + "Re-approve to seal it.") + elif integrity == INTEGRITY_OK: + lines.append(" >> baseline digest verified.") + if digest: + lines.append(" digest: %s" % digest) + lines += [ + " 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.", + "", + ] + return lines + + +def change_lines(changes: Sequence[dict]) -> list[str]: + """Render findings with a stable symbol per kind.""" + symbol = {"added": "+", "removed": "-", "changed": "~"} + return [ + " %s %s %s: %s" % (symbol.get(c["change"], "?"), c["change"].upper(), + c["what"], c["detail"]) + for c in changes + ] diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/seal.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/seal.py new file mode 100644 index 0000000..7b0f641 --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/seal.py @@ -0,0 +1,80 @@ +"""Baseline sealing: is the thing we compare against still what we wrote? + +The baseline is what every drift comparison is made against. An unsealed baseline +means anyone able to write it can add a component to the *approved* set, after +which the check reports "nothing added, nothing subtracted" indefinitely and +quietly. The evidence would share a fate with the adversary, which is the failure +this project exists to argue against. + +A note on what this is, because the obvious design is worse than it looks. The +first version used an HMAC with a secret stored beside the baseline. A scanner +flagged the stored secret, and the flag was worth more than a suppression: the +only adversary an HMAC defeats here is one who can WRITE the state directory +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. The secret bought almost no coverage while adding a credential to +leak and a claim inviting a reader to assume more protection than exists. + +So: a bare digest. Same real coverage, 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 +this module keeps the cheap local check and the engines point at the real one. +""" + +from __future__ import annotations + +from .hashing import now_iso, sha_mapping + +__all__ = [ + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "attach_seal", + "check_seal", + "state_digest", +] + +#: Excluded from the digest it carries, since including it would be circular. +SEAL_FIELD = "integrity" + +INTEGRITY_OK = "ok" +INTEGRITY_UNSEALED = "unsealed" # no digest: written before sealing existed +INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside the tool + + +def state_digest(snapshot: dict) -> str: + """Digest of a snapshot's content, ignoring any seal it carries. + + Deterministic, so the value ``approve`` prints can be compared by eye against + the value ``verify`` prints later. + """ + return sha_mapping({k: v for k, v in snapshot.items() if k != SEAL_FIELD}) + + +def attach_seal(snapshot: dict) -> dict: + """Return a copy of ``snapshot`` sealed with a digest over its content.""" + return {**snapshot, SEAL_FIELD: { + "alg": "SHA-256", + "digest": state_digest(snapshot), + "sealed_at": now_iso(), + }} + + +def check_seal(snapshot: dict | None) -> str: + """Recompute the seal and compare. Never raises. + + Catches accidental corruption, truncation, and a hand-edit that does not + recompute the digest. Does not catch an attacker who owns the state directory, + who can recompute it as easily as this function can. + """ + if snapshot is None: + return INTEGRITY_UNSEALED + seal = snapshot.get(SEAL_FIELD) + if not isinstance(seal, dict) or not isinstance(seal.get("digest"), str): + return INTEGRITY_UNSEALED + return INTEGRITY_OK if seal["digest"] == state_digest(snapshot) else INTEGRITY_BROKEN diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py new file mode 100644 index 0000000..c5e87c9 --- /dev/null +++ b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py @@ -0,0 +1,76 @@ +"""Reading and writing engine state, and the paths it lives at. + +Baseline scoping differs by design and is not unified here. Claude Code keeps one +baseline per machine; Codex keeps one per workspace, because a workspace can carry +its own instructions and skills and a single baseline would blend them. Both are +correct for their agent, so an engine supplies its own paths and this module only +handles the reading and writing. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from .seal import attach_seal + +__all__ = ["StatePaths", "atomic_write", "load_state", "save_state", "save_baseline"] + + +@dataclass(frozen=True) +class StatePaths: + """Where one engine keeps its approved baseline and its latest snapshot.""" + + baseline: Path + latest: Path + + +def atomic_write(path: Path, content: str) -> None: + """Write via a temporary file and replace, so a crash cannot truncate state. + + A half-written baseline is worse than a missing one: the engine would treat it + as corrupt on every future session, and a user who sees a broken check often + enough stops reading it. + """ + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save_state(path: Path, value: dict) -> None: + atomic_write(path, json.dumps(value, indent=2)) + + +def save_baseline(path: Path, snapshot: dict) -> dict: + """Seal a snapshot and write it as the approved baseline. Returns what was written.""" + sealed = attach_seal(snapshot) + save_state(path, sealed) + return sealed + + +def load_state(path: Path) -> dict | None: + """Load a state file, or None if it is absent, unreadable, or corrupt. + + A truncated baseline (crash mid-write, disk full, racing sessions) must not + brick the hook on every future session. Treating corrupt state as absent lets + the next run re-establish it instead of failing forever. + """ + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md b/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md new file mode 100644 index 0000000..2c8b669 --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md @@ -0,0 +1,7 @@ +# Generated by scripts/sync_vendored_core.py. Do not edit. +# +# Pinned copy of agentrust-capture-core, used when the package is not installed. +# The engines run from shell hooks before anything is installed, so this fallback +# is what makes drift detection work on a bare plugin install. Edit +# packages/agentrust-capture-core and re-run the sync script; CI fails if this +# copy and the package disagree. diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/__init__.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/__init__.py new file mode 100644 index 0000000..ef9c4e1 --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/__init__.py @@ -0,0 +1,97 @@ +"""Shared core for AgenTrust agent-integrity capture engines. + +Each engine answers one question about a different coding agent: is this the +composition I approved, with nothing added and nothing subtracted? What differs +between agents is where to look and what to call things. What must not differ is +how content is fingerprinted, how snapshots are compared, how a baseline is sealed, +and the rules that keep a report honest. + +Those lived in three copies before this package existed, and the cost was not +theoretical: the same skill-fingerprinting bypass had to be found and fixed twice, +independently, and a reporting defect once. This package is the single source of +truth for the parts that are genuinely identical. + +Standard library only, because the engines run from shell hooks at session start +and must work before anything is installed. +""" + +from __future__ import annotations + +from .compare import ( + Change, + diff_hash, + diff_maps, + diff_scalar, + diff_sets, + observed_categories, + scope_change, +) +from .hashing import ( + EXCLUDE_DIRS, + EXCLUDE_SUFFIXES, + now_iso, + safe_sha_file, + sha_bytes, + sha_file, + sha_mapping, + tree_digest, + uuid7, +) +from .report import ( + UNMEASURED, + change_lines, + clean_verdict, + measured_or, + seal_section, + unmeasured_footnote, +) +from .seal import ( + INTEGRITY_BROKEN, + INTEGRITY_OK, + INTEGRITY_UNSEALED, + SEAL_FIELD, + attach_seal, + check_seal, + state_digest, +) +from .state import StatePaths, atomic_write, load_state, save_baseline, save_state + +__version__ = "0.1.0" + +__all__ = [ + "Change", + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "StatePaths", + "UNMEASURED", + "__version__", + "atomic_write", + "attach_seal", + "change_lines", + "check_seal", + "clean_verdict", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "load_state", + "measured_or", + "now_iso", + "observed_categories", + "safe_sha_file", + "save_baseline", + "save_state", + "scope_change", + "seal_section", + "sha_bytes", + "sha_file", + "sha_mapping", + "state_digest", + "tree_digest", + "unmeasured_footnote", + "uuid7", +] diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/compare.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/compare.py new file mode 100644 index 0000000..67ddebe --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/compare.py @@ -0,0 +1,119 @@ +"""Comparison primitives, plus the two gates that keep a comparison honest. + +Every engine's diff reduces to four shapes: a map of name to digest (components, +instruction files, policy files), a set of names (tools, MCP servers), a scalar +(model, permission mode), and a rollup hash. What differs between engines is which +categories exist and what they are called, so those stay with the engine and the +shapes live here. + +Two gates matter more than the shapes. + +**Observed gating.** A snapshot records which categories it actually measured. A +shell hook cannot enumerate a live tool roster, so comparing a hook snapshot +against a richer baseline would report the baseline's tools as removed. Only +categories that BOTH sides measured are compared. + +**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints +become incomparable. Without handling, an upgrade reports every affected component +as changed. That is an alarm the user knows is false, which is worse than no alarm +because it teaches them to dismiss the next one. So a scope mismatch is reported +once, as a re-approval prompt, and the affected categories are dropped from the +comparison rather than compared wrongly. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence + +__all__ = [ + "Change", + "diff_hash", + "diff_maps", + "diff_scalar", + "diff_sets", + "observed_categories", + "scope_change", +] + +#: A single finding. ``change`` is one of added, removed, changed. +Change = dict + + +def _change(change: str, what: str, detail: str) -> Change: + return {"change": change, "what": what, "detail": detail} + + +def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]: + """Compare two name-to-digest maps. Names are reported, digests are not. + + A digest in a report tells the reader nothing they can act on; the name of the + component that moved does. + """ + out: list[Change] = [] + for name in sorted(set(current) - set(base)): + out.append(_change("added", what, name)) + for name in sorted(set(base) - set(current)): + out.append(_change("removed", what, name)) + for name in sorted(set(base) & set(current)): + if base[name] != current[name]: + out.append(_change("changed", what, name)) + return out + + +def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]: + """Compare two name sets, for categories with no per-item digest.""" + before, after = set(base), set(current) + out: list[Change] = [] + for name in sorted(after - before): + out.append(_change("added", what, name)) + for name in sorted(before - after): + out.append(_change("removed", what, name)) + return out + + +def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]: + """Compare a single value, reporting the transition rather than just the fact.""" + if before == after: + return [] + return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))] + + +def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]: + """Compare a rollup hash, where only the fact of change is available.""" + if before == after: + return [] + return [_change("changed", what, detail)] + + +def observed_categories( + base: Mapping[str, object], + current: Mapping[str, object], + default: Sequence[str] = (), +) -> set[str]: + """Categories both snapshots measured, and therefore may be compared.""" + return set(base.get("observed", list(default))) & set(current.get("observed", list(default))) + + +def scope_change( + base: Mapping[str, object], + current_scope: int, + *, + affected: Sequence[str], + reason: str, +) -> Change | None: + """Report a widened measurement scope, or None when the scopes agree. + + ``affected`` names the categories the caller must drop from its comparison, + and is included in the message so the reader knows what was not checked rather + than assuming everything was. + """ + base_scope = base.get("scope", 1) + if base_scope == current_scope: + return None + dropped = ", ".join(affected) if affected else "none" + return _change( + "changed", + "measurement scope", + "widened from %s to %s; %s Not compared this run: %s. Re-approve once to " + "compare on the new scope." % (base_scope, current_scope, reason, dropped), + ) diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/hashing.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/hashing.py new file mode 100644 index 0000000..0b60284 --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/hashing.py @@ -0,0 +1,146 @@ +"""Content fingerprinting shared by every AgenTrust capture engine. + +Every engine answers the same question about a different agent: is this the +composition I approved, with nothing added and nothing subtracted? The parts that +differ between agents are *where to look* and *what to call things*. Hashing is +not one of them, so it lives here. + +Standard library only. The engines are invoked by shell hooks at session start and +must run before any dependency is installed. +""" + +from __future__ import annotations + +import hashlib +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + +__all__ = [ + "EXCLUDE_DIRS", + "EXCLUDE_SUFFIXES", + "now_iso", + "sha_bytes", + "sha_file", + "sha_mapping", + "safe_sha_file", + "tree_digest", + "uuid7", +] + +#: Directory names skipped when fingerprinting a component tree. These hold state +#: a component writes as it runs, so hashing them would report drift on ordinary +#: use, and a tool that cries wolf on every run trains its user to ignore it. +#: +#: Controlled here rather than by a file inside the component on purpose. A +#: per-component ignore file would let the thing being measured decide what gets +#: measured, so a hostile component could ship a rule covering its own payload. +#: Adding a name here is a reviewed change to this package. +EXCLUDE_DIRS = frozenset({ + "state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules", +}) + +#: File suffixes skipped for the same reason: run artifacts, not behaviour. +EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"}) + + +def sha_bytes(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def sha_file(path: Path) -> str: + return sha_bytes(path.read_bytes()) + + +def safe_sha_file(path: Path) -> str | None: + """Digest a file, or None if it is missing or unreadable. + + Used on the discovery path, where a file vanishing between listing and + reading is ordinary rather than exceptional. + """ + try: + return sha_file(path) + except OSError: + return None + + +def sha_mapping(value: dict) -> str: + """Digest a mapping by canonical JSON, so key order cannot change the result.""" + import json + + return sha_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def tree_digest( + root: Path, + *, + exclude_dirs: frozenset[str] = EXCLUDE_DIRS, + exclude_suffixes: frozenset[str] = EXCLUDE_SUFFIXES, + pattern: str = "*", +) -> str | None: + """Digest every behavioural file under ``root``, or None if nothing was read. + + Covers the whole tree rather than a single manifest file. A component is not + just its manifest: these directories carry scripts, tools, templates and + reference material that decide what the component actually does. Digesting one + manifest let a payload be swapped into a sibling ``scripts/`` directory while + the report said nothing added, nothing subtracted. That was a live bypass in + two shipped engines before this function existed, which is the reason it is + shared rather than reimplemented. + + Relative paths are bound into the digest alongside contents, so a rename or a + move is drift. Traversal is sorted so the digest is stable across platforms. + Symlinks are skipped so a link out of the tree cannot pull unrelated content + into the fingerprint, and so a cycle cannot hang the hook. + """ + digest = hashlib.sha256() + try: + paths = sorted(root.rglob(pattern)) + except OSError: + return None + saw_file = False + for path in paths: + if path.is_symlink(): + continue + try: + if not path.is_file(): + continue + relative = path.relative_to(root) + except (OSError, ValueError): + continue + if exclude_dirs & set(relative.parts[:-1]): + continue + if path.suffix in exclude_suffixes: + continue + digest.update(relative.as_posix().encode("utf-8")) + try: + body = path.read_bytes() + except OSError: + # An unreadable file is itself worth recording: its path is already + # bound in, so the file appearing or vanishing still moves the digest + # instead of being silently skipped. + digest.update(b"\0\0") + saw_file = True + continue + digest.update(b"\0") + digest.update(body) + digest.update(b"\0") + saw_file = True + if not saw_file: + return None + return "sha256:" + digest.hexdigest() + + +def uuid7() -> str: + """RFC 9562 UUID v7 (time-ordered), required by agent-manifest.""" + ms = int(time.time() * 1000) + raw = bytearray(ms.to_bytes(6, "big") + os.urandom(10)) + raw[6] = 0x70 | (raw[6] & 0x0F) + raw[8] = 0x80 | (raw[8] & 0x3F) + return str(uuid.UUID(bytes=bytes(raw))) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/report.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/report.py new file mode 100644 index 0000000..e2629cc --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/report.py @@ -0,0 +1,101 @@ +"""Report vocabulary shared across engines. + +The engines render different reports on purpose: they name different things and a +Codex user should not read Claude Code labels. What must not differ is the honesty +rules, because those drifted once already and each engine had to be fixed +separately. + +Two rules live here. + +**An unmeasured category is not an empty one.** A shell hook cannot see a live tool +roster or the model, so those arrive only from a caller-supplied live context. +Rendering them as ``0 tools`` or ``model: unknown`` states a measurement that was +never taken, and a reader who cannot tell "we did not check" from "we checked and +found nothing" will treat an absence as a pass. + +**A partial check is not a clean bill of health.** "Nothing added, nothing +subtracted" is only true of what was compared, so it is qualified whenever coverage +is incomplete. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .seal import INTEGRITY_BROKEN, INTEGRITY_OK, INTEGRITY_UNSEALED + +__all__ = [ + "UNMEASURED", + "clean_verdict", + "measured_or", + "seal_section", + "unmeasured_footnote", +] + +#: Shown wherever a category was not measured. +UNMEASURED = "not measured this run" + + +def measured_or(value: object, measured: bool, hint: str | None = None) -> str: + """Render ``value`` when it was measured, and say so plainly when it was not.""" + if measured: + return str(value) + return "%s (%s)" % (UNMEASURED, hint) if hint else UNMEASURED + + +def unmeasured_footnote(complete: bool) -> list[str]: + """The line that stops an absent measurement reading as a verified absence.""" + if complete: + return [] + return [ + ' Categories marked "%s" are NOT part of this comparison.' % UNMEASURED, + " They are unchecked, not verified as empty.", + "", + ] + + +def clean_verdict(complete: bool, phrasing: str = "nothing added, nothing subtracted") -> str: + """A no-changes verdict, qualified when coverage was partial.""" + scope = "" if complete else " in the categories checked" + return " >> Verified: %s%s." % (phrasing, scope) + + +def seal_section(integrity: str, digest: str | None = None) -> list[str]: + """The baseline-integrity block, stated before any drift result. + + Ordering is the point. If the baseline was altered, a reassuring "nothing + changed" underneath it is worse than no result at all, so a caller renders this + above its drift section. + """ + lines = [" IS THE BASELINE ITSELF INTACT?", " " + "-" * 62] + if integrity == INTEGRITY_BROKEN: + lines += [ + " !! the baseline 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_UNSEALED: + lines.append(" ~ baseline carries no digest (written by an older version). " + "Re-approve to seal it.") + elif integrity == INTEGRITY_OK: + lines.append(" >> baseline digest verified.") + if digest: + lines.append(" digest: %s" % digest) + lines += [ + " 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.", + "", + ] + return lines + + +def change_lines(changes: Sequence[dict]) -> list[str]: + """Render findings with a stable symbol per kind.""" + symbol = {"added": "+", "removed": "-", "changed": "~"} + return [ + " %s %s %s: %s" % (symbol.get(c["change"], "?"), c["change"].upper(), + c["what"], c["detail"]) + for c in changes + ] diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/seal.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/seal.py new file mode 100644 index 0000000..7b0f641 --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/seal.py @@ -0,0 +1,80 @@ +"""Baseline sealing: is the thing we compare against still what we wrote? + +The baseline is what every drift comparison is made against. An unsealed baseline +means anyone able to write it can add a component to the *approved* set, after +which the check reports "nothing added, nothing subtracted" indefinitely and +quietly. The evidence would share a fate with the adversary, which is the failure +this project exists to argue against. + +A note on what this is, because the obvious design is worse than it looks. The +first version used an HMAC with a secret stored beside the baseline. A scanner +flagged the stored secret, and the flag was worth more than a suppression: the +only adversary an HMAC defeats here is one who can WRITE the state directory +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. The secret bought almost no coverage while adding a credential to +leak and a claim inviting a reader to assume more protection than exists. + +So: a bare digest. Same real coverage, 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 +this module keeps the cheap local check and the engines point at the real one. +""" + +from __future__ import annotations + +from .hashing import now_iso, sha_mapping + +__all__ = [ + "INTEGRITY_BROKEN", + "INTEGRITY_OK", + "INTEGRITY_UNSEALED", + "SEAL_FIELD", + "attach_seal", + "check_seal", + "state_digest", +] + +#: Excluded from the digest it carries, since including it would be circular. +SEAL_FIELD = "integrity" + +INTEGRITY_OK = "ok" +INTEGRITY_UNSEALED = "unsealed" # no digest: written before sealing existed +INTEGRITY_BROKEN = "broken" # digest present and wrong: edited outside the tool + + +def state_digest(snapshot: dict) -> str: + """Digest of a snapshot's content, ignoring any seal it carries. + + Deterministic, so the value ``approve`` prints can be compared by eye against + the value ``verify`` prints later. + """ + return sha_mapping({k: v for k, v in snapshot.items() if k != SEAL_FIELD}) + + +def attach_seal(snapshot: dict) -> dict: + """Return a copy of ``snapshot`` sealed with a digest over its content.""" + return {**snapshot, SEAL_FIELD: { + "alg": "SHA-256", + "digest": state_digest(snapshot), + "sealed_at": now_iso(), + }} + + +def check_seal(snapshot: dict | None) -> str: + """Recompute the seal and compare. Never raises. + + Catches accidental corruption, truncation, and a hand-edit that does not + recompute the digest. Does not catch an attacker who owns the state directory, + who can recompute it as easily as this function can. + """ + if snapshot is None: + return INTEGRITY_UNSEALED + seal = snapshot.get(SEAL_FIELD) + if not isinstance(seal, dict) or not isinstance(seal.get("digest"), str): + return INTEGRITY_UNSEALED + return INTEGRITY_OK if seal["digest"] == state_digest(snapshot) else INTEGRITY_BROKEN diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/state.py b/scheduled-agents/engine/_vendor/agentrust_capture_core/state.py new file mode 100644 index 0000000..c5e87c9 --- /dev/null +++ b/scheduled-agents/engine/_vendor/agentrust_capture_core/state.py @@ -0,0 +1,76 @@ +"""Reading and writing engine state, and the paths it lives at. + +Baseline scoping differs by design and is not unified here. Claude Code keeps one +baseline per machine; Codex keeps one per workspace, because a workspace can carry +its own instructions and skills and a single baseline would blend them. Both are +correct for their agent, so an engine supplies its own paths and this module only +handles the reading and writing. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from .seal import attach_seal + +__all__ = ["StatePaths", "atomic_write", "load_state", "save_state", "save_baseline"] + + +@dataclass(frozen=True) +class StatePaths: + """Where one engine keeps its approved baseline and its latest snapshot.""" + + baseline: Path + latest: Path + + +def atomic_write(path: Path, content: str) -> None: + """Write via a temporary file and replace, so a crash cannot truncate state. + + A half-written baseline is worse than a missing one: the engine would treat it + as corrupt on every future session, and a user who sees a broken check often + enough stops reading it. + """ + path.parent.mkdir(parents=True, exist_ok=True) + handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save_state(path: Path, value: dict) -> None: + atomic_write(path, json.dumps(value, indent=2)) + + +def save_baseline(path: Path, snapshot: dict) -> dict: + """Seal a snapshot and write it as the approved baseline. Returns what was written.""" + sealed = attach_seal(snapshot) + save_state(path, sealed) + return sealed + + +def load_state(path: Path) -> dict | None: + """Load a state file, or None if it is absent, unreadable, or corrupt. + + A truncated baseline (crash mid-write, disk full, racing sessions) must not + brick the hook on every future session. Treating corrupt state as absent lets + the next run re-establish it instead of failing forever. + """ + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None diff --git a/scripts/sync_vendored_core.py b/scripts/sync_vendored_core.py new file mode 100644 index 0000000..34fa261 --- /dev/null +++ b/scripts/sync_vendored_core.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Copy agentrust-capture-core into each engine's ``_vendor`` directory. + +The engines are invoked by shell hooks at session start, by absolute path, before +anything is installed. So the core cannot be a plain runtime dependency: a user who +installs the Claude Code plugin and nothing else must still get drift detection. +Each engine therefore prefers the installed package and falls back to a pinned +vendored copy. + +That leaves the copies free to drift, which is the failure this whole exercise is +meant to end. So the copies are generated, never hand-edited, and CI asserts they +match the package byte for byte. + + python scripts/sync_vendored_core.py # write the copies + python scripts/sync_vendored_core.py --check # verify, exit 1 on drift +""" + +from __future__ import annotations + +import argparse +import filecmp +import shutil +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SOURCE = REPO / "packages" / "agentrust-capture-core" / "src" / "agentrust_capture_core" + +#: Engines that vendor the core, as directories that will each hold +#: ``_vendor/agentrust_capture_core``. +ENGINES = ( + REPO / "claude-code" / "engine", + REPO / "plugins" / "agentrust-codex" / "engine", + REPO / "scheduled-agents" / "engine", +) + +HEADER = """# Generated by scripts/sync_vendored_core.py. Do not edit. +# +# Pinned copy of agentrust-capture-core, used when the package is not installed. +# The engines run from shell hooks before anything is installed, so this fallback +# is what makes drift detection work on a bare plugin install. Edit +# packages/agentrust-capture-core and re-run the sync script; CI fails if this +# copy and the package disagree. +""" + + +def _targets() -> list[Path]: + return [engine / "_vendor" / "agentrust_capture_core" for engine in ENGINES] + + +def _source_files() -> list[Path]: + return sorted(SOURCE.glob("*.py")) + + +def write() -> int: + for target in _targets(): + target.mkdir(parents=True, exist_ok=True) + for existing in target.glob("*.py"): + existing.unlink() + for module in _source_files(): + shutil.copy2(module, target / module.name) + (target / "VENDORED.md").write_text(HEADER, encoding="utf-8") + print("wrote %s (%d modules)" % (target.relative_to(REPO), len(_source_files()))) + return 0 + + +def check() -> int: + expected = {module.name for module in _source_files()} + failures: list[str] = [] + for target in _targets(): + if not target.is_dir(): + failures.append("%s is missing; run scripts/sync_vendored_core.py" + % target.relative_to(REPO)) + continue + found = {module.name for module in target.glob("*.py")} + for extra in sorted(found - expected): + failures.append("%s/%s is not in the package" % (target.relative_to(REPO), extra)) + for missing in sorted(expected - found): + failures.append("%s/%s is missing" % (target.relative_to(REPO), missing)) + for name in sorted(expected & found): + if not filecmp.cmp(SOURCE / name, target / name, shallow=False): + failures.append("%s/%s differs from the package" + % (target.relative_to(REPO), name)) + if failures: + print("Vendored core is out of sync:", file=sys.stderr) + for line in failures: + print(" - %s" % line, file=sys.stderr) + print("\nRun: python scripts/sync_vendored_core.py", file=sys.stderr) + return 1 + print("vendored core matches the package in %d engine(s)" % len(_targets())) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="verify the copies match, without writing") + args = parser.parse_args() + if not SOURCE.is_dir(): + print("package source not found at %s" % SOURCE, file=sys.stderr) + return 1 + return check() if args.check else write() + + +if __name__ == "__main__": + sys.exit(main())