Skip to content

Commit 140be9b

Browse files
Merge pull request #66 from agentrust-io/feat/capture-core
2 parents b2fb598 + bc63be8 commit 140be9b

33 files changed

Lines changed: 3147 additions & 171 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: capture-core tests
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "packages/agentrust-capture-core/**"
7+
- "scripts/sync_vendored_core.py"
8+
- "**/_vendor/agentrust_capture_core/**"
9+
- ".github/workflows/capture-core-tests.yml"
10+
push:
11+
branches: [main]
12+
paths:
13+
- "packages/agentrust-capture-core/**"
14+
- "scripts/sync_vendored_core.py"
15+
- "**/_vendor/agentrust_capture_core/**"
16+
- ".github/workflows/capture-core-tests.yml"
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
# The engines run from shell hooks at session start, before anything is
23+
# installed, so the core must work on the standard library alone. 3.9 is the
24+
# floor because the scheduled-agents matrix tests it.
25+
core:
26+
runs-on: ubuntu-latest
27+
strategy:
28+
matrix:
29+
python-version: ["3.9", "3.11", "3.12", "3.13"]
30+
steps:
31+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32+
with:
33+
persist-credentials: false
34+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
35+
with:
36+
python-version: ${{ matrix.python-version }}
37+
- name: Run core tests
38+
working-directory: packages/agentrust-capture-core
39+
run: |
40+
pip install pytest
41+
python -m pytest tests -q
42+
43+
# Each engine keeps a pinned copy of the core so a bare plugin install still
44+
# gets drift detection. Copies are free to rot, which is the failure this whole
45+
# package exists to end, so they are generated and checked rather than trusted.
46+
vendored-in-sync:
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
50+
with:
51+
persist-credentials: false
52+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
53+
with:
54+
python-version: "3.12"
55+
- name: Vendored copies must match the package
56+
run: python scripts/sync_vendored_core.py --check
57+
58+
# The fallback is the path most users are on, since it is what runs before any
59+
# pip install. Exercising it explicitly stops it rotting behind the installed
60+
# path, which nothing else would catch.
61+
bare-install-fallback:
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
65+
with:
66+
persist-credentials: false
67+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
68+
with:
69+
python-version: "3.12"
70+
- name: Engines must import with the core NOT installed
71+
run: |
72+
python - <<'PY'
73+
import importlib.util, sys
74+
assert importlib.util.find_spec("agentrust_capture_core") is None, (
75+
"the core is installed; this job must test the vendored fallback"
76+
)
77+
for path in (
78+
"claude-code/engine/capture.py",
79+
"plugins/agentrust-codex/engine/capture.py",
80+
"scheduled-agents/engine/capture.py",
81+
):
82+
spec = importlib.util.spec_from_file_location("cap_" + path.replace("/", "_"), path)
83+
module = importlib.util.module_from_spec(spec)
84+
spec.loader.exec_module(module)
85+
print("ok:", path)
86+
PY
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Generated by scripts/sync_vendored_core.py. Do not edit.
2+
#
3+
# Pinned copy of agentrust-capture-core, used when the package is not installed.
4+
# The engines run from shell hooks before anything is installed, so this fallback
5+
# is what makes drift detection work on a bare plugin install. Edit
6+
# packages/agentrust-capture-core and re-run the sync script; CI fails if this
7+
# copy and the package disagree.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Shared core for AgenTrust agent-integrity capture engines.
2+
3+
Each engine answers one question about a different coding agent: is this the
4+
composition I approved, with nothing added and nothing subtracted? What differs
5+
between agents is where to look and what to call things. What must not differ is
6+
how content is fingerprinted, how snapshots are compared, how a baseline is sealed,
7+
and the rules that keep a report honest.
8+
9+
Those lived in three copies before this package existed, and the cost was not
10+
theoretical: the same skill-fingerprinting bypass had to be found and fixed twice,
11+
independently, and a reporting defect once. This package is the single source of
12+
truth for the parts that are genuinely identical.
13+
14+
Standard library only, because the engines run from shell hooks at session start
15+
and must work before anything is installed.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from .compare import (
21+
Change,
22+
diff_hash,
23+
diff_maps,
24+
diff_scalar,
25+
diff_sets,
26+
observed_categories,
27+
scope_change,
28+
)
29+
from .hashing import (
30+
EXCLUDE_DIRS,
31+
EXCLUDE_SUFFIXES,
32+
now_iso,
33+
safe_sha_file,
34+
sha_bytes,
35+
sha_file,
36+
sha_mapping,
37+
tree_digest,
38+
uuid7,
39+
)
40+
from .report import (
41+
UNMEASURED,
42+
change_lines,
43+
clean_verdict,
44+
measured_or,
45+
seal_section,
46+
unmeasured_footnote,
47+
)
48+
from .seal import (
49+
INTEGRITY_BROKEN,
50+
INTEGRITY_OK,
51+
INTEGRITY_UNSEALED,
52+
SEAL_FIELD,
53+
attach_seal,
54+
check_seal,
55+
state_digest,
56+
)
57+
from .state import StatePaths, atomic_write, load_state, save_baseline, save_state
58+
59+
__version__ = "0.1.0"
60+
61+
__all__ = [
62+
"Change",
63+
"EXCLUDE_DIRS",
64+
"EXCLUDE_SUFFIXES",
65+
"INTEGRITY_BROKEN",
66+
"INTEGRITY_OK",
67+
"INTEGRITY_UNSEALED",
68+
"SEAL_FIELD",
69+
"StatePaths",
70+
"UNMEASURED",
71+
"__version__",
72+
"atomic_write",
73+
"attach_seal",
74+
"change_lines",
75+
"check_seal",
76+
"clean_verdict",
77+
"diff_hash",
78+
"diff_maps",
79+
"diff_scalar",
80+
"diff_sets",
81+
"load_state",
82+
"measured_or",
83+
"now_iso",
84+
"observed_categories",
85+
"safe_sha_file",
86+
"save_baseline",
87+
"save_state",
88+
"scope_change",
89+
"seal_section",
90+
"sha_bytes",
91+
"sha_file",
92+
"sha_mapping",
93+
"state_digest",
94+
"tree_digest",
95+
"unmeasured_footnote",
96+
"uuid7",
97+
]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Comparison primitives, plus the two gates that keep a comparison honest.
2+
3+
Every engine's diff reduces to four shapes: a map of name to digest (components,
4+
instruction files, policy files), a set of names (tools, MCP servers), a scalar
5+
(model, permission mode), and a rollup hash. What differs between engines is which
6+
categories exist and what they are called, so those stay with the engine and the
7+
shapes live here.
8+
9+
Two gates matter more than the shapes.
10+
11+
**Observed gating.** A snapshot records which categories it actually measured. A
12+
shell hook cannot enumerate a live tool roster, so comparing a hook snapshot
13+
against a richer baseline would report the baseline's tools as removed. Only
14+
categories that BOTH sides measured are compared.
15+
16+
**Scope gating.** When an engine widens what a fingerprint covers, old fingerprints
17+
become incomparable. Without handling, an upgrade reports every affected component
18+
as changed. That is an alarm the user knows is false, which is worse than no alarm
19+
because it teaches them to dismiss the next one. So a scope mismatch is reported
20+
once, as a re-approval prompt, and the affected categories are dropped from the
21+
comparison rather than compared wrongly.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from collections.abc import Iterable, Mapping, Sequence
27+
28+
__all__ = [
29+
"Change",
30+
"diff_hash",
31+
"diff_maps",
32+
"diff_scalar",
33+
"diff_sets",
34+
"observed_categories",
35+
"scope_change",
36+
]
37+
38+
#: A single finding. ``change`` is one of added, removed, changed.
39+
Change = dict
40+
41+
42+
def _change(change: str, what: str, detail: str) -> Change:
43+
return {"change": change, "what": what, "detail": detail}
44+
45+
46+
def diff_maps(base: Mapping[str, str], current: Mapping[str, str], what: str) -> list[Change]:
47+
"""Compare two name-to-digest maps. Names are reported, digests are not.
48+
49+
A digest in a report tells the reader nothing they can act on; the name of the
50+
component that moved does.
51+
"""
52+
out: list[Change] = []
53+
for name in sorted(set(current) - set(base)):
54+
out.append(_change("added", what, name))
55+
for name in sorted(set(base) - set(current)):
56+
out.append(_change("removed", what, name))
57+
for name in sorted(set(base) & set(current)):
58+
if base[name] != current[name]:
59+
out.append(_change("changed", what, name))
60+
return out
61+
62+
63+
def diff_sets(base: Iterable[str], current: Iterable[str], what: str) -> list[Change]:
64+
"""Compare two name sets, for categories with no per-item digest."""
65+
before, after = set(base), set(current)
66+
out: list[Change] = []
67+
for name in sorted(after - before):
68+
out.append(_change("added", what, name))
69+
for name in sorted(before - after):
70+
out.append(_change("removed", what, name))
71+
return out
72+
73+
74+
def diff_scalar(before: object, after: object, what: str, *, unknown: str = "unknown") -> list[Change]:
75+
"""Compare a single value, reporting the transition rather than just the fact."""
76+
if before == after:
77+
return []
78+
return [_change("changed", what, "%s -> %s" % (before or unknown, after or unknown))]
79+
80+
81+
def diff_hash(before: str | None, after: str | None, what: str, detail: str) -> list[Change]:
82+
"""Compare a rollup hash, where only the fact of change is available."""
83+
if before == after:
84+
return []
85+
return [_change("changed", what, detail)]
86+
87+
88+
def observed_categories(
89+
base: Mapping[str, object],
90+
current: Mapping[str, object],
91+
default: Sequence[str] = (),
92+
) -> set[str]:
93+
"""Categories both snapshots measured, and therefore may be compared."""
94+
return set(base.get("observed", list(default))) & set(current.get("observed", list(default)))
95+
96+
97+
def scope_change(
98+
base: Mapping[str, object],
99+
current_scope: int,
100+
*,
101+
affected: Sequence[str],
102+
reason: str,
103+
) -> Change | None:
104+
"""Report a widened measurement scope, or None when the scopes agree.
105+
106+
``affected`` names the categories the caller must drop from its comparison,
107+
and is included in the message so the reader knows what was not checked rather
108+
than assuming everything was.
109+
"""
110+
base_scope = base.get("scope", 1)
111+
if base_scope == current_scope:
112+
return None
113+
dropped = ", ".join(affected) if affected else "none"
114+
return _change(
115+
"changed",
116+
"measurement scope",
117+
"widened from %s to %s; %s Not compared this run: %s. Re-approve once to "
118+
"compare on the new scope." % (base_scope, current_scope, reason, dropped),
119+
)

0 commit comments

Comments
 (0)