diff --git a/.github/workflows/agentrust-codex-tests.yml b/.github/workflows/agentrust-codex-tests.yml index c01452b..316ea8f 100644 --- a/.github/workflows/agentrust-codex-tests.yml +++ b/.github/workflows/agentrust-codex-tests.yml @@ -19,7 +19,7 @@ permissions: contents: read jobs: - stdlib: + drift-without-signing: runs-on: ubuntu-latest strategy: fail-fast: false @@ -32,9 +32,9 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "${{ matrix.python-version }}" - - name: Run dependency-free capture tests + - name: Run capture tests without the signing packages run: | - pip install pytest + pip install ./packages/agentrust-capture-core pytest python -m pytest plugins/agentrust-codex/tests -q signing: @@ -52,7 +52,7 @@ jobs: python-version: "${{ matrix.python-version }}" - name: Run signing and conformance tests run: | - pip install pytest -r plugins/agentrust-codex/requirements.txt + pip install ./packages/agentrust-capture-core pytest -r plugins/agentrust-codex/requirements.txt python -m pytest plugins/agentrust-codex/tests -q structure: diff --git a/.github/workflows/capture-core-publish.yml b/.github/workflows/capture-core-publish.yml index b4ff8d7..416afc5 100644 --- a/.github/workflows/capture-core-publish.yml +++ b/.github/workflows/capture-core-publish.yml @@ -41,24 +41,9 @@ jobs: exit 1 fi - # The vendored copies inside each engine must match what is about to be - # published, or the installed path and the fallback path would ship different - # code under the same version. - consistency: - name: Vendored copies match the package - 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" - - run: python scripts/sync_vendored_core.py --check - build: name: Build distribution - needs: [guard, consistency] + needs: [guard] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/capture-core-tests.yml b/.github/workflows/capture-core-tests.yml index 3f72d25..ad71f58 100644 --- a/.github/workflows/capture-core-tests.yml +++ b/.github/workflows/capture-core-tests.yml @@ -4,15 +4,11 @@ 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: @@ -40,10 +36,10 @@ jobs: 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: + # The engines import the core as a hard dependency now, so the thing worth + # proving is that a fresh install of the built package actually satisfies every + # engine. Previously this job asserted the opposite, that they worked WITHOUT it. + engines-import-against-the-installed-core: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -52,35 +48,44 @@ jobs: - 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 + - name: Install the core from source, then import every engine run: | + pip install ./packages/agentrust-capture-core 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" - ) + import importlib.util for path in ( "claude-code/engine/capture.py", "plugins/agentrust-codex/engine/capture.py", "scheduled-agents/engine/capture.py", + "copilot/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) + assert "_vendor" not in module.core.__file__, path + " still loaded a vendored copy" + print("ok:", path, "->", module.core.__version__) PY + + # A missing core must fail loudly. The engines previously fell back to a vendored + # copy; now they must tell the user what to install rather than emitting a vague + # "integrity check skipped". + missing-core-fails-clearly: + 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: Engine must name the missing package + run: | + set +e + out="$(python claude-code/engine/capture.py verify 2>&1)" + code=$? + set -e + echo "$out" + if [ "$code" -eq 0 ]; then + echo "::error::engine succeeded without the core installed"; exit 1 + fi + echo "$out" | grep -q "pip install agentrust-capture-core" || { echo "::error::error message does not say what to install"; exit 1; } diff --git a/.github/workflows/claude-code-tests.yml b/.github/workflows/claude-code-tests.yml index dd0f930..e7a57f3 100644 --- a/.github/workflows/claude-code-tests.yml +++ b/.github/workflows/claude-code-tests.yml @@ -15,10 +15,11 @@ permissions: contents: read jobs: - # The SessionStart hook and drift check must work with the standard library - # alone. This job installs no crypto packages, so the signing tests skip and - # any accidental dependency on them fails the build. - stdlib: + # The drift path needs only agentrust-capture-core. This job installs no crypto + # packages, so the signing tests skip and any accidental dependency on them fails + # the build. The core is installed from this checkout, not PyPI, so the suite runs + # against the code in the pull request. + drift-without-signing: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,10 +28,10 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Run stdlib-only tests (no crypto packages) - working-directory: claude-code + - name: Run the drift suite without the signing packages run: | - pip install pytest + pip install ./packages/agentrust-capture-core pytest + cd claude-code python -m pytest tests -q # Full suite including the signing / verification tests, which need the crypto @@ -50,5 +51,5 @@ jobs: - name: Run full suite (with crypto packages) working-directory: claude-code run: | - pip install pytest -r requirements.txt + pip install ../packages/agentrust-capture-core pytest -r requirements.txt python -m pytest tests -q diff --git a/.github/workflows/copilot-tests.yml b/.github/workflows/copilot-tests.yml index d668daa..da33637 100644 --- a/.github/workflows/copilot-tests.yml +++ b/.github/workflows/copilot-tests.yml @@ -17,7 +17,7 @@ permissions: jobs: # Standard library only, so the composite action needs no install step. 3.9 is # the floor the shared core supports. - stdlib: + tests: runs-on: ubuntu-latest strategy: matrix: @@ -29,10 +29,10 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - - name: Run tests with no dependencies installed - working-directory: copilot + - name: Run the suite against the core in this checkout run: | - pip install pytest + pip install ./packages/agentrust-capture-core pytest + cd copilot python -m pytest tests -q # The check runs against this repository, which carries the surfaces it looks @@ -47,6 +47,8 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" + - name: Install the core from this checkout + run: pip install ./packages/agentrust-capture-core - name: Snapshot this repository's Copilot composition run: python copilot/engine/capture.py snapshot - name: Verify against the baseline, reporting without failing diff --git a/.github/workflows/scheduled-agents-tests.yml b/.github/workflows/scheduled-agents-tests.yml index 3c2ef56..1ebbac6 100644 --- a/.github/workflows/scheduled-agents-tests.yml +++ b/.github/workflows/scheduled-agents-tests.yml @@ -15,10 +15,10 @@ permissions: contents: read jobs: - # The SessionStart hook and drift check must work with the standard library - # alone. This job installs no crypto packages, so the signing tests skip and - # any accidental dependency on them fails the build. - stdlib: + # The drift path needs only agentrust-capture-core. This job installs no crypto + # packages, so the signing tests skip and any accidental dependency on them fails + # the build. The core comes from this checkout, not PyPI. + drift-without-signing: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,10 +27,10 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Run stdlib-only tests (no crypto packages) - working-directory: scheduled-agents + - name: Run the drift suite without the signing packages run: | - pip install pytest + pip install ./packages/agentrust-capture-core pytest + cd scheduled-agents python -m pytest tests -q # Full suite including the signing / verification tests, which need the crypto @@ -50,5 +50,5 @@ jobs: - name: Run full suite (with crypto packages) working-directory: scheduled-agents run: | - pip install pytest -r requirements.txt + pip install ../packages/agentrust-capture-core pytest -r requirements.txt python -m pytest tests -q diff --git a/claude-code/README.md b/claude-code/README.md index 4731625..2367921 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -39,7 +39,9 @@ I'd know within one session if it wasn't." ``` That's the whole install for drift detection. The SessionStart hook is -dependency-free (Python standard library only), so it never blocks a session. +needs one package, `agentrust-capture-core`, which itself has no dependencies, so +the install stays a single lightweight step rather than a tree. Without it the hook +tells you what to install rather than silently skipping the check. On your **first** session after install, it records your baseline and tells you: diff --git a/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md b/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md deleted file mode 100644 index 2c8b669..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/VENDORED.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index ef9c4e1..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index 67ddebe..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/compare.py +++ /dev/null @@ -1,119 +0,0 @@ -"""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 deleted file mode 100644 index 0b60284..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/hashing.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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 deleted file mode 100644 index e2629cc..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/report.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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 deleted file mode 100644 index 7b0f641..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/seal.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index 5a0839d..0000000 --- a/claude-code/engine/_vendor/agentrust_capture_core/state.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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, *, mode: int | None = None) -> 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. - - ``mode`` is applied to the temporary file before the replace, so the file is - never briefly readable at wider permissions than intended. Callers that write - a private key pass ``0o600``. Best-effort, since not every filesystem carries - POSIX permissions. - """ - 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()) - if mode is not None: - try: - os.chmod(tmp, mode) - except OSError: - pass - 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 3f5af3e..c32fd2e 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -39,15 +39,17 @@ 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. +# agentrust-capture-core is a declared dependency, not an optional one. A clear +# failure beats a vague one: the hook's own guard would otherwise report "integrity +# check skipped" and leave the user guessing, so say exactly what is missing. 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 +except ImportError as _exc: # pragma: no cover - install-time failure path + raise SystemExit( + "AgenTrust needs agentrust-capture-core, which is not installed.\n" + "Install it with: pip install agentrust-capture-core\n" + "Drift detection cannot run without it." + ) from _exc #: Version of WHAT this engine measures, distinct from what it found. #: diff --git a/claude-code/requirements.txt b/claude-code/requirements.txt index 840ccd3..e2d6936 100644 --- a/claude-code/requirements.txt +++ b/claude-code/requirements.txt @@ -1,5 +1,10 @@ -# The SessionStart hook and drift check (snapshot / verify / approve) use only -# the Python standard library -- nothing here is needed for them. +# REQUIRED for everything, including the SessionStart hook and the drift check. +# agentrust-capture-core carries the fingerprinting, comparison, sealing and report +# rules the engine is built on. It has no dependencies of its own, so this stays a +# single lightweight install rather than a tree. +agentrust-capture-core>=0.1,<0.2 + +# Signing only, below the core: # # These are required only to generate SIGNED records (/trace, /manifest approve # --sign): the Agent Manifest and the TRACE Trust Record. Verified against the diff --git a/copilot/README.md b/copilot/README.md index 45db20e..6d75992 100644 --- a/copilot/README.md +++ b/copilot/README.md @@ -130,9 +130,8 @@ python copilot/engine/capture.py verify # diff against the baseline, exit 1 python copilot/engine/capture.py approve # write the baseline ``` -No install step. The engine and its vendored copy of -[`agentrust-capture-core`](../packages/agentrust-capture-core) are standard library -only. +One dependency: [`agentrust-capture-core`](../packages/agentrust-capture-core), +which has none of its own. The action installs it before running the check. ## License diff --git a/copilot/action.yml b/copilot/action.yml index 5e71fe7..ce621b5 100644 --- a/copilot/action.yml +++ b/copilot/action.yml @@ -38,8 +38,12 @@ outputs: runs: using: composite steps: - # No install step. The engine and its vendored core are standard library - # only, which is the whole reason the core carries no dependencies. + # The engine imports agentrust-capture-core, which has no dependencies of its + # own, so this stays one small install rather than a tree. Pinned to a minor + # range so a core release cannot silently change what this check measures. + - shell: bash + run: pip install --quiet "agentrust-capture-core>=0.1,<0.2" + - id: check shell: bash run: | diff --git a/copilot/engine/_vendor/agentrust_capture_core/VENDORED.md b/copilot/engine/_vendor/agentrust_capture_core/VENDORED.md deleted file mode 100644 index 2c8b669..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/VENDORED.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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/copilot/engine/_vendor/agentrust_capture_core/__init__.py b/copilot/engine/_vendor/agentrust_capture_core/__init__.py deleted file mode 100644 index ef9c4e1..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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/copilot/engine/_vendor/agentrust_capture_core/compare.py b/copilot/engine/_vendor/agentrust_capture_core/compare.py deleted file mode 100644 index 67ddebe..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/compare.py +++ /dev/null @@ -1,119 +0,0 @@ -"""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/copilot/engine/_vendor/agentrust_capture_core/hashing.py b/copilot/engine/_vendor/agentrust_capture_core/hashing.py deleted file mode 100644 index 0b60284..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/hashing.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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/copilot/engine/_vendor/agentrust_capture_core/report.py b/copilot/engine/_vendor/agentrust_capture_core/report.py deleted file mode 100644 index e2629cc..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/report.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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/copilot/engine/_vendor/agentrust_capture_core/seal.py b/copilot/engine/_vendor/agentrust_capture_core/seal.py deleted file mode 100644 index 7b0f641..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/seal.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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/copilot/engine/_vendor/agentrust_capture_core/state.py b/copilot/engine/_vendor/agentrust_capture_core/state.py deleted file mode 100644 index 5a0839d..0000000 --- a/copilot/engine/_vendor/agentrust_capture_core/state.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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, *, mode: int | None = None) -> 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. - - ``mode`` is applied to the temporary file before the replace, so the file is - never briefly readable at wider permissions than intended. Callers that write - a private key pass ``0o600``. Best-effort, since not every filesystem carries - POSIX permissions. - """ - 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()) - if mode is not None: - try: - os.chmod(tmp, mode) - except OSError: - pass - 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/copilot/engine/capture.py b/copilot/engine/capture.py index 54f0a4d..7f3c0db 100644 --- a/copilot/engine/capture.py +++ b/copilot/engine/capture.py @@ -43,13 +43,17 @@ import sys from pathlib import Path -# Prefer the installed package; fall back to the pinned vendored copy, so the -# action works without an install step. +# agentrust-capture-core is a declared dependency, not an optional one. A clear +# failure beats a vague one: the hook's own guard would otherwise report "integrity +# check skipped" and leave the user guessing, so say exactly what is missing. 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 +except ImportError as _exc: # pragma: no cover - install-time failure path + raise SystemExit( + "AgenTrust needs agentrust-capture-core, which is not installed.\n" + "Install it with: pip install agentrust-capture-core\n" + "Drift detection cannot run without it." + ) from _exc VERSION = "0.1.0" diff --git a/copilot/requirements.txt b/copilot/requirements.txt new file mode 100644 index 0000000..d2c231d --- /dev/null +++ b/copilot/requirements.txt @@ -0,0 +1,8 @@ +# REQUIRED for everything, including the SessionStart hook and the drift check. +# agentrust-capture-core carries the fingerprinting, comparison, sealing and report +# rules the engine is built on. It has no dependencies of its own, so this stays a +# single lightweight install rather than a tree. +agentrust-capture-core>=0.1,<0.2 + +# The check emits no signed record, so nothing else is needed. See +# README.md for why, and agent-manifest#256 for the spec question behind it. diff --git a/packages/agentrust-capture-core/README.md b/packages/agentrust-capture-core/README.md index b50ad5e..8b625d5 100644 --- a/packages/agentrust-capture-core/README.md +++ b/packages/agentrust-capture-core/README.md @@ -29,7 +29,8 @@ 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 +the smallest possible install for a drift check, 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 diff --git a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md b/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md deleted file mode 100644 index 2c8b669..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/VENDORED.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index ef9c4e1..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index 67ddebe..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/compare.py +++ /dev/null @@ -1,119 +0,0 @@ -"""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 deleted file mode 100644 index 0b60284..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/hashing.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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 deleted file mode 100644 index e2629cc..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/report.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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 deleted file mode 100644 index 7b0f641..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/seal.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index 5a0839d..0000000 --- a/plugins/agentrust-codex/engine/_vendor/agentrust_capture_core/state.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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, *, mode: int | None = None) -> 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. - - ``mode`` is applied to the temporary file before the replace, so the file is - never briefly readable at wider permissions than intended. Callers that write - a private key pass ``0o600``. Best-effort, since not every filesystem carries - POSIX permissions. - """ - 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()) - if mode is not None: - try: - os.chmod(tmp, mode) - except OSError: - pass - 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/plugins/agentrust-codex/engine/capture.py b/plugins/agentrust-codex/engine/capture.py index ae6ad66..54b3f40 100644 --- a/plugins/agentrust-codex/engine/capture.py +++ b/plugins/agentrust-codex/engine/capture.py @@ -22,15 +22,17 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple -# Prefer the installed package; fall back to the pinned vendored copy. The hook -# runs before anything is installed, so the fallback is what makes drift detection -# work on a bare install. The copy is generated by scripts/sync_vendored_core.py -# and CI fails if it disagrees with the package. +# agentrust-capture-core is a declared dependency, not an optional one. A clear +# failure beats a vague one: the hook's own guard would otherwise report "integrity +# check skipped" and leave the user guessing, so say exactly what is missing. 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 +except ImportError as _exc: # pragma: no cover - install-time failure path + raise SystemExit( + "AgenTrust needs agentrust-capture-core, which is not installed.\n" + "Install it with: pip install agentrust-capture-core\n" + "Drift detection cannot run without it." + ) from _exc VERSION = "0.1.0" diff --git a/plugins/agentrust-codex/requirements.txt b/plugins/agentrust-codex/requirements.txt index 17e7d6f..d902b13 100644 --- a/plugins/agentrust-codex/requirements.txt +++ b/plugins/agentrust-codex/requirements.txt @@ -1,5 +1,10 @@ -# Snapshot, drift detection, and SessionStart use the Python standard library. -# Install these released packages only when generating signed records. +# REQUIRED for everything, including the SessionStart hook and the drift check. +# agentrust-capture-core carries the fingerprinting, comparison, sealing and report +# rules the engine is built on. It has no dependencies of its own, so this stays a +# single lightweight install rather than a tree. +agentrust-capture-core>=0.1,<0.2 + +# Signing only, below the core: install these to generate signed records. agent-manifest==0.3.0 agentrust-trace>=0.5 agentrust-trace-tests>=0.4,<0.5 diff --git a/scheduled-agents/README.md b/scheduled-agents/README.md index fc9ab73..9837180 100644 --- a/scheduled-agents/README.md +++ b/scheduled-agents/README.md @@ -25,7 +25,8 @@ you the moment any of them drifts from a baseline you approved: /plugin install agentrust-scheduled-agents ``` -The `SessionStart` hook is dependency-free (Python standard library only), so it +The `SessionStart` hook needs only `agentrust-capture-core`, which has no +dependencies of its own, so it never blocks a session. On first run it records a baseline. After that it stays quiet until something moves, then prints one line: diff --git a/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md b/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md deleted file mode 100644 index 2c8b669..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/VENDORED.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index ef9c4e1..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index 67ddebe..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/compare.py +++ /dev/null @@ -1,119 +0,0 @@ -"""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 deleted file mode 100644 index 0b60284..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/hashing.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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 deleted file mode 100644 index e2629cc..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/report.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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 deleted file mode 100644 index 7b0f641..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/seal.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index 5a0839d..0000000 --- a/scheduled-agents/engine/_vendor/agentrust_capture_core/state.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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, *, mode: int | None = None) -> 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. - - ``mode`` is applied to the temporary file before the replace, so the file is - never briefly readable at wider permissions than intended. Callers that write - a private key pass ``0o600``. Best-effort, since not every filesystem carries - POSIX permissions. - """ - 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()) - if mode is not None: - try: - os.chmod(tmp, mode) - except OSError: - pass - 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/capture.py b/scheduled-agents/engine/capture.py index fa3d5d9..d2d2b57 100644 --- a/scheduled-agents/engine/capture.py +++ b/scheduled-agents/engine/capture.py @@ -45,15 +45,17 @@ import time from pathlib import Path -# Prefer the installed package; fall back to the pinned vendored copy. The hook -# runs before anything is installed, so the fallback is what makes drift detection -# work on a bare install. The copy is generated by scripts/sync_vendored_core.py -# and CI fails if it disagrees with the package. +# agentrust-capture-core is a declared dependency, not an optional one. A clear +# failure beats a vague one: the hook's own guard would otherwise report "integrity +# check skipped" and leave the user guessing, so say exactly what is missing. 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 +except ImportError as _exc: # pragma: no cover - install-time failure path + raise SystemExit( + "AgenTrust needs agentrust-capture-core, which is not installed.\n" + "Install it with: pip install agentrust-capture-core\n" + "Drift detection cannot run without it." + ) from _exc CLAUDE_HOME = Path(os.path.expanduser("~")) / ".claude" STATE_DIR = CLAUDE_HOME / "agentrust" / "scheduled" diff --git a/scheduled-agents/requirements.txt b/scheduled-agents/requirements.txt index 084b32f..2323e62 100644 --- a/scheduled-agents/requirements.txt +++ b/scheduled-agents/requirements.txt @@ -1,5 +1,10 @@ -# Drift detection (snapshot / verify / approve) and the SessionStart hook use -# only the Python standard library -- nothing here is needed for them. +# REQUIRED for everything, including the SessionStart hook and the drift check. +# agentrust-capture-core carries the fingerprinting, comparison, sealing and report +# rules the engine is built on. It has no dependencies of its own, so this stays a +# single lightweight install rather than a tree. +agentrust-capture-core>=0.1,<0.2 + +# Signing only, below the core: # # Required only to sign a TRACE record (/schedule-trace, /schedule-manifest # approve --sign). Verified against the PyPI releases below in a clean virtualenv. diff --git a/scripts/sync_vendored_core.py b/scripts/sync_vendored_core.py deleted file mode 100644 index d4909ec..0000000 --- a/scripts/sync_vendored_core.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/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", - REPO / "copilot" / "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())