Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/capture-core-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: capture-core tests

on:
pull_request:
paths:
- "packages/agentrust-capture-core/**"
- "scripts/sync_vendored_core.py"
- "**/_vendor/agentrust_capture_core/**"
- ".github/workflows/capture-core-tests.yml"
push:
branches: [main]
paths:
- "packages/agentrust-capture-core/**"
- "scripts/sync_vendored_core.py"
- "**/_vendor/agentrust_capture_core/**"
- ".github/workflows/capture-core-tests.yml"

permissions:
contents: read

jobs:
# The engines run from shell hooks at session start, before anything is
# installed, so the core must work on the standard library alone. 3.9 is the
# floor because the scheduled-agents matrix tests it.
core:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Run core tests
working-directory: packages/agentrust-capture-core
run: |
pip install pytest
python -m pytest tests -q

# Each engine keeps a pinned copy of the core so a bare plugin install still
# gets drift detection. Copies are free to rot, which is the failure this whole
# package exists to end, so they are generated and checked rather than trusted.
vendored-in-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Vendored copies must match the package
run: python scripts/sync_vendored_core.py --check

# The fallback is the path most users are on, since it is what runs before any
# pip install. Exercising it explicitly stops it rotting behind the installed
# path, which nothing else would catch.
bare-install-fallback:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Engines must import with the core NOT installed
run: |
python - <<'PY'
import importlib.util, sys
assert importlib.util.find_spec("agentrust_capture_core") is None, (
"the core is installed; this job must test the vendored fallback"
)
for path in (
"claude-code/engine/capture.py",
"plugins/agentrust-codex/engine/capture.py",
"scheduled-agents/engine/capture.py",
):
spec = importlib.util.spec_from_file_location("cap_" + path.replace("/", "_"), path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
print("ok:", path)
PY
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Generated by scripts/sync_vendored_core.py. Do not edit.
#
# Pinned copy of agentrust-capture-core, used when the package is not installed.
# The engines run from shell hooks before anything is installed, so this fallback
# is what makes drift detection work on a bare plugin install. Edit
# packages/agentrust-capture-core and re-run the sync script; CI fails if this
# copy and the package disagree.
97 changes: 97 additions & 0 deletions claude-code/engine/_vendor/agentrust_capture_core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Shared core for AgenTrust agent-integrity capture engines.

Each engine answers one question about a different coding agent: is this the
composition I approved, with nothing added and nothing subtracted? What differs
between agents is where to look and what to call things. What must not differ is
how content is fingerprinted, how snapshots are compared, how a baseline is sealed,
and the rules that keep a report honest.

Those lived in three copies before this package existed, and the cost was not
theoretical: the same skill-fingerprinting bypass had to be found and fixed twice,
independently, and a reporting defect once. This package is the single source of
truth for the parts that are genuinely identical.

Standard library only, because the engines run from shell hooks at session start
and must work before anything is installed.
"""

from __future__ import annotations

from .compare import (
Change,
diff_hash,
diff_maps,
diff_scalar,
diff_sets,
observed_categories,
scope_change,
)
from .hashing import (
EXCLUDE_DIRS,
EXCLUDE_SUFFIXES,
now_iso,
safe_sha_file,
sha_bytes,
sha_file,
sha_mapping,
tree_digest,
uuid7,
)
from .report import (
UNMEASURED,
change_lines,
clean_verdict,
measured_or,
seal_section,
unmeasured_footnote,
)
from .seal import (
INTEGRITY_BROKEN,
INTEGRITY_OK,
INTEGRITY_UNSEALED,
SEAL_FIELD,
attach_seal,
check_seal,
state_digest,
)
from .state import StatePaths, atomic_write, load_state, save_baseline, save_state

__version__ = "0.1.0"

__all__ = [
"Change",
"EXCLUDE_DIRS",
"EXCLUDE_SUFFIXES",
"INTEGRITY_BROKEN",
"INTEGRITY_OK",
"INTEGRITY_UNSEALED",
"SEAL_FIELD",
"StatePaths",
"UNMEASURED",
"__version__",
"atomic_write",
"attach_seal",
"change_lines",
"check_seal",
"clean_verdict",
"diff_hash",
"diff_maps",
"diff_scalar",
"diff_sets",
"load_state",
"measured_or",
"now_iso",
"observed_categories",
"safe_sha_file",
"save_baseline",
"save_state",
"scope_change",
"seal_section",
"sha_bytes",
"sha_file",
"sha_mapping",
"state_digest",
"tree_digest",
"unmeasured_footnote",
"uuid7",
]
119 changes: 119 additions & 0 deletions claude-code/engine/_vendor/agentrust_capture_core/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Comparison primitives, plus the two gates that keep a comparison honest.

Every engine's diff reduces to four shapes: a map of name to digest (components,
instruction files, policy files), a set of names (tools, MCP servers), a scalar
(model, permission mode), and a rollup hash. What differs between engines is which
categories exist and what they are called, so those stay with the engine and the
shapes live here.

Two gates matter more than the shapes.

**Observed gating.** A snapshot records which categories it actually measured. A
shell hook cannot enumerate a live tool roster, so comparing a hook snapshot
against a richer baseline would report the baseline's tools as removed. Only
categories that BOTH sides measured are compared.

**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints
become incomparable. Without handling, an upgrade reports every affected component
as changed. That is an alarm the user knows is false, which is worse than no alarm
because it teaches them to dismiss the next one. So a scope mismatch is reported
once, as a re-approval prompt, and the affected categories are dropped from the
comparison rather than compared wrongly.
"""

from __future__ import annotations

from collections.abc import Iterable, Mapping, Sequence

__all__ = [
"Change",
"diff_hash",
"diff_maps",
"diff_scalar",
"diff_sets",
"observed_categories",
"scope_change",
]

#: A single finding. ``change`` is one of added, removed, changed.
Change = dict


def _change(change: str, what: str, detail: str) -> Change:
return {"change": change, "what": what, "detail": detail}


def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]:
"""Compare two name-to-digest maps. Names are reported, digests are not.

A digest in a report tells the reader nothing they can act on; the name of the
component that moved does.
"""
out: list[Change] = []
for name in sorted(set(current) - set(base)):
out.append(_change("added", what, name))
for name in sorted(set(base) - set(current)):
out.append(_change("removed", what, name))
for name in sorted(set(base) & set(current)):
if base[name] != current[name]:
out.append(_change("changed", what, name))
return out


