mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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>
83 lines
3.0 KiB
TypeScript
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']);
|
|
});
|
|
});
|