From 0ecd32d10343c23cd3546ec24d2bd0a9be130541 Mon Sep 17 00:00:00 2001 From: nmccready Date: Thu, 9 Jul 2026 15:52:26 -0400 Subject: [PATCH] =?UTF-8?q?docs(spec):=20SPEC-008=20=E2=80=94=20full-conte?= =?UTF-8?q?xt=20integrity=20lock=20(agents.lock=20+=20agents.sum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft spec for a whole-tree provenance + integrity layer over .agents/: agents.lock records where every artifact comes from (local / source / linked / imported), agents.sum is the go.sum-style per-file content ground truth. New lock/verify commands; verify --json as a scheduled drift sentinel closing the loop with SPEC-005 quarantine. Co-Authored-By: Claude Fable 5 --- specs/SPEC-008-context-integrity-lock.md | 446 +++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 specs/SPEC-008-context-integrity-lock.md diff --git a/specs/SPEC-008-context-integrity-lock.md b/specs/SPEC-008-context-integrity-lock.md new file mode 100644 index 0000000..01ebf8f --- /dev/null +++ b/specs/SPEC-008-context-integrity-lock.md @@ -0,0 +1,446 @@ +--- +id: SPEC-008 +title: "Full-context integrity lock: agents.lock + agents.sum for the whole .agents/ tree" +status: Draft +owner: nmccready +created: 2026-07-09 +updated: 2026-07-09 +related: SPEC-003, SPEC-005, SPEC-007 +--- + +# [SPEC-008] Feature: Full-context integrity lock (`agents.lock` + `agents.sum`) + +## Overview + +Add a **full-tree integrity lock** for `.agents/`: two machine-written, +git-committed files that together answer, for *every* artifact in every +bucket, the two questions that matter for an AI-context supply chain: + +1. **Where did this come from?** → `agents.lock` — the *provenance* + record. One entry per artifact: name, bucket, origin + (`local` / `source:` / `linked:` / + `imported:`), and the resolved SHA where one exists. +2. **Is it still exactly what we locked?** → `agents.sum` — the + *content* ground truth. go.sum-style: one line per file, + ` sha256:`, sorted, LF-joined, trivially diffable and + trivially verifiable. + +SPEC-003's `sources.lock` already does this — but **only for +remotely-sourced entries** declared in `sources.yaml`. Locally-authored +rules, hand-written skills, `import`ed one-offs, SPEC-007 linked +checkouts, hooks, agents, plans, specs, and ADRs are invisible to it. +SPEC-008 closes that gap: the lock pair covers the **entire AI-context +picture**, so a single `sync-agents verify` proves (or disproves) that +what the harnesses are loading is what was reviewed and locked. + +> Verbatim intent: "there should be a lock file which encapsulates +> local, rules, skills… everything, to identify where everything is +> coming from. Ideally this would have an associated sum file so +> checksums check out — a full integrity lock of the AI context +> picture." + +## Motivation + +- **Provenance blind spots.** `sources.lock` records only + manifest-declared remote entries. A rule dropped in by hand, a skill + vendored two years ago, or a hostile file *added* to `.agents/rules/` + by a compromised tool has no record anywhere. "Where did + `rules/deploy.md` come from?" is currently unanswerable for most of + the tree. +- **Tamper detection stops at install time.** SPEC-005 quarantine + + scan gates *entry* into the live tree; SPEC-003 content hashes detect + drift *of manifest entries* at the next `pull`. Nothing detects a + post-install edit to a local rule, an injected file inside an + approved skill dir, or a new artifact that never went through any + gate. The `agents.sum` file is the missing continuous-integrity + layer: hash everything once at lock time, verify anytime. +- **Agents audit agents.** A daily `sync-agents verify --json` is + PLEX-style drift detection for the whole AI-context picture — a cron + or CI job can diff "what the context is" against "what the context + was approved to be" and page a human only on unexplained drift. + +## Separate lock, or extend `sources.lock`? (decision) + +**Decision: separate files — `agents.lock` + `agents.sum` at the +`.agents/` root — not an extension of `sources.lock`.** + +Considered extending `sources.lock` with synthetic entries for local +artifacts. Rejected: + +- **Different owner, different lifecycle.** `sources.lock` is written + entry-at-a-time by the pull pipeline and is meaningless without + `sources.yaml`; `agents.lock` is a whole-tree snapshot regenerated by + `lock`/`verify`-adjacent operations and exists even in a repo with no + `sources.yaml` at all. Merging them makes every local edit churn the + file the pull pipeline owns, and makes "sources.lock exists" ambiguous + between "we pull from upstream" and "we merely lock local files". +- **Different failure semantics.** A stale `sources.lock` entry means + "re-pull"; a stale `agents.lock` entry means "tamper or unrecorded + edit". Overloading one file overloads the operator's mental model. +- **The go.mod/go.sum analogy** (which motivates the two-file split + within SPEC-008 itself): Go keeps *resolution* (`go.mod` — what you + asked for and what version was chosen) apart from *integrity* + (`go.sum` — flat, append-friendly, machine-checked hashes). We mirror + that: `agents.lock` is provenance (rich, structured, YAML — humans + read it to answer "where from?"), `agents.sum` is content (flat, + line-per-file, text — machines diff and verify it, and a one-line + change in review screams exactly which file moved). Collapsing hashes + into the lock would make integrity review mean reading YAML diffs of + nested structures; a `.sum` line change is unmissable. +- `sources.lock` stays exactly as it is; `agents.lock` **references** + its entries (via `origin: source:`) rather than duplicating + them. Layering: `sources.yaml → sources.lock` answers "what upstream + resolved to what"; `agents.lock/agents.sum` sits above and answers + "what is the whole tree and does it check out". + +## Goals + +- `sync-agents lock` generates/refreshes `.agents/agents.lock` + + `.agents/agents.sum` covering every artifact in every registered + bucket (the `Buckets` registry — rules, skills, workflows, agents, + plans, specs, hooks, adrs — plus root-level context files, see + Coverage). +- `sync-agents verify` re-walks the tree, compares against lock + sum, + exits non-zero on drift, and supports `--json` for CI/agent + consumption and `--explain ` for "where did this come from and + does it match?". +- Every artifact entry carries an **origin**: + `local` (authored here), `source:` (SPEC-003 manifest pull), + `linked:` (SPEC-007 link), `imported:` (manual + `import`), with `resolved_sha` where the origin has one. +- Deterministic output: same tree ⇒ byte-identical lock and sum files, + on every OS. Regeneration produces minimal, reviewable git diffs. +- `pull`, `update`, `approve`, `detach`, `add`, `remove`, and `import` + refresh the affected entries automatically once the lock exists — + the lock never goes stale through normal sync-agents operations. +- Local edits to a *pulled* artifact are an **ERROR** (tamper signal); + drift in a *linked* artifact is **INFO** by default (it's a live + checkout — drift is its job) and escalates only under `--strict`. + +## Non-goals + +- **Signing.** sha256 pinning only, same posture as SPEC-003. A future + sigstore layer composes on top of `agents.sum` naturally. +- **Auto-remediation.** `verify` reports; it never rewrites the tree. + (Re-locking after an intentional edit is the explicit `lock` command + — the analogue of `go mod tidy` after editing go.mod.) +- **Locking harness-side copies.** We lock `.agents/` (the source of + truth); `.claude/`, `.cursor/` etc. are derived by `sync` and already + covered by symlinking back to the locked tree. +- **Replacing `sources.lock`.** It remains the resolution record for + the pull pipeline; SPEC-008 consumes it, never edits it. + +## File formats + +Both files live at the `.agents/` root, next to `sources.yaml` / +`sources.lock`, and are **committed to git** (that's the point — review +the diff, then trust the verify). + +### `agents.lock` (provenance — YAML, structured) + +```yaml +version: 1 +generated_at: "2026-07-09T14:00:00Z" # informational; excluded from determinism guarantees below it +artifacts: + - path: rules/security.md # relative to .agents/, forward slashes — the sort key + bucket: rules + name: security + origin: "source:rule:my-org/agent-norms@main/rules/security.md" + resolved_sha: "abc1234567890abcdef…" # from sources.lock / _origin.json + tree_hash: "sha256:1f3a…" # SPEC-003 HashTree over the artifact + - path: rules/state.md + bucket: rules + name: state + origin: "local" # no _origin.json, no manifest entry + tree_hash: "sha256:9c2b…" + - path: skills/foo-skill + bucket: skills + name: foo-skill + origin: "linked:file:../foo-skill" # SPEC-007 link override + resolved_sha: "9f3a…" # checkout HEAD at lock time — informational + tree_hash: "sha256:77aa…" # hash of resolved target files AT LOCK TIME + - path: skills/one-off + bucket: skills + name: one-off + origin: "imported:https://github.com/foo/bar/blob/main/skills/one-off/SKILL.md" + resolved_sha: "" + tree_hash: "sha256:41de…" +``` + +- **Ordering:** artifacts sorted lexicographically by `path` + (forward-slash, byte order). Deterministic across platforms; a + one-artifact change is a one-hunk diff. +- **Origin resolution precedence** (per artifact): + 1. `sources.lock` has a `link` record whose target is this artifact + → `linked:`. + 2. `sources.lock` / `_origin.json` with `source: "manifest"` → + `source:` (+ `resolved_sha` from the lock). + 3. `_origin.json` with `source: "manual"` → `imported:` + (URL reconstructed from owner/repo/path/ref; `resolved_sha` = its + `sha` when recorded). + 4. No origin metadata → `local`. +- `tree_hash` reuses SPEC-003's `HashTree` (sorted per-file manifest, + origin files excluded) — one artifact = one hash, matching what + `sources.lock`/`_origin.json` already record for pulled entries, so + a pulled artifact's `tree_hash` in `agents.lock` MUST equal its + `content_hash` in `sources.lock` at lock time (cross-check is a + `verify` invariant). + +### `agents.sum` (integrity — plain text, go.sum-style) + +One line per **file**, not per artifact — the finest verification +grain, so `verify` can name the exact file that moved: + +``` +rules/security.md sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +rules/state.md sha256:aa11… +skills/foo-skill/SKILL.md sha256:bb22… +skills/foo-skill/scripts/run.sh sha256:cc33… +sources.lock sha256:dd44… +sources.yaml sha256:ee55… +agents.lock sha256:ff66… +``` + +- Format: ` sha256:\n` — path relative to `.agents/`, + forward slashes, lexicographically sorted, LF-only, no trailing + metadata. Parseable with `cut`/`awk`; diff-reviewable line by line. +- Hash is sha256 of raw file bytes (no normalization — same rule as + SPEC-003 `hash.go`: the recorded hash must verify against the real + tree). +- **`sources.yaml` and `sources.lock` are themselves summed** — tamper + with the source manifest or its lock and `verify` catches it. +- **`agents.lock` is summed too** (written first, then hashed into the + sum) — the sum file is the single root of trust; a hand-edited lock + fails verification. `agents.sum` cannot contain itself + (chicken-and-egg) — it is the trust root, protected by git review + like `go.sum`. +- Symlinked (linked) artifacts: files are hashed **through** the + symlink (resolved target contents at lock time). The symlink itself + contributes a ` link:` line so a re-pointed + link is detected even when target contents match. + +### Coverage & exclusions + +Included: + +- Every artifact in every registered bucket (`Buckets` registry), + **including OS-scoped subdirs** (`macos/`, `linux/`, `unix/`, + `windows/` per SPEC-006). The lock is a *content* record, not a + *routing* record — a Linux teammate must still be able to verify the + `macos/` files they don't sync. `verify` therefore needs no OS gate. +- Root-level managed context files: `sources.yaml`, `sources.lock` + (sum only — they are inputs, not artifacts, so no lock entries). + +Excluded (never locked, never summed, never flagged): + +- `.quarantine/` — pre-approval holding area by definition + (SPEC-005); locking it would launder unapproved content into the + trust record. +- `.sources/` — SPEC-007 managed clones (live git checkouts; their + linked *artifacts* are covered via the symlink rules above). +- `STATE_*.md` snapshots and legacy `STATE.md` **without** + `shared: true` frontmatter — per-engineer scratch, same convention + the AGENTS.md indexer already applies. A snapshot that opts in via + `shared: true` IS locked (origin `local`). +- `_origin.json` / `*.origin.json` — provenance metadata already + excluded from `HashTree`; their content is reflected in + `agents.lock` origins instead. +- `.findings.json`, editor droppings (`.DS_Store`), and anything + matched by existing gitignore-managed patterns for `.agents/`. + +## Commands + +### `sync-agents lock` + +``` +sync-agents lock [--global] [--dry-run] [--json] +``` + +- Walks `.agents/` per Coverage rules, resolves each artifact's origin, + computes tree + per-file hashes, writes `agents.lock` then + `agents.sum` (atomic writes, lock hashed into sum). +- Idempotent: unchanged tree ⇒ byte-identical files except + `generated_at` (which is therefore updated **only when any other + byte changed**, keeping no-op runs diff-free). +- `--dry-run` prints the would-be entry delta (added / removed / + rehashed) without writing. +- Prints a one-line summary: `locked 34 artifacts (212 files) across 6 + buckets; 2 source, 1 linked, 1 imported, 30 local`. + +### `sync-agents verify` + +``` +sync-agents verify [--global] [--strict] [--json] [--explain ] +``` + +- Recomputes the walk and compares three ways: + 1. **sum check** — every `agents.sum` line matches the file on disk; + every on-disk covered file has a line. + 2. **lock check** — every lock artifact exists, its `tree_hash` + matches, and (for `source:` origins) matches `sources.lock`'s + `content_hash`. + 3. **coverage check** — no covered artifact is missing from the lock. +- **Findings and severities:** + + | condition | severity | rationale | + |---|---|---| + | file hash mismatch, origin `source:`/`imported:` | **ERROR** | local edit to pulled content = tamper signal (ClawHub supply-chain posture; the sanctioned paths are `update` or `detach`) | + | file hash mismatch, origin `local` | **ERROR** | unrecorded edit — re-run `lock` after review | + | file/artifact present on disk, absent from lock | **ERROR** | injected content that bypassed every gate | + | artifact in lock, missing on disk | **ERROR** | deleted without `remove`/re-lock | + | drift in a `linked:` artifact (content or `resolved_sha`) | **INFO** (ERROR under `--strict`) | a live checkout drifts by design; hash is a point-in-time record | + | dangling link symlink | **WARN** | mirrors SPEC-007 `pull` behavior | + | `agents.lock` hash ≠ its `agents.sum` line | **ERROR** | hand-edited lock | + +- **Exit codes:** `0` clean (INFO-only allowed), `1` any WARN/ERROR + (`--strict` promotes INFO to ERROR), `2` usage/IO error. No lock + present ⇒ exit `0` with a notice (`not locked — run 'sync-agents + lock'`), so adding `verify` to CI before adopting the lock is safe. +- `--json` emits `{status, findings: [{path, origin, severity, kind, + want, got}], counts}` — stable schema for cron/agent consumers. +- `--explain ` answers Nick's question for one artifact/file: + + ``` + $ sync-agents verify --explain skills/foo-skill + skills/foo-skill + origin: linked:file:../foo-skill (SPEC-007 link, user checkout) + resolved_sha: 9f3a… (checkout HEAD at lock time; now 41bc…) + locked: sha256:77aa… 2026-07-09T14:00:00Z + on disk: sha256:81fe… → DRIFT (INFO: linked sources are live) + files: 3 match, 1 modified (scripts/run.sh) + ``` + +### Automatic refresh (integration) + +Once `agents.lock` exists, mutating commands refresh the affected +entries (and rewrite the sum) as their final step: + +- `pull` / `update` — re-lock entries whose artifacts were (re)fetched; + `resolved_sha`/`tree_hash` updated from the fresh `sources.lock`. +- `approve` — promotion out of quarantine adds/updates the artifact's + entry (this is the moment SPEC-005's gate verdict becomes a locked + hash). +- `detach` — origin flips `source:…`/`linked:…` → `local` (or + `imported:` when origin metadata is kept), hashes recomputed over the + materialized copy. +- `add` (local scaffold), `import`, `source remove` — entry + added/updated/removed respectively. +- No lock file ⇒ all of the above are no-ops (see Rollout). + +`sync` does **not** verify by default (it must stay fast and offline); +a `sync --verify` opt-in and a documented CI/cron recipe cover the +gate-on-drift use case. + +## Security framing (closing the SPEC-005 loop) + +SPEC-005 gates **entry**: quarantine + static scan means nothing +reaches the live tree without a scan verdict and a human approve. +SPEC-008 gates **residence**: from the moment of approval, every byte +in the tree is pinned in `agents.sum`, so post-install tampering — +malicious edit, injected file, swapped symlink, or an artifact that +sidestepped quarantine entirely (hand-copied into `.agents/`) — is +detected by the next `verify`. + +Together: *quarantine proves it was safe when it entered; the sum file +proves it hasn't changed since.* A scheduled `sync-agents verify +--json` (daily cron or CI on every PR) is PLEX-style drift detection +for the full AI-context picture, with git history of +`agents.lock`/`agents.sum` as the audit trail of every approved change. + +Trust boundaries, explicitly: + +- `agents.sum` is the root of trust and is protected by git review — + an attacker who can silently rewrite the sum in a committed PR has + already won the repo; that threat is out of scope (same stance as + go.sum). +- `verify` reads the tree and the two lock files only; it makes no + network calls (offline-safe, cron-safe). +- Severity policy encodes the posture: pulled content drifting locally + is ERROR (supply-chain tamper signal), linked checkouts drifting is + INFO (that's what links are for), unlocked files appearing is ERROR + (the gate was bypassed). + +## Test plan (Go + bats) + +Go unit tests (`internal/agent/source` + a new `integrity.go` or +`internal/agent/lock` package): + +- [ ] `TestLock_Deterministic` — same tree, two runs ⇒ byte-identical + lock + sum (modulo `generated_at` rule). +- [ ] `TestLock_Ordering` — artifacts and sum lines lexicographically + sorted; inserting an artifact produces a single-hunk diff. +- [ ] `TestLock_OriginResolution` — fixtures for all four origins + (manifest `_origin.json`, manual `_origin.json`, SPEC-007 link + record, bare local file) map to the right `origin:` strings. +- [ ] `TestLock_Exclusions` — `.quarantine/`, `.sources/`, unshared + `STATE_*.md`, origin files, `.findings.json` absent from both + files; `shared: true` STATE snapshot present. +- [ ] `TestLock_OSSubdirs` — `rules/macos/brew.md` locked and summed + regardless of host GOOS. +- [ ] `TestSum_IncludesLockAndSources` — `agents.lock`, + `sources.yaml`, `sources.lock` lines present; `agents.sum` line + absent. +- [ ] `TestVerify_Clean` — untouched tree ⇒ exit 0, empty findings. +- [ ] `TestVerify_PulledEdit_Error` — edit a `source:` artifact file ⇒ + ERROR, exit 1, finding names the file. +- [ ] `TestVerify_UnlockedFile_Error` — drop a new file into + `rules/` ⇒ ERROR (coverage check). +- [ ] `TestVerify_MissingArtifact_Error` — delete a locked artifact ⇒ + ERROR. +- [ ] `TestVerify_LinkedDrift_InfoThenStrict` — modify a linked + checkout ⇒ INFO + exit 0; `--strict` ⇒ ERROR + exit 1. +- [ ] `TestVerify_RepointedLink` — same content, different symlink + target ⇒ finding (link line mismatch). +- [ ] `TestVerify_TamperedLock` — hand-edit `agents.lock` ⇒ ERROR via + its sum line. +- [ ] `TestVerify_NoLock_Noop` — no lock file ⇒ exit 0 + notice. +- [ ] `TestVerify_JSONSchema` — `--json` output round-trips; stable + field names. +- [ ] `TestExplain` — origin, both hashes, and per-file breakdown for + each origin class. +- [ ] `TestRefresh_OnMutatingCommands` — pull/update/approve/detach/ + add/import/remove each leave lock+sum consistent (verify exits 0 + immediately after). +- [ ] Cross-check invariant: pulled artifact's lock `tree_hash` == + `sources.lock` `content_hash`. + +Bats integration: + +- [ ] End-to-end: init → add local rule → source add remote skill → + approve → `lock` → `verify` (0) → tamper pulled file → `verify` + (1, ERROR) → `update` → `verify` (0). +- [ ] Linked flow: `source add --link` → `lock` → edit checkout → + `verify` (0, INFO) → `verify --strict` (1). +- [ ] Portability grep gate: no absolute paths in either file (same + gate as SPEC-007). + +## Rollout + +Additive and backwards-compatible; ships in 1.x (standing no-2.0 +decision): + +1. **Inert until adopted.** No `agents.lock` on disk ⇒ `verify` is a + friendly no-op, mutating commands skip refresh, nothing changes for + existing users. The feature activates on the first explicit + `sync-agents lock`. +2. PR 1 — lock/sum writer + `lock` command + determinism tests. +3. PR 2 — `verify` (+ `--json`, `--strict`, `--explain`) + severity + policy. +4. PR 3 — mutating-command refresh hooks + bats end-to-end. +5. README "Integrity lock" section + documented CI/cron recipe + (`sync-agents verify --json` as a scheduled drift sentinel). +6. CHANGELOG: `feat: full-context integrity lock — agents.lock + + agents.sum with lock/verify commands (SPEC-008)`. + +## Open questions + +- **Q1**: Should `verify` gain `--fix` sugar (re-run `lock` for + INFO-only drift, e.g. linked entries) or stay strictly read-only? + Lean read-only in v1; `lock` is one command away. +- **Q2**: Root-level `AGENTS.md`-adjacent files inside `.agents/` + (e.g. `config`) — sum them too? Lean yes for `config` (it toggles + quarantine — security-relevant), defer decision to implementation PR. +- **Q3**: `generated_at` in the lock at all? It aids audit but is the + only nondeterministic byte. Current answer: keep, with the + "only-touch-when-content-changed" rule; drop entirely if it proves + noisy.