From db2fed41f8e3a98e3218ade4eea8d8eb12ad253c Mon Sep 17 00:00:00 2001 From: nmccready Date: Mon, 20 Jul 2026 00:22:48 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20full-context=20integrity=20lock=20?= =?UTF-8?q?=E2=80=94=20agents.lock=20+=20agents.sum=20with=20lock/verify?= =?UTF-8?q?=20(SPEC-008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `sync-agents lock` and `sync-agents verify`: a whole-tree integrity lock covering every artifact in every bucket, closing the provenance + tamper-detection gap that sources.lock leaves for local/imported/linked and non-source buckets. - agents.lock (provenance, YAML): one entry per artifact with path, bucket, name, origin (local | source: | linked: | imported:), resolved SHA, and SPEC-003 tree_hash. Path-sorted for minimal diffs; generated_at only advances when other bytes change. - agents.sum (integrity, go.sum-style): one line per file ` sha256:`, plus ` link:` for symlinks so a re-pointed link is caught. Sums sources.yaml/sources.lock/config and agents.lock itself (the sum is the single root of trust). - verify: sum + lock + coverage checks with the SPEC-008 severity policy (pulled/local edit = ERROR, unlocked file = ERROR, hand-edited lock = ERROR, linked drift = INFO/--strict ERROR, swapped symlink = ERROR). Exit 0 clean / 1 drift / 2 usage-IO; no lock = friendly no-op. --json stable schema, --strict, --explain . - Coverage honors OS-scoped subdirs regardless of host GOOS and excludes .quarantine/.sources/origin-metadata/unshared STATE_*.md. - ExitError type + main wiring for distinct process exit codes. - Docs: docs/integrity.md + README feature bullet. Implements SPEC-008 PR1 (writer + lock) and PR2 (verify). Auto-refresh of the lock from mutating commands (pull/update/approve/detach/add/ import/remove) is the remaining PR3 and is tracked in #73. Refs #73 Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- docs/integrity.md | 108 ++++ internal/agent/integrity.go | 929 +++++++++++++++++++++++++++++++ internal/agent/integrity_test.go | 477 ++++++++++++++++ main.go | 46 +- 5 files changed, 1563 insertions(+), 2 deletions(-) create mode 100644 docs/integrity.md create mode 100644 internal/agent/integrity.go create mode 100644 internal/agent/integrity_test.go diff --git a/README.md b/README.md index 9f620e4..f4c75a9 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ Remote installs are treated like a hostile supply chain. Fetched artifacts are s ### πŸ”— Linked (editable) sources β€” *new in 1.4.0* `npm link` for agent context: point a source at a live local checkout instead of a frozen snapshot. Edits flow both ways, `git pull` reaps updates, and the link is recorded declaratively so it survives clones. [Linked sources β†’](docs/linked-sources.md) +### πŸ” Full-context integrity lock β€” *new in 1.5.0* +`sync-agents lock` writes `agents.lock` (provenance β€” where every rule, skill, hook, and spec came from) + `agents.sum` (go.sum-style content hashes for the whole tree). `sync-agents verify` then proves the tree is exactly what was reviewed β€” a post-install edit to a pulled artifact, an injected file that skipped quarantine, or a swapped symlink all fail `verify --json` with a non-zero exit. PLEX-style drift detection for your AI context. [Integrity lock β†’](docs/integrity.md) + ### πŸ–₯️ OS-scoped routing Drop rules into `rules/macos/`, `rules/linux/`, or `rules/unix/` and they only sync on matching machines. One committed tree, no brew-rules noise on your Linux box. [OS routing β†’](docs/os-scoped-routing.md) @@ -98,7 +101,7 @@ The full manual lives in [`docs/`](docs/README.md): - [Command reference](docs/commands/README.md) β€” every command and flag - [Topology & configuration](docs/topology.md) β€” the `.agents/` tree, `config`, `STATE.md` -- [Sources, lockfile & provenance](docs/sources.md) Β· [Quarantine](docs/quarantine.md) Β· [Linked sources](docs/linked-sources.md) +- [Sources, lockfile & provenance](docs/sources.md) Β· [Quarantine](docs/quarantine.md) Β· [Linked sources](docs/linked-sources.md) Β· [Integrity lock](docs/integrity.md) - [Inheritance](docs/inheritance.md) Β· [ADRs](docs/adrs.md) Β· [OS-scoped routing](docs/os-scoped-routing.md) - [Architecture](docs/README.md#architecture) β€” scope resolution, semantic routing, global roots - [Examples](examples/README.md) β€” ready-to-import rules, skills, and workflows diff --git a/docs/integrity.md b/docs/integrity.md new file mode 100644 index 0000000..2a01cb2 --- /dev/null +++ b/docs/integrity.md @@ -0,0 +1,108 @@ +# Full-context integrity lock (`agents.lock` + `agents.sum`) + +`sources.lock` records only *remotely-sourced* manifest entries. +Locally-authored rules, hand-written skills, imports, linked checkouts, +hooks, agents, plans, specs and ADRs have no provenance record and no +post-install integrity check. The integrity lock (SPEC-008) closes that +gap for the **entire `.agents/` tree**. + +Two committed files live at the `.agents/` root: + +- **`agents.lock`** β€” *provenance* (YAML). One entry per artifact: + `path`, `bucket`, `name`, `origin`, resolved SHA (where one exists), + and the SPEC-003 `tree_hash`. Answers *where did this come from?* +- **`agents.sum`** β€” *integrity* (go.sum-style text). One line per + file, ` sha256:`, sorted and LF-joined. Answers *is it + still exactly what we locked?* A symlink contributes a + ` link:` line so a re-pointed link is caught even when + the target contents match. + +`agents.sum` also sums `sources.yaml`, `sources.lock`, the `.agents/` +`config`, and `agents.lock` itself β€” so the sum file is the single root +of trust, protected by git review like `go.sum`. + +## Origins + +Every artifact carries one of four origins: + +| origin | meaning | +|---|---| +| `local` | authored here β€” no manifest entry, no `_origin.json` | +| `source:` | pulled from a `sources.yaml` manifest entry (SPEC-003) | +| `linked:file:` | a live local checkout (SPEC-007 linked source) | +| `imported:` | a manual `import ` one-off | + +## Commands + +```bash +sync-agents lock [--global] [--dry-run] [--json] +``` + +Walks the tree, resolves each artifact's origin, computes hashes, and +writes both files (`agents.lock` first, then hashed into `agents.sum`). +Idempotent: an unchanged tree yields byte-identical files, and +`generated_at` only advances when some other byte changed. `--dry-run` +prints the added/removed/rehashed delta without writing. + +```bash +sync-agents verify [--global] [--strict] [--json] [--explain ] +``` + +Re-walks the tree and compares it against `agents.lock` + `agents.sum`. + +- **Exit codes:** `0` clean (INFO-only allowed), `1` on any + WARN/ERROR, `2` on usage/IO error. No lock on disk β‡’ exit `0` with a + `not locked` notice, so you can add `verify` to CI before adopting + the lock. +- **Severity policy:** a local edit to a *pulled* (`source:` / + `imported:`) artifact is an **ERROR** (supply-chain tamper signal); a + new file that skipped every gate is an **ERROR**; a hand-edited + `agents.lock` is an **ERROR** (caught via its sum line); drift in a + **linked** checkout is **INFO** by default (that's what links are + for) and escalates to ERROR under `--strict`; a swapped symlink is + always an ERROR. +- `--json` emits `{status, findings, counts}` β€” a stable schema for a + cron/CI drift sentinel. +- `--explain ` reports one artifact's origin, locked-vs-on-disk + hashes, and a per-file match/modified breakdown. + +## Security framing + +SPEC-005 quarantine gates **entry**: nothing reaches the live tree +without a scan verdict and a human approve. The integrity lock gates +**residence**: from the moment of approval, every byte is pinned in +`agents.sum`, so post-install tampering β€” a malicious edit, an injected +file, a swapped symlink, or an artifact hand-copied past quarantine β€” is +caught by the next `verify`. A scheduled `sync-agents verify --json` +(daily cron or per-PR CI) is PLEX-style drift detection for the full +AI-context picture, with the git history of `agents.lock`/`agents.sum` +as the audit trail. + +## CI / cron recipe + +```yaml +# Fail the build if the AI context drifted from what was reviewed. +- run: sync-agents verify --json --strict +``` + +```bash +# Daily drift sentinel (cron): page a human only on unexplained drift. +sync-agents verify --json || notify "agents/ integrity drift detected" +``` + +## Rollout & scope + +The feature is **inert until you run `sync-agents lock`** β€” with no +`agents.lock` on disk, `verify` is a friendly no-op. It ships additively +in the 1.x line. + +**Implemented:** the `lock` and `verify` commands (including `--json`, +`--strict`, `--explain`), the origin/hash writer, coverage of every +registered bucket (including OS-scoped subdirs), and the exclusion +rules (`.quarantine/`, `.sources/`, unshared `STATE_*.md`, origin +metadata). + +**Not yet automatic:** mutating commands (`pull`, `update`, `approve`, +`detach`, `add`, `import`, `source remove`) do **not** yet refresh the +lock as their final step β€” re-run `sync-agents lock` after a sync until +that integration lands. Tracked in the SPEC-008 issue. diff --git a/internal/agent/integrity.go b/internal/agent/integrity.go new file mode 100644 index 0000000..b5e2db7 --- /dev/null +++ b/internal/agent/integrity.go @@ -0,0 +1,929 @@ +package agent + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/brickhouse-tech/sync-agents/internal/agent/source" + "gopkg.in/yaml.v3" +) + +// integrity.go implements SPEC-008: the full-context integrity lock. +// +// Two machine-written, git-committed files sit at the .agents/ root +// and together answer, for every artifact in every bucket, the two +// supply-chain questions: +// +// - agents.lock (provenance, YAML) β€” where did each artifact come +// from? One entry per artifact: path, bucket, name, origin +// (local / source: / linked: / imported:), +// resolved SHA where one exists, and the SPEC-003 tree hash. +// - agents.sum (integrity, go.sum-style text) β€” is it still exactly +// what we locked? One line per file, " sha256:", plus a +// " link:" line for each symlink so a re-pointed +// link is caught even when target contents match. +// +// SPEC-003's sources.lock covers only remotely-sourced manifest +// entries; SPEC-008 closes the gap for locally-authored rules, +// hand-written skills, imports, linked checkouts, hooks, agents, +// plans, specs and ADRs, so a single `verify` proves the whole tree +// checks out. See specs/SPEC-008-context-integrity-lock.md. + +const ( + // AgentsLockFileName is the provenance record at the .agents/ root. + AgentsLockFileName = "agents.lock" + // AgentsSumFileName is the go.sum-style integrity record. + AgentsSumFileName = "agents.sum" + // integritySchemaVersion is the agents.lock schema this build + // writes and understands. + integritySchemaVersion = 1 +) + +// integrityNow returns lock-generation time; overridable in tests so +// the generated_at field is deterministic. +var integrityNow = func() time.Time { return time.Now().UTC() } + +// lockedArtifact is one entry in agents.lock. Field order controls +// YAML emission order, which keeps regeneration diffs stable. +type lockedArtifact struct { + Path string `yaml:"path"` + Bucket string `yaml:"bucket"` + Name string `yaml:"name"` + Origin string `yaml:"origin"` + ResolvedSHA string `yaml:"resolved_sha,omitempty"` + TreeHash string `yaml:"tree_hash"` +} + +// agentsLock is the parsed agents.lock document. +type agentsLock struct { + Version int `yaml:"version"` + GeneratedAt string `yaml:"generated_at"` + Artifacts []lockedArtifact `yaml:"artifacts"` +} + +// sumLine is one agents.sum record: a file path (relative to the +// .agents/ dir, forward slashes) and its value β€” "sha256:" for a +// regular file or "link:" for a symlink. +type sumLine struct { + Path string + Value string +} + +// integrityState is the whole-tree snapshot both `lock` and `verify` +// compute from disk, so the two commands can never disagree about what +// the tree contains. +type integrityState struct { + // artifacts, sorted lexicographically by Path. + artifacts []lockedArtifact + // sums covers every tree file plus the root inputs + // (sources.yaml/sources.lock/config); it excludes agents.lock and + // agents.sum (agents.lock is summed separately, agents.sum is the + // trust root and cannot contain itself). Sorted by Path. + sums []sumLine + // fileOrigin maps a sum Path to the origin of its owning artifact, + // or "" for root inputs β€” used to pick a drift severity. + fileOrigin map[string]string + // artFiles maps an artifact Path to its per-file sum lines, for + // `verify --explain`. + artFiles map[string][]sumLine +} + +// AgentsLockPath returns .agents/agents.lock for the given agents dir. +func AgentsLockPath(agentsDir string) string { + return filepath.Join(agentsDir, AgentsLockFileName) +} + +// AgentsSumPath returns .agents/agents.sum for the given agents dir. +func AgentsSumPath(agentsDir string) string { + return filepath.Join(agentsDir, AgentsSumFileName) +} + +// ExitError carries a specific process exit code up to main. `verify` +// distinguishes drift (1) from usage/IO failure (2); everything else +// exits 1 by default. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err == nil { + return "" + } + return e.Err.Error() +} + +// ExitCode reports the process exit code main should use. +func (e *ExitError) ExitCode() int { return e.Code } + +// --------------------------------------------------------------------- +// State computation (shared by lock + verify) +// --------------------------------------------------------------------- + +// computeIntegrityState walks the .agents/ tree per SPEC-008 Coverage +// rules and returns the artifacts + per-file sums. It never filters by +// host OS β€” the lock is a content record, not a routing record, so a +// teammate on another platform can still verify the files they don't +// sync (SPEC-008 Β§Coverage). +func computeIntegrityState(agentsDir string) (integrityState, error) { + st := integrityState{ + fileOrigin: map[string]string{}, + artFiles: map[string][]sumLine{}, + } + srcLock, _ := source.LoadLock(agentsDir) // best-effort; empty on absence + + for _, b := range Buckets { + bucketDir := filepath.Join(agentsDir, b.Dir) + entries, err := os.ReadDir(bucketDir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return integrityState{}, err + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") || source.IsOriginFile(name) { + continue + } + // OS-scoped subdir (SPEC-006): locked regardless of host OS, + // artifacts keep the subdir as a name prefix ("macos/brew"). + if e.IsDir() && isOSScopeDir(name) { + sub, err := os.ReadDir(filepath.Join(bucketDir, name)) + if err != nil { + return integrityState{}, err + } + for _, se := range sub { + if strings.HasPrefix(se.Name(), ".") || source.IsOriginFile(se.Name()) { + continue + } + if err := st.addArtifact(agentsDir, b, filepath.Join(bucketDir, name), se, name+"/", srcLock); err != nil { + return integrityState{}, err + } + } + continue + } + if err := st.addArtifact(agentsDir, b, bucketDir, e, "", srcLock); err != nil { + return integrityState{}, err + } + } + } + + // Root inputs: hashed but not artifacts (SPEC-008 Β§Coverage). config + // toggles quarantine, so it is security-relevant and summed too. + for _, root := range []string{source.ManifestFileName, source.LockFileName, "config"} { + full := filepath.Join(agentsDir, root) + if fi, err := os.Stat(full); err == nil && !fi.IsDir() { + sum, err := sha256File(full) + if err != nil { + return integrityState{}, err + } + st.sums = append(st.sums, sumLine{Path: root, Value: sum}) + st.fileOrigin[root] = "" + } + } + + sort.Slice(st.artifacts, func(i, j int) bool { return st.artifacts[i].Path < st.artifacts[j].Path }) + sort.Slice(st.sums, func(i, j int) bool { return st.sums[i].Path < st.sums[j].Path }) + return st, nil +} + +// addArtifact resolves one directory entry into a lockedArtifact plus +// its per-file sum lines and appends them to the state. ok-less: stray +// files (skill dir without SKILL.md, wrong extension) are silently +// skipped, matching DiscoverArtifacts. +func (st *integrityState) addArtifact(agentsDir string, b Bucket, dir string, e os.DirEntry, prefix string, srcLock source.Lock) error { + abs := filepath.Join(dir, e.Name()) + rel, err := filepath.Rel(agentsDir, abs) + if err != nil { + return err + } + relSlash := filepath.ToSlash(rel) + + if b.DirPerArtifact { + if !entryIsDir(abs, e) { + return nil + } + if _, err := os.Stat(filepath.Join(abs, "SKILL.md")); err != nil { + return nil + } + } else { + if entryIsDir(abs, e) || !strings.HasSuffix(e.Name(), b.FileExt()) { + return nil + } + } + + // STATE snapshots are per-engineer scratch unless they opt in with + // `shared: true` frontmatter (same convention as the AGENTS.md + // indexer). Applies to flat .md artifacts. + if !b.DirPerArtifact && strings.HasPrefix(e.Name(), "STATE_") && strings.HasSuffix(e.Name(), ".md") { + if !stateSnapshotIsShared(abs) { + return nil + } + } + + baseName := e.Name() + if !b.DirPerArtifact { + baseName = strings.TrimSuffix(baseName, b.FileExt()) + } + la := lockedArtifact{ + Path: relSlash, + Bucket: b.Dir, + Name: prefix + baseName, + } + + // Origin + tree hash + files. + isLink := e.Type()&fs.ModeSymlink != 0 + var files []sumLine + if isLink { + target, terr := os.Readlink(abs) + la.Origin = "linked:file:" + filepath.ToSlash(target) + // A repointed link must be detectable even when contents match: + // the symlink itself contributes a link: line. + files = append(files, sumLine{Path: relSlash, Value: "link:" + filepath.ToSlash(target)}) + resolved, rerr := filepath.EvalSymlinks(abs) + if terr == nil && rerr == nil { + th, herr := source.HashTree(resolved) + if herr != nil { + return herr + } + la.TreeHash = th + fl, ferr := fileSums(agentsDir, resolved, relSlash) + if ferr != nil { + return ferr + } + files = append(files, fl...) + } + } else { + la.Origin = resolveNonLinkOrigin(abs, b, &la) + th, herr := source.HashTree(abs) + if herr != nil { + return herr + } + la.TreeHash = th + fl, ferr := fileSums(agentsDir, abs, relSlash) + if ferr != nil { + return ferr + } + files = append(files, fl...) + } + + st.artifacts = append(st.artifacts, la) + st.artFiles[relSlash] = files + for _, f := range files { + st.sums = append(st.sums, f) + st.fileOrigin[f.Path] = la.Origin + } + return nil +} + +// resolveNonLinkOrigin classifies a non-symlink artifact by reading its +// SPEC-003 _origin.json (manifest β†’ source:, manual β†’ imported:), +// falling back to local. It also fills resolved_sha on la. +func resolveNonLinkOrigin(abs string, b Bucket, la *lockedArtifact) string { + o, err := source.ReadOriginFor(abs, b.DirPerArtifact) + if err != nil { + return "local" + } + la.ResolvedSHA = o.SHA + switch o.Source { + case source.SourceManifest: + return "source:" + reconstructEntry(b, o) + case source.SourceManual: + return "imported:" + reconstructBlobURL(o) + default: + return "local" + } +} + +// reconstructEntry rebuilds the SPEC-003 entry string an artifact was +// pulled from, from its origin metadata: :/[@ref][/path]. +func reconstructEntry(b Bucket, o source.Origin) string { + typ := entryPrefixForBucket(b) + s := typ + ":" + o.Owner + "/" + o.Repo + if o.Ref != "" { + s += "@" + o.Ref + } + if o.Path != "" { + s += "/" + o.Path + } + return s +} + +// reconstructBlobURL rebuilds a github blob URL from origin metadata, +// used for imported (manual) artifacts. +func reconstructBlobURL(o source.Origin) string { + ref := o.Ref + if ref == "" { + ref = "HEAD" + } + return fmt.Sprintf("https://github.com/%s/%s/blob/%s/%s", o.Owner, o.Repo, ref, o.Path) +} + +// entryPrefixForBucket maps a bucket to the SPEC-003 entry type prefix. +// Only rules/skills/workflows are pullable; other buckets never carry a +// source: origin, so their prefix is informational only. +func entryPrefixForBucket(b Bucket) string { + switch b.Dir { + case "rules": + return "rule" + case "skills": + return "skill" + case "workflows": + return "workflow" + default: + return string(b.Artifact) + } +} + +// fileSums returns the sha256 sum lines for every file under root +// (a single file or an artifact directory), keyed by relBase (the +// artifact's path relative to the .agents/ dir). Origin files and +// nested symlinks are excluded, matching HashTree. +func fileSums(agentsDir, root, relBase string) ([]sumLine, error) { + fi, err := os.Stat(root) + if err != nil { + return nil, err + } + if !fi.IsDir() { + sum, err := sha256File(root) + if err != nil { + return nil, err + } + return []sumLine{{Path: relBase, Value: sum}}, nil + } + var out []sumLine + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if d.Type()&fs.ModeSymlink != 0 || source.IsOriginFile(d.Name()) { + return nil + } + relIn, err := filepath.Rel(root, p) + if err != nil { + return err + } + sum, err := sha256File(p) + if err != nil { + return err + } + out = append(out, sumLine{Path: relBase + "/" + filepath.ToSlash(relIn), Value: sum}) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// sha256File returns "sha256:" over a file's raw bytes. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil +} + +// --------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------- + +// renderSum renders sum lines to the go.sum-style text body: one +// " \n" per line, path-sorted, LF-joined. +func renderSum(lines []sumLine) []byte { + sorted := append([]sumLine(nil), lines...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path }) + var b strings.Builder + for _, l := range sorted { + b.WriteString(l.Path) + b.WriteByte(' ') + b.WriteString(l.Value) + b.WriteByte('\n') + } + return []byte(b.String()) +} + +// --------------------------------------------------------------------- +// lock command +// --------------------------------------------------------------------- + +// CmdLock implements `sync-agents lock`: generate/refresh agents.lock + +// agents.sum. Idempotent β€” an unchanged tree yields byte-identical +// files, and generated_at is only advanced when some other byte moved. +func (a *App) CmdLock(opts SourceCmdOpts) error { + agentsDir := a.sourceAgentsDir(opts.Global) + st, err := computeIntegrityState(agentsDir) + if err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + + // Preserve generated_at when nothing else changed (keeps no-op runs + // diff-free). + generatedAt := integrityNow().Format(time.RFC3339) + if existing, err := loadAgentsLock(agentsDir); err == nil { + if artifactsEqual(existing.Artifacts, st.artifacts) { + generatedAt = existing.GeneratedAt + } + } + lock := agentsLock{Version: integritySchemaVersion, GeneratedAt: generatedAt, Artifacts: st.artifacts} + lockBytes, err := yaml.Marshal(lock) + if err != nil { + return &ExitError{Code: 2, Err: err} + } + + // The sum covers agents.lock itself (written first, then hashed into + // the sum) β€” the sum is the single root of trust. + sums := append([]sumLine(nil), st.sums...) + sums = append(sums, sumLine{Path: AgentsLockFileName, Value: sha256Bytes(lockBytes)}) + sumBytes := renderSum(sums) + + if a.DryRun { + a.reportLockDelta(agentsDir, st) + return nil + } + if err := writeFileAtomicAgent(AgentsLockPath(agentsDir), lockBytes); err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + if err := writeFileAtomicAgent(AgentsSumPath(agentsDir), sumBytes); err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + if opts.JSON { + byOrigin := map[string]int{"source": 0, "linked": 0, "imported": 0, "local": 0} + for _, la := range st.artifacts { + byOrigin[originClass(la.Origin)]++ + } + data, _ := json.MarshalIndent(map[string]any{ + "artifacts": len(st.artifacts), + "files": len(sums), + "origins": byOrigin, + }, "", " ") + fmt.Fprintln(a.Stdout, string(data)) + return nil + } + a.Info(lockSummary(st)) + return nil +} + +// reportLockDelta prints what a --dry-run lock would add/remove/rehash +// relative to the on-disk lock. +func (a *App) reportLockDelta(agentsDir string, st integrityState) { + existing, _ := loadAgentsLock(agentsDir) + old := map[string]lockedArtifact{} + for _, la := range existing.Artifacts { + old[la.Path] = la + } + cur := map[string]bool{} + for _, la := range st.artifacts { + cur[la.Path] = true + prev, ok := old[la.Path] + if !ok { + a.Info("+ " + la.Path + " (" + la.Origin + ")") + } else if prev.TreeHash != la.TreeHash { + a.Info("~ " + la.Path + " (rehashed)") + } + } + for _, la := range existing.Artifacts { + if !cur[la.Path] { + a.Info("- " + la.Path) + } + } + a.Info(lockSummary(st)) +} + +// lockSummary is the one-line "locked N artifacts (M files) …" report. +func lockSummary(st integrityState) string { + byOrigin := map[string]int{} + files := 0 + for _, la := range st.artifacts { + byOrigin[originClass(la.Origin)]++ + } + for _, s := range st.sums { + if !strings.HasPrefix(s.Value, "link:") { + files++ + } + } + buckets := map[string]bool{} + for _, la := range st.artifacts { + buckets[la.Bucket] = true + } + return fmt.Sprintf("locked %d artifacts (%d files) across %d buckets; %d source, %d linked, %d imported, %d local", + len(st.artifacts), files, len(buckets), + byOrigin["source"], byOrigin["linked"], byOrigin["imported"], byOrigin["local"]) +} + +// originClass reduces a full origin string to its leading class word. +func originClass(origin string) string { + if i := strings.IndexByte(origin, ':'); i >= 0 { + return origin[:i] + } + return origin +} + +// --------------------------------------------------------------------- +// verify command +// --------------------------------------------------------------------- + +// Finding is one verify result; JSON tags match the documented schema. +type Finding struct { + Path string `json:"path"` + Origin string `json:"origin"` + Severity string `json:"severity"` + Kind string `json:"kind"` + Want string `json:"want,omitempty"` + Got string `json:"got,omitempty"` +} + +// verifyResult is the --json envelope. +type verifyResult struct { + Status string `json:"status"` + Findings []Finding `json:"findings"` + Counts map[string]int `json:"counts"` +} + +const ( + sevError = "ERROR" + sevWarn = "WARN" + sevInfo = "INFO" +) + +// CmdVerify implements `sync-agents verify`. +func (a *App) CmdVerify(opts SourceCmdOpts, strict bool, explain string) error { + agentsDir := a.sourceAgentsDir(opts.Global) + + lockPath := AgentsLockPath(agentsDir) + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + a.Info("not locked β€” run 'sync-agents lock'") + return nil + } + stored, err := loadAgentsLock(agentsDir) + if err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + storedSum, err := loadAgentsSum(agentsDir) + if err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + st, err := computeIntegrityState(agentsDir) + if err != nil { + a.Error(err.Error()) + return &ExitError{Code: 2, Err: err} + } + + if explain != "" { + return a.explainOne(agentsDir, explain, stored, st) + } + + findings := verifyFindings(agentsDir, stored, storedSum, st, strict) + + if opts.JSON { + return a.emitVerifyJSON(findings) + } + return a.emitVerifyText(findings) +} + +// verifyFindings compares the stored lock+sum against the freshly +// walked tree and returns findings, most-severe ordering left to the +// caller. Pure (no I/O beyond hashing agents.lock) for testability. +func verifyFindings(agentsDir string, stored agentsLock, storedSum map[string]string, st integrityState, strict bool) []Finding { + var findings []Finding + fresh := map[string]string{} + for _, s := range st.sums { + fresh[s.Path] = s.Value + } + + // 1. Sum check β€” file-level content/coverage. + consumed := map[string]bool{} + for _, s := range st.sums { + want, ok := storedSum[s.Path] + if !ok { + // On disk, covered, but never locked = bypassed every gate. + findings = append(findings, Finding{ + Path: s.Path, Origin: st.fileOrigin[s.Path], Severity: sevError, + Kind: "unlocked-file", Got: s.Value, + }) + continue + } + consumed[s.Path] = true + if want == s.Value { + continue + } + origin := st.fileOrigin[s.Path] + kind := "content-mismatch" + sev := driftSeverity(origin, strict) + if strings.HasPrefix(want, "link:") || strings.HasPrefix(s.Value, "link:") { + // A swapped symlink is a tamper signal regardless of origin + // (SPEC-008 Β§Security framing lists it explicitly). + kind = "link-repointed" + sev = sevError + } + findings = append(findings, Finding{ + Path: s.Path, Origin: origin, Severity: sev, Kind: kind, Want: want, Got: s.Value, + }) + } + + // 2. Anything in the stored sum but not on disk. + for p, want := range storedSum { + if consumed[p] { + continue + } + if p == AgentsLockFileName { + got, err := sha256File(AgentsLockPath(agentsDir)) + if err != nil { + findings = append(findings, Finding{Path: p, Severity: sevError, Kind: "lock-missing"}) + } else if got != want { + // Hand-edited lock β€” the sum is the root of trust. + findings = append(findings, Finding{Path: p, Severity: sevError, Kind: "lock-tampered", Want: want, Got: got}) + } + continue + } + findings = append(findings, Finding{ + Path: p, Origin: originForStoredFile(stored, p), Severity: sevError, + Kind: "missing-file", Want: want, + }) + } + + // 3. Cross-check: a source: artifact's tree_hash must equal its + // sources.lock content_hash at lock time (SPEC-008 invariant). + srcLock, _ := source.LoadLock(agentsDir) + freshByPath := map[string]lockedArtifact{} + for _, la := range st.artifacts { + freshByPath[la.Path] = la + } + for _, la := range st.artifacts { + if !strings.HasPrefix(la.Origin, "source:") || la.ResolvedSHA == "" { + continue + } + for _, le := range srcLock.Entries { + if le.ResolvedSHA == la.ResolvedSHA && le.ContentHash != "" && le.ContentHash != la.TreeHash { + findings = append(findings, Finding{ + Path: la.Path, Origin: la.Origin, Severity: sevError, + Kind: "sources-lock-mismatch", Want: le.ContentHash, Got: la.TreeHash, + }) + } + } + } + + sortFindings(findings) + return findings +} + +// driftSeverity: a linked checkout drifts by design (INFO, ERROR under +// --strict); anything else is ERROR. +func driftSeverity(origin string, strict bool) string { + if strings.HasPrefix(origin, "linked:") { + if strict { + return sevError + } + return sevInfo + } + return sevError +} + +// originForStoredFile finds the origin of the artifact that owns a +// stored sum path (best-effort; "" for root inputs). +func originForStoredFile(stored agentsLock, p string) string { + best := "" + for _, la := range stored.Artifacts { + if la.Path == p || strings.HasPrefix(p, la.Path+"/") { + if len(la.Path) > len(best) { + best = la.Origin + } + } + } + return best +} + +// sortFindings orders ERROR β†’ WARN β†’ INFO, then by path, for a stable +// most-severe-first report. +func sortFindings(f []Finding) { + rank := map[string]int{sevError: 0, sevWarn: 1, sevInfo: 2} + sort.SliceStable(f, func(i, j int) bool { + if rank[f[i].Severity] != rank[f[j].Severity] { + return rank[f[i].Severity] < rank[f[j].Severity] + } + return f[i].Path < f[j].Path + }) +} + +// verifyExitError maps findings to the SPEC-008 exit-code policy: 0 +// clean (INFO allowed), 1 on any WARN/ERROR. +func verifyExitError(findings []Finding) error { + for _, f := range findings { + if f.Severity == sevError || f.Severity == sevWarn { + return &ExitError{Code: 1, Err: fmt.Errorf("verify failed: integrity drift detected")} + } + } + return nil +} + +func (a *App) emitVerifyText(findings []Finding) error { + if len(findings) == 0 { + a.Info("verify: clean β€” tree matches agents.lock + agents.sum") + return nil + } + for _, f := range findings { + msg := fmt.Sprintf("[%s] %s: %s", f.Severity, f.Kind, f.Path) + if f.Want != "" || f.Got != "" { + msg += fmt.Sprintf(" (want %s, got %s)", short(f.Want), short(f.Got)) + } + switch f.Severity { + case sevError: + a.Error(msg) + default: + a.Info(msg) + } + } + return verifyExitError(findings) +} + +func (a *App) emitVerifyJSON(findings []Finding) error { + counts := map[string]int{sevError: 0, sevWarn: 0, sevInfo: 0} + for _, f := range findings { + counts[f.Severity]++ + } + status := "clean" + if counts[sevError] > 0 || counts[sevWarn] > 0 { + status = "drift" + } + if findings == nil { + findings = []Finding{} + } + out := verifyResult{Status: status, Findings: findings, Counts: counts} + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return &ExitError{Code: 2, Err: err} + } + fmt.Fprintln(a.Stdout, string(data)) + return verifyExitError(findings) +} + +// explainOne answers "where did this come from and does it match?" for +// a single artifact path. +func (a *App) explainOne(agentsDir, target string, stored agentsLock, st integrityState) error { + target = filepath.ToSlash(strings.TrimSuffix(target, "/")) + var locked *lockedArtifact + for i := range stored.Artifacts { + if stored.Artifacts[i].Path == target { + locked = &stored.Artifacts[i] + break + } + } + if locked == nil { + a.Error(fmt.Sprintf("%s: not in agents.lock", target)) + return &ExitError{Code: 2, Err: fmt.Errorf("unknown artifact")} + } + var fresh *lockedArtifact + for i := range st.artifacts { + if st.artifacts[i].Path == target { + fresh = &st.artifacts[i] + break + } + } + out := a.Stdout + fmt.Fprintln(out, target) + fmt.Fprintf(out, " origin: %s\n", locked.Origin) + if locked.ResolvedSHA != "" { + fmt.Fprintf(out, " resolved_sha: %s\n", short(locked.ResolvedSHA)) + } + fmt.Fprintf(out, " locked: %s\n", locked.TreeHash) + if fresh == nil { + fmt.Fprintln(out, " on disk: MISSING") + return &ExitError{Code: 1, Err: fmt.Errorf("artifact missing")} + } + status := "match" + if fresh.TreeHash != locked.TreeHash { + status = "DRIFT" + } + fmt.Fprintf(out, " on disk: %s β†’ %s\n", fresh.TreeHash, status) + + // Per-file breakdown. + storedSum, _ := loadAgentsSum(agentsDir) + match, modified := 0, []string{} + for _, f := range st.artFiles[target] { + if storedSum[f.Path] == f.Value { + match++ + } else { + modified = append(modified, f.Path) + } + } + fmt.Fprintf(out, " files: %d match, %d modified", match, len(modified)) + if len(modified) > 0 { + fmt.Fprintf(out, " (%s)", strings.Join(modified, ", ")) + } + fmt.Fprintln(out) + return nil +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- + +func loadAgentsLock(agentsDir string) (agentsLock, error) { + data, err := os.ReadFile(AgentsLockPath(agentsDir)) + if err != nil { + return agentsLock{}, err + } + var l agentsLock + if err := yaml.Unmarshal(data, &l); err != nil { + return agentsLock{}, fmt.Errorf("%s: %w", AgentsLockPath(agentsDir), err) + } + return l, nil +} + +// loadAgentsSum parses agents.sum into a pathβ†’value map. +func loadAgentsSum(agentsDir string) (map[string]string, error) { + data, err := os.ReadFile(AgentsSumPath(agentsDir)) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + out := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + i := strings.IndexByte(line, ' ') + if i <= 0 { + continue + } + out[line[:i]] = strings.TrimSpace(line[i+1:]) + } + return out, nil +} + +func artifactsEqual(a, b []lockedArtifact) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func sha256Bytes(b []byte) string { + h := sha256.Sum256(b) + return fmt.Sprintf("sha256:%x", h) +} + +func short(s string) string { + s = strings.TrimPrefix(s, "sha256:") + if len(s) > 12 { + return s[:12] + "…" + } + return s +} + +// writeFileAtomicAgent writes via temp file + rename in the target's +// directory (mirrors concat.go's helper; kept local to avoid widening +// any package's API surface). +func writeFileAtomicAgent(target string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(target), filepath.Base(target)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return err + } + return os.Rename(tmpName, target) +} diff --git a/internal/agent/integrity_test.go b/internal/agent/integrity_test.go new file mode 100644 index 0000000..5c9c34a --- /dev/null +++ b/internal/agent/integrity_test.go @@ -0,0 +1,477 @@ +package agent + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/brickhouse-tech/sync-agents/internal/agent/source" +) + +// newIntegrityApp builds an App rooted at a fresh temp project with a +// .agents/ tree, returning the app, the agents dir, and its output +// buffers. +func newIntegrityApp(t *testing.T) (*App, string, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + root := t.TempDir() + agentsDir := filepath.Join(root, ".agents") + mustMkdir(t, agentsDir) + var out, errb bytes.Buffer + app := &App{ProjectRoot: root, Stdout: &out, Stderr: &errb} + return app, agentsDir, &out, &errb +} + +func mustMkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + mustMkdir(t, filepath.Dir(path)) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// writeRule drops a flat rule at rules/.md. +func writeRule(t *testing.T, agentsDir, name, body string) { + writeFile(t, filepath.Join(agentsDir, "rules", name+".md"), body) +} + +// writeSkill drops a dir skill at skills//SKILL.md (+ extra files). +func writeSkillDir(t *testing.T, agentsDir, name, skillBody string, extra map[string]string) { + dir := filepath.Join(agentsDir, "skills", name) + writeFile(t, filepath.Join(dir, "SKILL.md"), skillBody) + for rel, content := range extra { + writeFile(t, filepath.Join(dir, rel), content) + } +} + +func mustLock(t *testing.T, app *App) { + t.Helper() + if err := app.CmdLock(SourceCmdOpts{}); err != nil { + t.Fatalf("lock: %v", err) + } +} + +func TestLock_Deterministic(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "security", "# security\n") + writeSkillDir(t, agentsDir, "foo", "---\nname: foo\n---\nbody\n", map[string]string{"scripts/run.sh": "echo hi\n"}) + + mustLock(t, app) + lock1, _ := os.ReadFile(AgentsLockPath(agentsDir)) + sum1, _ := os.ReadFile(AgentsSumPath(agentsDir)) + + mustLock(t, app) + lock2, _ := os.ReadFile(AgentsLockPath(agentsDir)) + sum2, _ := os.ReadFile(AgentsSumPath(agentsDir)) + + if !bytes.Equal(lock1, lock2) { + t.Errorf("agents.lock not byte-identical across runs:\n---1---\n%s\n---2---\n%s", lock1, lock2) + } + if !bytes.Equal(sum1, sum2) { + t.Errorf("agents.sum not byte-identical across runs") + } +} + +func TestLock_Ordering(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + // Deliberately out-of-order names. + writeRule(t, agentsDir, "zebra", "z\n") + writeRule(t, agentsDir, "alpha", "a\n") + writeRule(t, agentsDir, "mango", "m\n") + mustLock(t, app) + + st, err := computeIntegrityState(agentsDir) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, la := range st.artifacts { + paths = append(paths, la.Path) + } + want := []string{"rules/alpha.md", "rules/mango.md", "rules/zebra.md"} + if strings.Join(paths, ",") != strings.Join(want, ",") { + t.Errorf("artifact order = %v, want %v", paths, want) + } + + sum, _ := os.ReadFile(AgentsSumPath(agentsDir)) + lines := strings.Split(strings.TrimSpace(string(sum)), "\n") + for i := 1; i < len(lines); i++ { + if lines[i-1] > lines[i] { + t.Errorf("agents.sum not sorted: %q > %q", lines[i-1], lines[i]) + } + } +} + +func TestLock_OriginResolution(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + + // local rule (no origin metadata) + writeRule(t, agentsDir, "state", "local\n") + + // source: skill (manifest _origin.json) + writeSkillDir(t, agentsDir, "vendored", "---\nname: vendored\n---\n", nil) + if err := source.WriteOriginFor(filepath.Join(agentsDir, "skills", "vendored"), true, source.Origin{ + Owner: "my-org", Repo: "agent-norms", Path: "skills/vendored", Ref: "main", + SHA: "abc123", Source: source.SourceManifest, + }); err != nil { + t.Fatal(err) + } + + // imported: skill (manual _origin.json) + writeSkillDir(t, agentsDir, "oneoff", "---\nname: oneoff\n---\n", nil) + if err := source.WriteOriginFor(filepath.Join(agentsDir, "skills", "oneoff"), true, source.Origin{ + Owner: "foo", Repo: "bar", Path: "skills/oneoff/SKILL.md", Ref: "main", + SHA: "def456", Source: source.SourceManual, + }); err != nil { + t.Fatal(err) + } + + mustLock(t, app) + st, err := computeIntegrityState(agentsDir) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, la := range st.artifacts { + got[la.Name] = la.Origin + } + if got["state"] != "local" { + t.Errorf("state origin = %q, want local", got["state"]) + } + if got["vendored"] != "source:skill:my-org/agent-norms@main/skills/vendored" { + t.Errorf("vendored origin = %q", got["vendored"]) + } + if !strings.HasPrefix(got["oneoff"], "imported:https://github.com/foo/bar/blob/main/") { + t.Errorf("oneoff origin = %q", got["oneoff"]) + } +} + +func TestLock_Exclusions(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "keep", "kept\n") + // quarantine holding area β€” never locked + writeFile(t, filepath.Join(agentsDir, ".quarantine", "rules", "evil.md"), "evil\n") + // origin file alongside a flat rule β€” excluded from sums + writeFile(t, filepath.Join(agentsDir, "rules", "keep.origin.json"), `{"schema":1}`) + // unshared STATE snapshot β€” excluded + writeFile(t, filepath.Join(agentsDir, "rules", "STATE_scratch.md"), "scratch\n") + // shared STATE snapshot β€” included + writeFile(t, filepath.Join(agentsDir, "rules", "STATE_task.md"), "---\nshared: true\n---\ntask\n") + + mustLock(t, app) + sum, _ := os.ReadFile(AgentsSumPath(agentsDir)) + s := string(sum) + for _, bad := range []string{".quarantine", "keep.origin.json", "STATE_scratch.md", AgentsSumFileName} { + if strings.Contains(s, bad) { + t.Errorf("agents.sum unexpectedly contains %q:\n%s", bad, s) + } + } + if !strings.Contains(s, "rules/STATE_task.md") { + t.Errorf("shared STATE snapshot missing from sum:\n%s", s) + } +} + +func TestLock_OSSubdirs(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + // Present regardless of host OS. + writeFile(t, filepath.Join(agentsDir, "rules", "macos", "brew.md"), "brew\n") + writeFile(t, filepath.Join(agentsDir, "rules", "windows", "choco.md"), "choco\n") + mustLock(t, app) + + st, err := computeIntegrityState(agentsDir) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, la := range st.artifacts { + names[la.Name] = true + } + if !names["macos/brew"] || !names["windows/choco"] { + t.Errorf("OS-scoped artifacts missing: %v", names) + } +} + +func TestSum_IncludesLockAndSources(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + writeFile(t, filepath.Join(agentsDir, source.ManifestFileName), "version: 1\nsources: []\n") + writeFile(t, filepath.Join(agentsDir, source.LockFileName), "version: 1\nentries: []\n") + mustLock(t, app) + + sum := loadSumMap(t, agentsDir) + for _, want := range []string{AgentsLockFileName, source.ManifestFileName, source.LockFileName} { + if _, ok := sum[want]; !ok { + t.Errorf("agents.sum missing line for %q", want) + } + } + if _, ok := sum[AgentsSumFileName]; ok { + t.Errorf("agents.sum must not contain itself") + } +} + +func TestVerify_Clean(t *testing.T) { + app, agentsDir, out, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + writeSkillDir(t, agentsDir, "s", "---\nname: s\n---\n", map[string]string{"a.txt": "a\n"}) + mustLock(t, app) + out.Reset() + + if err := app.CmdVerify(SourceCmdOpts{}, false, ""); err != nil { + t.Fatalf("verify should be clean, got %v", err) + } + if !strings.Contains(out.String(), "clean") { + t.Errorf("expected clean message, got: %s", out.String()) + } +} + +func TestVerify_LocalEdit_Error(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "original\n") + mustLock(t, app) + + writeRule(t, agentsDir, "r", "TAMPERED\n") + err := app.CmdVerify(SourceCmdOpts{}, false, "") + assertExit(t, err, 1) +} + +func TestVerify_UnlockedFile_Error(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + mustLock(t, app) + + writeRule(t, agentsDir, "injected", "surprise\n") + err := app.CmdVerify(SourceCmdOpts{}, false, "") + assertExit(t, err, 1) +} + +func TestVerify_MissingArtifact_Error(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + writeRule(t, agentsDir, "gone", "here\n") + mustLock(t, app) + + if err := os.Remove(filepath.Join(agentsDir, "rules", "gone.md")); err != nil { + t.Fatal(err) + } + err := app.CmdVerify(SourceCmdOpts{}, false, "") + assertExit(t, err, 1) +} + +func TestVerify_TamperedLock_Error(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + mustLock(t, app) + + // Hand-edit agents.lock so its bytes no longer match its sum line. + data, _ := os.ReadFile(AgentsLockPath(agentsDir)) + writeFile(t, AgentsLockPath(agentsDir), string(data)+"# sneaky\n") + err := app.CmdVerify(SourceCmdOpts{}, false, "") + assertExit(t, err, 1) +} + +func TestVerify_NoLock_Noop(t *testing.T) { + app, agentsDir, out, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + // no lock written + if err := app.CmdVerify(SourceCmdOpts{}, false, ""); err != nil { + t.Fatalf("no-lock verify should be a friendly no-op, got %v", err) + } + if !strings.Contains(out.String(), "not locked") { + t.Errorf("expected 'not locked' notice, got %s", out.String()) + } + _ = agentsDir +} + +func TestVerify_JSONSchema(t *testing.T) { + app, agentsDir, out, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "orig\n") + mustLock(t, app) + writeRule(t, agentsDir, "r", "changed\n") + out.Reset() + + err := app.CmdVerify(SourceCmdOpts{JSON: true}, false, "") + assertExit(t, err, 1) + + var res verifyResult + if jerr := json.Unmarshal(out.Bytes(), &res); jerr != nil { + t.Fatalf("json output not parseable: %v\n%s", jerr, out.String()) + } + if res.Status != "drift" { + t.Errorf("status = %q, want drift", res.Status) + } + if len(res.Findings) == 0 || res.Findings[0].Path != "rules/r.md" { + t.Errorf("findings = %+v", res.Findings) + } + if res.Counts[sevError] < 1 { + t.Errorf("expected an ERROR count, got %v", res.Counts) + } +} + +func TestExplain(t *testing.T) { + app, agentsDir, out, _ := newIntegrityApp(t) + writeSkillDir(t, agentsDir, "s", "---\nname: s\n---\n", map[string]string{"run.sh": "one\n"}) + mustLock(t, app) + // modify one file + writeFile(t, filepath.Join(agentsDir, "skills", "s", "run.sh"), "two\n") + out.Reset() + + _ = app.CmdVerify(SourceCmdOpts{}, false, "skills/s") + s := out.String() + if !strings.Contains(s, "origin:") || !strings.Contains(s, "DRIFT") || !strings.Contains(s, "run.sh") { + t.Errorf("explain output missing expected fields:\n%s", s) + } +} + +func TestVerify_StrictDoesNotBreakClean(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + mustLock(t, app) + if err := app.CmdVerify(SourceCmdOpts{}, true, ""); err != nil { + t.Fatalf("strict verify on clean tree should pass, got %v", err) + } +} + +// linkSkill creates an external skill checkout and symlinks it into +// skills/ with a relative target, returning the checkout dir. +func linkSkill(t *testing.T, root, agentsDir, name string) string { + t.Helper() + ext := filepath.Join(root, "ext", name) + writeFile(t, filepath.Join(ext, "SKILL.md"), "---\nname: "+name+"\n---\nlinked\n") + link := filepath.Join(agentsDir, "skills", name) + mustMkdir(t, filepath.Dir(link)) + // relative target from skills/ up to root/ext/ + if err := os.Symlink(filepath.Join("..", "..", "ext", name), link); err != nil { + t.Fatal(err) + } + return ext +} + +func TestLock_LinkedOrigin(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + linkSkill(t, app.ProjectRoot, agentsDir, "widget") + mustLock(t, app) + + st, err := computeIntegrityState(agentsDir) + if err != nil { + t.Fatal(err) + } + var origin string + for _, la := range st.artifacts { + if la.Name == "widget" { + origin = la.Origin + } + } + if !strings.HasPrefix(origin, "linked:file:") { + t.Errorf("widget origin = %q, want linked:file:…", origin) + } + // The symlink itself must contribute a link: line to the sum. + sum := loadSumMap(t, agentsDir) + if v, ok := sum["skills/widget"]; !ok || !strings.HasPrefix(v, "link:") { + t.Errorf("expected link: sum line for skills/widget, got %q", v) + } +} + +func TestVerify_LinkedDrift_InfoThenStrict(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + ext := linkSkill(t, app.ProjectRoot, agentsDir, "widget") + mustLock(t, app) + + // A linked checkout drifts by design. + writeFile(t, filepath.Join(ext, "SKILL.md"), "---\nname: widget\n---\nEDITED\n") + + if err := app.CmdVerify(SourceCmdOpts{}, false, ""); err != nil { + t.Fatalf("linked drift should be INFO (exit 0), got %v", err) + } + err := app.CmdVerify(SourceCmdOpts{}, true, "") + assertExit(t, err, 1) +} + +func TestVerify_RepointedLink_Error(t *testing.T) { + app, agentsDir, _, _ := newIntegrityApp(t) + linkSkill(t, app.ProjectRoot, agentsDir, "widget") + // A second checkout with identical content. + other := filepath.Join(app.ProjectRoot, "ext", "widget2") + writeFile(t, filepath.Join(other, "SKILL.md"), "---\nname: widget\n---\nlinked\n") + mustLock(t, app) + + // Re-point the symlink at the twin (same contents, different target). + link := filepath.Join(agentsDir, "skills", "widget") + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "..", "ext", "widget2"), link); err != nil { + t.Fatal(err) + } + err := app.CmdVerify(SourceCmdOpts{}, false, "") + assertExit(t, err, 1) +} + +func TestLock_DryRunAndJSON(t *testing.T) { + app, agentsDir, out, _ := newIntegrityApp(t) + writeRule(t, agentsDir, "r", "r\n") + mustLock(t, app) + + // dry-run after adding an artifact: reports a delta, writes nothing. + writeRule(t, agentsDir, "added", "new\n") + sumBefore, _ := os.ReadFile(AgentsSumPath(agentsDir)) + out.Reset() + app.DryRun = true + if err := app.CmdLock(SourceCmdOpts{}); err != nil { + t.Fatal(err) + } + app.DryRun = false + if !strings.Contains(out.String(), "rules/added.md") { + t.Errorf("dry-run should mention the added artifact, got: %s", out.String()) + } + sumAfter, _ := os.ReadFile(AgentsSumPath(agentsDir)) + if !bytes.Equal(sumBefore, sumAfter) { + t.Errorf("dry-run must not modify agents.sum") + } + + // JSON summary. + out.Reset() + if err := app.CmdLock(SourceCmdOpts{JSON: true}); err != nil { + t.Fatal(err) + } + var summary map[string]any + if err := json.Unmarshal(out.Bytes(), &summary); err != nil { + t.Fatalf("lock --json not parseable: %v\n%s", err, out.String()) + } + if summary["artifacts"] == nil { + t.Errorf("json summary missing artifacts count: %v", summary) + } +} + +// ---- helpers ---- + +func loadSumMap(t *testing.T, agentsDir string) map[string]string { + t.Helper() + m, err := loadAgentsSum(agentsDir) + if err != nil { + t.Fatal(err) + } + return m +} + +func assertExit(t *testing.T, err error, code int) { + t.Helper() + if err == nil { + t.Fatalf("expected exit-code %d error, got nil", code) + } + ee, ok := err.(*ExitError) + if !ok { + t.Fatalf("expected *ExitError, got %T: %v", err, err) + } + if ee.Code != code { + t.Fatalf("exit code = %d, want %d", ee.Code, code) + } +} diff --git a/main.go b/main.go index d89a1d7..b462ab5 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "path/filepath" @@ -407,6 +408,42 @@ func main() { }) rootCmd.AddCommand(sourceCmd) + // lock β€” SPEC-008: write agents.lock + agents.sum (full-context + // integrity lock over the whole .agents/ tree). + var lockGlobal, lockDryRun, lockJSON bool + lockCmd := &cobra.Command{ + Use: "lock", + Short: "Write agents.lock + agents.sum (full-context integrity lock)", + RunE: func(cmd *cobra.Command, args []string) error { + return app.CmdLock(agent.SourceCmdOpts{Global: lockGlobal, JSON: lockJSON}) + }, + } + lockCmd.Flags().BoolVar(&lockGlobal, "global", false, "Operate on the user-scope .agents/ tree") + lockCmd.Flags().BoolVar(&lockDryRun, "dry-run", false, "Print the would-be entry delta without writing") + lockCmd.Flags().BoolVar(&lockJSON, "json", false, "Machine-readable output") + lockCmd.PreRun = func(cmd *cobra.Command, args []string) { + if lockDryRun { + app.DryRun = true + } + } + rootCmd.AddCommand(lockCmd) + + // verify β€” SPEC-008: check the tree against agents.lock + agents.sum. + var verifyGlobal, verifyStrict, verifyJSON bool + var verifyExplain string + verifyCmd := &cobra.Command{ + Use: "verify", + Short: "Check the .agents/ tree against agents.lock + agents.sum", + RunE: func(cmd *cobra.Command, args []string) error { + return app.CmdVerify(agent.SourceCmdOpts{Global: verifyGlobal, JSON: verifyJSON}, verifyStrict, verifyExplain) + }, + } + verifyCmd.Flags().BoolVar(&verifyGlobal, "global", false, "Operate on the user-scope .agents/ tree") + verifyCmd.Flags().BoolVar(&verifyStrict, "strict", false, "Promote INFO (linked drift) to ERROR") + verifyCmd.Flags().BoolVar(&verifyJSON, "json", false, "Machine-readable findings for CI/agents") + verifyCmd.Flags().StringVar(&verifyExplain, "explain", "", "Explain origin + hashes for one artifact path") + rootCmd.AddCommand(verifyCmd) + // adr β€” transition Architecture Decision Records between statuses rootCmd.AddCommand(&cobra.Command{ Use: "adr ", @@ -646,7 +683,14 @@ func main() { fmt.Fprintf(os.Stderr, "[error] Unknown option: %s\n", flag) printUsage(rootCmd) } - os.Exit(1) + // Commands may request a specific exit code (e.g. `verify` + // returns 1 on drift, 2 on usage/IO error). + code := 1 + var ee *agent.ExitError + if errors.As(err, &ee) { + code = ee.ExitCode() + } + os.Exit(code) } }