feat(doctor): tamper-evident skills manifest + freshness guard (#159)

skills/ ships fat-markdown files that agents execute as instructions, but
nothing detected silent edits to them (#159). This adds lightweight tamper
EVIDENCE (a committed sha256 manifest), not a signature system:

- skills/skills.lock.json — committed manifest mapping every bundled file
  under skills/ to its sha256 (sorted relative paths, deterministic JSON,
  trailing newline; excludes itself from its own hash set).
- src/core/skills-integrity.ts — pure, unit-testable core: compute /
  render / verify(dir, manifest) -> { modified, missing, extra }.
- scripts/generate-skills-manifest.ts — regenerator
  (bun run scripts/generate-skills-manifest.ts).
- gbrain doctor check `skills_manifest_integrity` (SKILL group) — WARNS on
  drift, never fails or blocks; fail-safe ok/skip when no manifest is
  present at the resolved skills dir (user workspaces, compiled-binary
  installs far from the repo).
- scripts/check-skills-manifest-fresh.sh — CI freshness guard modeled on
  check-eval-glossary-fresh.sh; wired as `check:skills-manifest` in
  package.json and into bun run verify (run-verify-parallel.sh).
- test/skills-integrity.test.ts — clean / modified / missing / extra /
  self-exclusion / deterministic-render, over tmp-dir fixtures.

Docs: regeneration workflow in docs/TESTING.md + a KEY_FILES.md entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-27 16:19:41 -07:00
co-authored by Claude Fable 5
parent 032af6e5f7
commit f489243159
11 changed files with 423 additions and 0 deletions
+9
View File
@@ -46,6 +46,15 @@ If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the
- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them.
- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each).
### Skills-manifest freshness guard
`skills/skills.lock.json` is a committed sha256 inventory of every bundled file under
`skills/` (tamper evidence, not signatures — see `src/core/skills-integrity.ts`).
Any change under `skills/` must regenerate it: `bun run scripts/generate-skills-manifest.ts`.
`scripts/check-skills-manifest-fresh.sh` (`bun run check:skills-manifest`, wired into
`bun run verify`) regenerates to a tmp file and diffs, failing CI on drift; at runtime
`gbrain doctor` reports the same drift as a warn-only `skills_manifest_integrity` check.
### Test-isolation lint and helpers
The cross-file flake class is enforced statically by `scripts/check-test-isolation.sh`, wired into `bun run verify` and `bun run check:all`. Rules (non-serial unit files only; `*.serial.test.ts` and `test/e2e/*` are skipped):
+1
View File
@@ -146,6 +146,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/core/skill-catalog.ts` — host-repo skill catalog backing the MCP `list_skills` / `get_skill` ops. Lets a thin MCP client (Codex desktop, Claude Code, Claude Cowork, Perplexity) DISCOVER + FOLLOW the agent repo's fat-markdown skills over `gbrain serve` — a skill is prose, so "using" one = fetching its body then calling the gbrain MCP tools the server already exposes. Read-scope, NOT localOnly (defensible only via the full mitigation stack): (1) **publish gate**`assertPublishEnabled(ctx, publishSkills)`; remote callers require `mcp.publish_skills === true`, default-OFF so an upgrade never silently grants existing read tokens host-skill read; local callers (`ctx.remote === false`) always pass. (2) **path confinement**`assertSkillNameShape` rejects separators/`..`/null/space before any FS access; the client `name` is a manifest LOOKUP KEY (via `loadOrDeriveManifest`), never a raw path segment; `confineManifestPath` does realpath + relative-containment + `SKILL.md`-regular-file check on EVERY entry (defeats poisoned manifest.json `path`, symlink/`..` escape). (3) **frontmatter allowlist**`GetSkillResult.frontmatter` projects a safe subset; private `writes_to` + `sources` dropped. (4) **prose-only + 256KB cap** (`MAX_SKILL_MD_BYTES`, env `GBRAIN_MAX_SKILL_MD_BYTES`), size-checked twice (statSync + UTF-8 byte length). (5) **no install_path serve for remote** — remote callers use `autoDetectSkillsDir` (no install-path tier) so a hosted gbrain with no agent repo returns `storage_error`; local callers use `autoDetectSkillsDirReadOnly`. (6) MCP rate-limiter caps call rate. Config reads honor BOTH planes: `readMcpPublishSkills` / `readMcpSkillsDir` prefer the DB plane (`engine.getConfig`) over the file plane (`ctx.config.mcp`). Tool-honesty: `crossReferenceTools(declared, ctx)` splits a skill's declared `tools:` into `usable_tools` vs `unavailable_tools`; `buildSkillCatalog`'s `instructions` envelope (`SKILL_CATALOG_INSTRUCTIONS`) carries the "these are prose, follow-then-call-tools" protocol. Skills are host-filesystem repo-global — `sourceScopeOpts(ctx)` / `ctx.brainId` deliberately do NOT apply. `buildSkillCatalog` is resilient (one malformed/escaping skill is skipped, never throws). Config keys in `src/core/config.ts`: `GBrainConfig.mcp?: { publish_skills?, skills_dir? }` + `KNOWN_CONFIG_KEYS` entries `mcp.publish_skills`/`mcp.publish_skills_prompted`/`mcp.skills_dir` + `mcp.` prefix in `KNOWN_CONFIG_KEY_PREFIXES`. `src/commands/init.ts` writes `config.mcp = { publish_skills: true, ... }` for new installs (existing config wins on re-init). `src/commands/upgrade.ts:runPostUpgrade` adds a one-time consent prompt (gated by `mcp.publish_skills_prompted`; existing installs stay OFF until owner opts in). Two ops register in `src/core/operations.ts` (`list_skills` with optional `section` filter + `cliHints:{name:'skills'}`; `get_skill` taking `name` + `cliHints:{name:'skill', positional:['name']}`) and dynamically import this module to avoid the import cycle (skill-catalog statically imports the `operations` array). Descriptions in `src/core/operations-descriptions.ts` (`LIST_SKILLS_DESCRIPTION`, `GET_SKILL_DESCRIPTION`, `SKILL_CATALOG_INSTRUCTIONS`, `SKILL_CLIENT_GUIDANCE`), pinned by `test/operations-descriptions.test.ts`. CLI: `gbrain skills` / `gbrain skill <name>`. Pinned by `test/skill-catalog.test.ts`, `test/skill-catalog-security.test.ts` (path-confinement / poisoned-manifest / symlink-escape), `test/skill-catalog-transports.test.ts` (publish-gate + remote-vs-local) over `test/fixtures/skill-catalog/`.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts `conventions/quality.md` and `_brain-filing-rules.md`). `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. `parseResolverEntries` accepts BOTH the markdown table AND a compact list format (`- **skill-name**: trigger1 | trigger2 | trigger3` or `- skill-name: trigger1 | trigger2`); shapes can mix in one file, folded by the multi-resolver merge. Skill name MUST be kebab-lowercase (regex `[a-z][a-z0-9-]+`) so prose bullets like `- **Note**:`/`- **Convention**:`/`- **TODO**:` don't false-match as skill rows. `skillPath` is ALWAYS derived as `skills/<name>/SKILL.md`: an optional `→ \`skills/path\`` (or ASCII `->`) suffix is stripped from the trigger but NOT honored as the path — two consumers (`routing-eval.ts:skillSlugFromPath`, the manifest lookup) assume the convention; use the table format for non-conventional paths. Multi-trigger rows fan out to one entry per trigger sharing the same `skillPath`; `checkResolvable` dedupes so the reachability count counts each skill once. Pinned by `test/check-resolvable.test.ts` (11 cases: bold+plain forms, Unicode+ASCII suffix strip, ellipsis filter, empty pipe segments, mixed shapes, prose-bullet rejection) + `test/check-resolvable-openclaw-compact.test.ts` (8 cases over `test/fixtures/openclaw-compact-resolver/` and `test/fixtures/openclaw-mixed-merge/`). Tutorial: `docs/guides/scaling-skills.md` (three-tier scaling: ~300-skill agent to ~4K tokens/turn from ~25K).
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)`: walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency, imported by `doctor.ts` and `check-resolvable.ts`; parameterized `startDir` makes tests hermetic. Read-path / write-path split: `autoDetectSkillsDir` (shared, read+write-safe) has tier-0 `$GBRAIN_SKILLS_DIR` operator override ahead of the 4-tier chain. `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) stay on the shared function so install-from-`~` can't retarget the bundled gbrain `skills/` instead of the user's workspace. `SkillsDirSource` variants `'env_explicit'`, `'install_path'`; `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'`.
- `src/core/skills-integrity.ts` — Tamper-evidence manifest for the bundled `skills/` tree (#159); NOT a signature system. Pure functions over `node:crypto` sha256: `computeSkillsManifest(dir)` (recursive, sorted '/'-relative paths, excludes the manifest itself, skips symlinks), `renderSkillsManifest(dir)` (2-space JSON + trailing newline, deterministic), `verifySkillsManifest(dir, manifest)``{modified, missing, extra}`. Committed manifest lives at `skills/skills.lock.json` (`SKILLS_MANIFEST_FILENAME`); regenerate via `bun run scripts/generate-skills-manifest.ts`. Consumers: the warn-only `skills_manifest_integrity` doctor check in `src/commands/doctor.ts` (ok/skip when no manifest is present — user workspaces and compiled-binary installs are not drift) and the CI freshness guard `scripts/check-skills-manifest-fresh.sh` (`bun run check:skills-manifest`, in `bun run verify`). Pinned by `test/skills-integrity.test.ts`.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error. `--fix` runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing. AGENTS.md workspaces resolve natively (see `src/core/resolver-filenames.ts`). `DEFERRED[]` is empty. Resolver lookup is the multi-file merge in `src/core/check-resolvable.ts` — entries collected from every `RESOLVER.md`/`AGENTS.md` across the skills dir AND its parent, deduped by `skillPath` (first occurrence wins). Uses `autoDetectSkillsDirReadOnly` so `cd ~ && gbrain check-resolvable` finds bundled skills via the install-path fallback; `--fix` carries the same install-path safety gate (refuses to write when `detected.source === 'install_path'`).
- `src/core/resolver-filenames.ts` — central list of accepted routing filenames (`RESOLVER.md`, `AGENTS.md`). Shared by `findRepoRoot`, `check-resolvable`, and skillpack install so every code path walks the same fallback chain.
- `src/commands/skillify.ts` + `src/core/skillify/{generator,templates}.ts``gbrain skillify scaffold <name>` creates all stubs for a new skill: SKILL.md, script, tests, routing-eval.jsonl, resolver entry, filing-rules pointer. `gbrain skillify check <script>` runs the 10-step checklist (LLM evals, routing evals, check-resolvable gate, filing audit) against a candidate skill before it lands.
+1
View File
@@ -74,6 +74,7 @@
"check:privacy": "scripts/check-privacy.sh",
"check:proposal-pii": "scripts/check-proposal-pii.sh",
"check:eval-glossary": "scripts/check-eval-glossary-fresh.sh",
"check:skills-manifest": "scripts/check-skills-manifest-fresh.sh",
"check:test-names": "scripts/check-test-real-names.sh",
"check:progress": "scripts/check-progress-to-stdout.sh",
"check:exports-count": "scripts/check-exports-count.sh",
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# CI guard for skills/skills.lock.json freshness (#159).
#
# Mirrors scripts/check-eval-glossary-fresh.sh: regenerate the manifest into
# a tmp file, diff against the committed version, fail the build if they
# drift. Tamper-evidence, not a signature system — the point is that any
# change under skills/ ships with an explicit manifest diff.
#
# Run: bash scripts/check-skills-manifest-fresh.sh
# Wired through `bun run verify` (scripts/run-verify-parallel.sh) so PRs that
# edit skills/ without regenerating the manifest are caught before review.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMMITTED="$REPO_ROOT/skills/skills.lock.json"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
if [ ! -f "$COMMITTED" ]; then
echo "ERROR: $COMMITTED not found." >&2
echo "Run: bun run scripts/generate-skills-manifest.ts" >&2
exit 1
fi
cd "$REPO_ROOT"
# Render directly via bun + a one-liner that exposes the module function.
bun -e "import { renderSkillsManifest } from './src/core/skills-integrity.ts'; process.stdout.write(renderSkillsManifest('skills'));" > "$TMP"
if ! diff -q "$COMMITTED" "$TMP" >/dev/null 2>&1; then
echo "ERROR: skills/skills.lock.json is stale." >&2
echo "" >&2
echo "Diff between committed and freshly-generated:" >&2
echo "" >&2
diff -u "$COMMITTED" "$TMP" >&2 || true
echo "" >&2
echo "To regenerate: bun run scripts/generate-skills-manifest.ts" >&2
exit 1
fi
echo "✓ skills/skills.lock.json is fresh"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
/**
* Regenerates skills/skills.lock.json — the tamper-evidence manifest mapping
* every bundled file under skills/ to its sha256 (#159). Not a signature
* system: it turns silent skill edits into explicit diffs. `gbrain doctor`
* warns (never fails) on drift; scripts/check-skills-manifest-fresh.sh keeps
* the committed manifest in sync in CI.
*
* Run after any change under skills/:
* bun run scripts/generate-skills-manifest.ts
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
SKILLS_MANIFEST_FILENAME,
renderSkillsManifest,
} from '../src/core/skills-integrity.ts';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const skillsDir = join(repoRoot, 'skills');
const outPath = join(skillsDir, SKILLS_MANIFEST_FILENAME);
writeFileSync(outPath, renderSkillsManifest(skillsDir));
console.log(`Wrote ${outPath}`);
+1
View File
@@ -49,6 +49,7 @@ CHECKS=(
"check:cli-exec"
"check:system-of-record"
"check:eval-glossary"
"check:skills-manifest"
"check:no-pii-agent-voice"
"check:synthetic-corpus-privacy"
"check:skill-brain-first"
+130
View File
@@ -0,0 +1,130 @@
{
"RESOLVER.md": "ad9f85d0f953ad0ec80c70091cb31c7227a89e4a08c924454be1d61d4df5b65c",
"_AGENT_README.md": "3dd82df125ceb87bdbc1ad16be4306fb3ca6e3a491c70122f6c7724a862f0b21",
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
"_friction-protocol.md": "1b6e7cfa58725a6a5dc2dc787242141bc33f5fde524540d85b14ec22266140f7",
"_output-rules.md": "239022bd9003b4d45870fea2ddb177a132f0684d898ed5075dc5dcb90cc56ab7",
"academic-verify/SKILL.md": "1c19e27e75249d869da428ce8d060075feef8fbbfe146af58b305d11a260ebbc",
"academic-verify/routing-eval.jsonl": "90d894a9829d9936e6ac7a6507e4de67ad26e46a1fe13b7a34e7dec1c0d887dd",
"archive-crawler/SKILL.md": "10c529797e1cba75da5bdb13bdd3609ae5f962f7dcc429c3b85d51c0032d9a26",
"archive-crawler/routing-eval.jsonl": "a90c607d69737adf58771d1b986c5a7d9d11f5303df7de4c7def74aa2c86a2f5",
"article-enrichment/SKILL.md": "fcdbce0f250aa2c38b86299dae510b8dc7f1e1cd854961cb8520c61642309549",
"article-enrichment/routing-eval.jsonl": "408fa8cc80caf1a2208afeb049e5ada69beb378f22d6b7114828201a4657e6e3",
"ask-user/SKILL.md": "a40f484721e548a3a14d4b33a4636111d92f99619ecc4e4ea54c3da3a15f8331",
"book-mirror/SKILL.md": "a80b2fb1daf832ecf34b10260bafcaa17ab40853a2d30bd730fc66960c727fc3",
"book-mirror/routing-eval.jsonl": "79fd23642cfa37b1255a799907e71bb2904585cf79dd6a596e3a2f019787e54c",
"brain-ops/SKILL.md": "40553ad3bf0f27fc8363b69bec3ef89ae0ea0d9290de8d1efbaa0c532c2a9590",
"brain-pdf/SKILL.md": "13c3e3162763a4503685db0a10663475d3687c4874b5f04d539af83a990f643e",
"brain-pdf/routing-eval.jsonl": "119e4fa113ea45783cee4499e63a729fdeecb4d9a45d47497754b4f5b21d0734",
"brain-taxonomist/SKILL.md": "dea4557b540868ec2c56bf43ee7f63c5d03a22d4047cd0dfbeaf19adef334f60",
"brain-taxonomist/routing-eval.jsonl": "8b485b3d735aace60be703854f0f2e9d97c52d52564efdaf7334a0c39e8d20ae",
"briefing/SKILL.md": "a661804c3eb2ce5bd4eb6f3e7106283046913945d9dba0b967bdc8483a58dd66",
"capture/SKILL.md": "98568ac96331f57397ea072749641d9748b1ce31e8b09d512b8db25c8fcda65f",
"citation-fixer/SKILL.md": "abdadbf0740a529b9c4f86f05bba416417624503fdcbc6054402d5546afd08b4",
"citation-fixer/routing-eval.jsonl": "52b23b71e66fdc18aee67d0576099b0c83997d648cf4ecf8fe7753b91b6c9c53",
"cold-start/SKILL.md": "a2c42dd7c4eceb7d3ce6449a414b417d55195aa445e723e6c798d90906cbf4e6",
"concept-synthesis/SKILL.md": "2bc060ae6d706c4e8e7d784cbe3e577b85e211a21c68cba094a1754d3f34436b",
"concept-synthesis/routing-eval.jsonl": "51d1da894158503ce18b892a34edd203f40732e79ac1c0e85141fd37e0b9922f",
"conventions/brain-first.md": "29d020470d0168f8f0b29dde0350a485a9b0472f7ac9962e34948f4897455590",
"conventions/brain-routing.md": "a8035f7dbadff0ea68b8babb8314b3d044cafbed8242dce5b931fa08b028fc45",
"conventions/calibration.md": "eda7ca76f80c8a17ae546110484389f805c5b21fc0a57f951bbe8b6abba26e03",
"conventions/cron-via-minions.md": "60b617093aecf71cca81ca36dcf4e536105e65b180ea688eeb5f1e27dc1b3530",
"conventions/cross-modal.yaml": "c012c3d72614a87b1ee698173dce2a0fb0d057a54df7aab87993c4b07fff6280",
"conventions/model-routing.md": "fb7ae8746a578500d6789b68ff40049037aa4d337b65b42f7c1745ae7080c2db",
"conventions/quality.md": "8aa681001114689d34268ccadaf0e2ff07b8f68aa5987c093a8c4a7a744f12a6",
"conventions/salience-and-recency.md": "62b0b303bf48bef10adcf08d3b51f23d3b1f1187b3850476a4b5532c2ee88926",
"conventions/schema-evolution.md": "4ba6b3557526637d8286b12fd9c0bc96f8e7783d39c2260c0f28fc37ca56c1ce",
"conventions/search-modes.md": "2a920225d1c95ea978fb1c77c5170f6a598377ab86fc0024b70962d5a84d54d0",
"conventions/subagent-routing.md": "59afd362ff0cbaf3a63586e97c53f68feb837a258a2bd43f9177af4e57d2b20e",
"conventions/test-before-bulk.md": "5073e5b93d570445f72f3c10ed3e6ec10c1fee7574ad73c2e2695993c850eca5",
"cron-scheduler/SKILL.md": "e3f9745c4f8e2dacba5b055f408b1ffe541b450aee6bbfe4773f7b424d90e04c",
"cross-modal-review/SKILL.md": "685233b1afd477e96697562c502233eea22eda5db8df116b81dd2fa78f01f80c",
"daily-task-manager/SKILL.md": "e616f74a6befffc7bb64c0e29b2d2caa37c51bc470bca96df70101c772429d83",
"daily-task-prep/SKILL.md": "9fe89f85fae139adac25c3bdc6f23bbf64239f3738a9e679c447e686175516f0",
"data-research/SKILL.md": "9dc34392e954c688bd860872d5e169a6db9348c21b9b7696bd15a111b329ecf5",
"eiirp/SKILL.md": "177fc940ad2a2da08fce403da6f8f63928c19f73428df24e7346589b54061da9",
"eiirp/routing-eval.jsonl": "416459ff68da2e5f5eb216a0c24368c8eebdf4edbbc540a28fe730ceeb4c9700",
"enrich/SKILL.md": "9988168348f6c3391d3aeec6621f9c99c8d75d1e1bfdd6c44d48e4c1067ab775",
"frontmatter-guard/SKILL.md": "5142ab53f5428ebc084ded78f1fb7eb4bfa386d276ee034bf4d57273256570c6",
"frontmatter-guard/routing-eval.jsonl": "243c28b04b557bac5360318c0f47bb6f1dd56d2f720aad3763b93fc0b2ea081e",
"functional-area-resolver/SKILL.md": "52df04bc4f8e678f931c3b2078b2126524e6d2d72676ad46b6b710d13271b46c",
"functional-area-resolver/routing-eval.jsonl": "f80674d915acdfe229046737a5b171da834be15ac6524b5a3fd18048e9b37028",
"gbrain-advisor/SKILL.md": "c15c7a88bee2c96733d718a168dd9afcb123b7b6b2c0014c5260d37c72e8736a",
"gbrain-upgrade/SKILL.md": "7cd05f43027fa20d56ed651f696288f9e4814b7bdf7b396f132fde7c96dac620",
"idea-ingest/SKILL.md": "e118bc32d5044a4a6fba4fed1911b8be50210ade14957e6cfe8e7ed26ff95298",
"idea-lineage/SKILL.md": "bbf37781d93b71ddc7909ecc5ab635872c874fb8591995dbf88b45ffeac6b1de",
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
"install/SKILL.md": "881bd0a422f34c6df4642aae66c51e2a4cc18ad5ca6d0b52d44b4de93512a3c4",
"maintain/SKILL.md": "e80cf5bb170c979b773a67a18e0d880c086e0b983db9a576a207074b0112c4f8",
"manifest.json": "52b970cfc3ed340ee4f25323b6fadeb5d125741b02d1d2dc2f85c39f0c701258",
"media-ingest/SKILL.md": "33db12830ed31a4ff4a6a58c4f126bf2596ee28d54cee0027680c83fee648a20",
"meeting-ingestion/SKILL.md": "7767334c63ff3bd8e60cd4d7cd1d1b44f0d6b0a7e0ac411529a1d59cc0a7781b",
"migrate/SKILL.md": "442c117cfe50026a142ff4f934e489c56ee226d454b639d20bba3b111a86d8fd",
"migrations/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"migrations/v0.10.3.md": "1e12db6fdb88d21df479659e236bad111a041a205d47aa0894afde2f04903124",
"migrations/v0.11.0.md": "ac45c6144eeee5033f2b24308a5063ceced2be7831b04e48154aa0b6f8ff55d8",
"migrations/v0.12.0.md": "f30b452caed5ce2679cbd60dc2d0b9e60449bab20c3a3e0c895e6cd167644f44",
"migrations/v0.12.1.md": "a461d8fcd6b19dabe3a0ce849a54e3c577667ae2af41870c6f456c6195e76064",
"migrations/v0.13.0.md": "3bf61eb1da933e6c3c35d67d0ea6d9dada5d5f878bbd14020616d4e1fe48feb2",
"migrations/v0.14.0.md": "1025395f033b91480451aad6a98dc10f2c18bfacd1b1a4e2a9fb4dc99f8fb571",
"migrations/v0.15.2.md": "468a58e8a9f0f5c5867e0ed8eb9dd1bd60dbc8d66111647997ebd8d9d2119915",
"migrations/v0.17.0.md": "761dda3b6090bfe20c9b4354aff5248d5a9b6a79a24702ecc2b29873d4f5b413",
"migrations/v0.18.0.md": "94efc1d571070600a78aca13c2735866a987e6a8f188210b7062f81b54839986",
"migrations/v0.19.0.md": "c47584e8306680f21ddc7f21a4a89f2184da522acb177f0dd73a09f1cd77ae57",
"migrations/v0.21.0.md": "83569ce6fb2843f81964833affe5eae72c94a2983eba265c836aa05be7876957",
"migrations/v0.22.14.md": "8898c109716fbc48c058a44c606f50fb7c02be599541d367380aa2e63bffea9e",
"migrations/v0.22.4.md": "5f9b2ed86cb64d92416448e744ca6c4984b89728895cd4e363f9c76a860074d9",
"migrations/v0.23.0.md": "f0363274e5071fa35d0c60e3981c4caee0494c43adb254d65acf6b743d4762d5",
"migrations/v0.25.1.md": "981ef49abadf19a891cb68b655b9f35ee67d9f4ac232ceb29d83dfc3b1c9982e",
"migrations/v0.27.1.md": "719dd4ebcc7ac81413ee79af20f80de5141577e3860e5049a49fc8f6d09ddf89",
"migrations/v0.28.0.md": "21929a392af2e1f454272e80aade6c58c5f6ccd7253d24344d08c6b53bcdf4f9",
"migrations/v0.29.1.md": "7b85373a62a8500ee8c4238ffc9878318b44ee62543adb8b2462049c6c0cd1f8",
"migrations/v0.32.2.md": "101623fa48f946242d7edc8f126967822563c3fb30a6ad50d029894f5fe3499d",
"migrations/v0.32.6.md": "e9438a910afe8a1f7861c5222fd7db04a0f8d8b8283778476b07b7c3d539d3f0",
"migrations/v0.33.0.md": "11710cb11d6eb7dc3ea54b764e3c4a25f8679cf76590acd330f97bfa1c684945",
"migrations/v0.33.3.0.md": "188a03ca86a97a9aa697cbbc83cc8ca37843fab24db2bd82f1383c400173d5bd",
"migrations/v0.34.0.0.md": "d421c5ecff0765ac1de3592d3175734db7df52e8658ec101567779c7c56c2db2",
"migrations/v0.35.0.0.md": "0fc21dc0b098f87fff1ac79a669b00a3d69ab1510ebfc5eac4a66f5c6d783809",
"migrations/v0.35.7.0.md": "c6d4454bd39e2aa243b3b3d9bc72fe5a4fd25d097be7be2bb14b25604b5c2cc5",
"migrations/v0.36.2.0.md": "1b59328240ae19c5e7e8d3eafda245809cca1fea27146607334dbee53cbeb270",
"migrations/v0.36.5.0.md": "a01a722202dfc3c799693596750c8bee611fe4dafe3cb662f6b4cd0b635cb429",
"migrations/v0.40.3.0.md": "5f500f8c543c2b6f41778b0bd3beedada68f7284f7933ad8b769322b433a8fe9",
"migrations/v0.40.5.md": "b9837d52a030517698dfb31c439f562cde60a1015ae488dab09be2c16ff182e5",
"migrations/v0.41.11.0.md": "5c6873ab969d14def4a450d792f070f1259d08b3aca43bc7825d0a9114b2b36b",
"migrations/v0.5.0.md": "5e0dabc451595295c4d971e19bcb33c258a127223d25859d8321cb7e1ce60711",
"migrations/v0.7.0.md": "97c2740445a10b1c5c7123c17dbd625fa27a94095b85d27c2b278da756c4c59a",
"migrations/v0.8.0.md": "1919ff8b8f3680612ff888e7cfcc0d86ece5d5304ae19af4497bdf40b050561a",
"migrations/v0.8.1.md": "fad7341cfb5e02545fb8a23221d12ab395fc3d8db15d1d8ee8a18844aea6563a",
"migrations/v0.9.0.md": "773fab0a8d7f330576265a3f510c1f318f47789b6136c46d43e08121acbc20eb",
"migrations/v0.9.1.md": "75761bad6c0ad37b69ec8197c6a678bb6a1484f9a76e4b70f2d1e86dc80102b3",
"minion-orchestrator/SKILL.md": "669e23f485561cf6fef16d445dfb8a6d05f76127d0ebf9feb17534af8843df5f",
"perplexity-research/SKILL.md": "1c7225d9a616c4021ee805c5fd8e0beeac5cf8655d69c4436fe63edbacddf953",
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
"query/SKILL.md": "e155a08049984c524b838988ba456d16ccedf162442160f6ba66bfd97cd5208e",
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
"repo-architecture/SKILL.md": "4ec2b8f45d168aaa55f17ecd1ed404ab04217a75c2317f0710c71705846f5394",
"reports/SKILL.md": "5dc190a0c3a2ee518254e8b596418dbe19ff389ea5ec8c8d30fcb0dfef4d0ed5",
"schema-author/SKILL.md": "4da9a472c966f8e4fb43d97a3a3608c67ec3d43da6b8c626ee0c30a4e70da26f",
"schema-unify/SKILL.md": "c7d5f66bc33c8b6f1c58660560cf70fac9bb317ed6869aeefe6fb1c668bc6fb3",
"setup/SKILL.md": "4a47a5f6ee99ac8a2649a304abb261fd34810cf6185fd7d6a53e1b6c4cbc0764",
"signal-detector/SKILL.md": "64e4547f5a8624c53d875001b423d240ec73ee9fd026a96c7b799d287c5fb6e4",
"skill-creator/SKILL.md": "4a11f8935d4214b21b4664a5c0c03149733731020ec0d5d09dc9fd8c40bd92f6",
"skill-optimizer/SKILL.md": "ba3028c7351dec3e644a7114e08e59cae7c60dc367dc4fd10560265a162baa90",
"skill-optimizer/routing-eval.jsonl": "48f7fc04414b194ee8674577c3e58e03ebd7f74d836766bd16cb5abfd4effb76",
"skill-optimizer/skillopt-benchmark.jsonl": "5552457d6eaa32486b79796d12fcbe2c078b0c53d7fbdaf582f0fd1a17e9a381",
"skillify/SKILL.md": "270b06be6480889837410944e111987e7e4b7c03cca41a1e13d4166be37723b0",
"skillpack-check/SKILL.md": "3f347ec8b498530a662be212d05f4cd06b205bce5795c2231b6a4cecef149ea0",
"skillpack-harvest/SKILL.md": "3c4c591b33f03a5ccf11ca0ddde56b54fba541efef0d590b6d435687733182d7",
"skillpack-harvest/routing-eval.jsonl": "cb4783288e95af3132b32ecb40a54cffc095f57b36240f96a25c2d2adf5e68c6",
"smoke-test/SKILL.md": "f2f2172d41e63e288095451132a0c56848ccc34101d265b330b6cf00e5769f5d",
"soul-audit/SKILL.md": "7f162dddcc511e97a24db3a46136295fcbda76023019ad8994546744d7b8eb0a",
"strategic-reading/SKILL.md": "5be656c39c830153ec7c2f328dc8bdeac05c1412b01b3415de6b5b008926e7a2",
"strategic-reading/routing-eval.jsonl": "eb0fc239c93aac53cf7d856190967bb65eaf970b4fe8bd0aad1c87495fbb8ccd",
"testing/SKILL.md": "f1846ba7c35076d910744a6b867c9ee850c1895f75a104a319c9b7d18b1d5e90",
"voice-note-ingest/SKILL.md": "69181602a77a6fe3da4c47374104a838642fb184cb452900b5acb3db8a299b93",
"voice-note-ingest/routing-eval.jsonl": "374aaec16fbde336d1e376edce89e51adc4ecaa93c13fb4a69b967ba299b8742",
"webhook-transforms/SKILL.md": "b774293297af4d513c7efa92a79cf65b8438a714adafc16802b492613e679d17"
}
+59
View File
@@ -6,6 +6,11 @@ import { LATEST_VERSION, getIdleBlockers } from '../core/migrate.ts';
import { checkResolvable } from '../core/check-resolvable.ts';
import { autoFixDryViolations, type AutoFixReport, type FixOutcome } from '../core/dry-fix.ts';
import { autoDetectSkillsDirReadOnly } from '../core/repo-root.ts';
import {
SKILLS_MANIFEST_FILENAME,
verifySkillsManifest,
type SkillsManifest,
} from '../core/skills-integrity.ts';
import { loadOrDeriveManifest } from '../core/skill-manifest.ts';
import { parseSkillFrontmatter } from '../core/skill-frontmatter.ts';
import {
@@ -4767,6 +4772,15 @@ export async function buildChecks(
checks.push(skillBrainFirstCheck(skillsDir));
}
// 2c. Skills manifest integrity (#159): tamper-evidence, not signatures.
// Compares the skills tree against its committed skills.lock.json and
// WARNS on drift — never fails, never blocks. No manifest (e.g. a user
// workspace skills dir, or a compiled binary far from the repo) → ok/skip.
// SKILL group — gated.
if (scope === 'all' && skillsDir) {
checks.push(skillsManifestIntegrityCheck(skillsDir));
}
// 3. Half-migrated Minions detection (filesystem-only).
// If completed.jsonl has any status:"partial" entry with no later
// status:"complete" for the same version, the install is mid-migration.
@@ -7776,6 +7790,51 @@ export function skillConformanceCheck(skillsDir: string): Check {
* Test seam: pure function, no `process.exit`. Direct call from tests
* with a synthetic skills dir under tempdir.
*/
/**
* Skills-manifest integrity check (#159). Verifies the skills tree against
* the committed skills.lock.json tamper-evidence manifest. Advisory only:
* drift is a WARN (local edits are legitimate), and a missing/unreadable
* manifest is an ok/skip a user's workspace skills dir or a compiled
* binary far from the repo has no manifest, and that is not a problem.
*/
export function skillsManifestIntegrityCheck(skillsDir: string): Check {
const name = 'skills_manifest_integrity';
const manifestPath = join(skillsDir, SKILLS_MANIFEST_FILENAME);
if (!existsSync(manifestPath)) {
return { name, status: 'ok', message: `No ${SKILLS_MANIFEST_FILENAME} in ${skillsDir} — integrity check not applicable` };
}
let drift: ReturnType<typeof verifySkillsManifest>;
let tracked: number;
try {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as SkillsManifest;
tracked = Object.keys(manifest).length;
drift = verifySkillsManifest(skillsDir, manifest);
} catch (err) {
// Fail-safe: an unreadable/unparseable manifest or a filesystem error
// skips the check rather than warning — this check must never block.
const msg = err instanceof Error ? err.message : String(err);
return { name, status: 'ok', message: `Could not verify ${SKILLS_MANIFEST_FILENAME} (${msg}) — integrity check skipped` };
}
const total = drift.modified.length + drift.missing.length + drift.extra.length;
if (total === 0) {
return { name, status: 'ok', message: `${tracked} bundled skill files match ${SKILLS_MANIFEST_FILENAME}` };
}
const sample = (files: string[]): string =>
files.slice(0, 5).join(', ') + (files.length > 5 ? `, … +${files.length - 5} more` : '');
const parts: string[] = [];
if (drift.modified.length > 0) parts.push(`${drift.modified.length} modified (${sample(drift.modified)})`);
if (drift.missing.length > 0) parts.push(`${drift.missing.length} missing (${sample(drift.missing)})`);
if (drift.extra.length > 0) parts.push(`${drift.extra.length} extra (${sample(drift.extra)})`);
return {
name,
status: 'warn',
message:
`skills/ drifted from ${SKILLS_MANIFEST_FILENAME} (advisory — local edits are fine): ${parts.join('; ')}. ` +
`If intentional, regenerate: bun run scripts/generate-skills-manifest.ts`,
details: { modified: drift.modified, missing: drift.missing, extra: drift.extra },
};
}
export function skillBrainFirstCheck(skillsDir: string): Check {
let manifest: ReturnType<typeof loadOrDeriveManifest>;
try {
+1
View File
@@ -128,6 +128,7 @@ export const SKILL_CHECK_NAMES: ReadonlySet<string> = new Set([
'retrieval_reflex_health',
'skill_brain_first',
'skill_conformance',
'skills_manifest_integrity',
'whoknows_health',
]);
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
/**
* Tamper-evidence manifest for the bundled `skills/` tree (#159).
*
* NOT a signature system: the manifest is a committed sha256 inventory that
* makes silent edits to bundled skill files visible (doctor warns, CI diffs).
* Anyone who can edit skills/ can also regenerate the manifest — the value is
* that the edit becomes an explicit, reviewable diff instead of an invisible
* behavior change in a fat-markdown skill an agent will later execute.
*
* Regenerate after any change under skills/:
* bun run scripts/generate-skills-manifest.ts
* Freshness is CI-guarded by scripts/check-skills-manifest-fresh.sh.
*/
/** Committed manifest filename, lives inside the skills dir it describes. */
export const SKILLS_MANIFEST_FILENAME = 'skills.lock.json';
/** Relative posix path → sha256 hex digest. */
export type SkillsManifest = Record<string, string>;
export interface SkillsManifestDrift {
/** Present in manifest and on disk, but content hash differs. */
modified: string[];
/** Present in manifest, absent on disk. */
missing: string[];
/** Present on disk, absent from manifest. */
extra: string[];
}
/**
* Hash every regular file under `dir` (recursive). Paths are '/'-separated
* relative paths, sorted, so output is deterministic across platforms. The
* manifest file itself is excluded from its own hash set. Symlinks and other
* non-regular entries are skipped.
*/
export function computeSkillsManifest(dir: string): SkillsManifest {
const files: string[] = [];
const walk = (rel: string): void => {
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) walk(relPath);
else if (entry.isFile() && relPath !== SKILLS_MANIFEST_FILENAME) files.push(relPath);
}
};
walk('');
files.sort();
const manifest: SkillsManifest = {};
for (const f of files) {
manifest[f] = createHash('sha256').update(readFileSync(join(dir, f))).digest('hex');
}
return manifest;
}
/** Canonical serialized form: 2-space JSON + trailing newline. */
export function renderSkillsManifest(dir: string): string {
return JSON.stringify(computeSkillsManifest(dir), null, 2) + '\n';
}
/** Compare `dir`'s current contents against a previously computed manifest. */
export function verifySkillsManifest(dir: string, manifest: SkillsManifest): SkillsManifestDrift {
const actual = computeSkillsManifest(dir);
const modified: string[] = [];
const missing: string[] = [];
for (const [path, hash] of Object.entries(manifest)) {
if (!(path in actual)) missing.push(path);
else if (actual[path] !== hash) modified.push(path);
}
const extra = Object.keys(actual).filter((p) => !(p in manifest));
return { modified, missing, extra };
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
SKILLS_MANIFEST_FILENAME,
computeSkillsManifest,
renderSkillsManifest,
verifySkillsManifest,
} from '../src/core/skills-integrity.ts';
describe('skills-integrity', () => {
const created: string[] = [];
afterEach(() => {
while (created.length) {
const p = created.pop()!;
try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function fixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'skills-integrity-'));
created.push(dir);
writeFileSync(join(dir, 'RESOLVER.md'), '# RESOLVER\n');
mkdirSync(join(dir, 'query'), { recursive: true });
writeFileSync(join(dir, 'query', 'SKILL.md'), '# query skill\n');
return dir;
}
it('clean tree verifies clean', () => {
const dir = fixture();
const manifest = computeSkillsManifest(dir);
expect(Object.keys(manifest).sort()).toEqual(['RESOLVER.md', 'query/SKILL.md']);
expect(verifySkillsManifest(dir, manifest)).toEqual({ modified: [], missing: [], extra: [] });
});
it('detects a modified file', () => {
const dir = fixture();
const manifest = computeSkillsManifest(dir);
writeFileSync(join(dir, 'query', 'SKILL.md'), '# tampered\n');
const drift = verifySkillsManifest(dir, manifest);
expect(drift.modified).toEqual(['query/SKILL.md']);
expect(drift.missing).toEqual([]);
expect(drift.extra).toEqual([]);
});
it('detects a missing file', () => {
const dir = fixture();
const manifest = computeSkillsManifest(dir);
rmSync(join(dir, 'query', 'SKILL.md'));
const drift = verifySkillsManifest(dir, manifest);
expect(drift.missing).toEqual(['query/SKILL.md']);
expect(drift.modified).toEqual([]);
expect(drift.extra).toEqual([]);
});
it('detects an extra file', () => {
const dir = fixture();
const manifest = computeSkillsManifest(dir);
writeFileSync(join(dir, 'query', 'notes.md'), 'injected\n');
const drift = verifySkillsManifest(dir, manifest);
expect(drift.extra).toEqual(['query/notes.md']);
expect(drift.modified).toEqual([]);
expect(drift.missing).toEqual([]);
});
it('excludes the manifest file from its own hash set', () => {
const dir = fixture();
writeFileSync(join(dir, SKILLS_MANIFEST_FILENAME), '{}\n');
const manifest = computeSkillsManifest(dir);
expect(Object.keys(manifest)).not.toContain(SKILLS_MANIFEST_FILENAME);
expect(verifySkillsManifest(dir, manifest)).toEqual({ modified: [], missing: [], extra: [] });
});
it('renders deterministic sorted JSON with a trailing newline', () => {
const dir = fixture();
const rendered = renderSkillsManifest(dir);
expect(rendered.endsWith('}\n')).toBe(true);
expect(rendered).toBe(renderSkillsManifest(dir));
expect(Object.keys(JSON.parse(rendered))).toEqual(['RESOLVER.md', 'query/SKILL.md']);
});
});