Files
gbrain/test/skills-integrity.test.ts
T
Garry TanandClaude Fable 5 f489243159 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>
2026-07-27 16:19:41 -07:00

83 lines
3.0 KiB
TypeScript

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']);
});
});