def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]:
"""Compare two name sets, for categories with no per-item digest."""
before, after = set(base), set(current)
out: list[Change] = []
for name in sorted(after - before):
out.append(_change("added", what, name))
for name in sorted(before - after):
out.append(_change("removed", what, name))
return out


def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]:
"""Compare a single value, reporting the transition rather than just the fact."""
if before == after:
return []
return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))]


def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]:
"""Compare a rollup hash, where only the fact of change is available."""
if before == after:
return []
return [_change("changed", what, detail)]


def observed_categories(
base: Mapping[str, object],
current: Mapping[str, object],
default: Sequence[str] = (),
) -> set[str]:
"""Categories both snapshots measured, and therefore may be compared."""
return set(base.get("observed", list(default))) & set(current.get("observed", list(default)))


def scope_change(
base: Mapping[str, object],
current_scope: int,
*,
affected: Sequence[str],
reason: str,
) -> Change | None:
"""Report a widened measurement scope, or None when the scopes agree.

``affected`` names the categories the caller must drop from its comparison,
and is included in the message so the reader knows what was not checked rather
than assuming everything was.
"""
base_scope = base.get("scope", 1)
if base_scope == current_scope:
return None
dropped = ", ".join(affected) if affected else "none"
return _change(
"changed",
"measurement scope",
"widened from %s to %s; %s Not compared this run: %s. Re-approve once to "
"compare on the new scope." % (base_scope, current_scope, reason, dropped),
)
Loading