pre-commit-scan.mjs mekaniserer G6 §8: scan-adversarial-content over stagede skills/**/*.md ved commit-grensen, uansett modell-adferd. Kun ingestion-flaten (skills/**/*.md); BLOCK/WARN aborterer commit (WARN = flag->human). DI'd (selectStagedKbFiles + runGate), 9 nye tester. E2E: clean->0, seeded adversarial->BLOCK exit 1, tom->0. Runbook Forutsetning 0 = §5b Layer A-gate + untrusted-data-ramme: judge-side fencing UTELATT bevisst (ville vaere judge-bump per brief §5b.4 / Non-goal §7); foreground-fetch + Layer B er de kompenserende kontrollene. Suite 910->919 exit 0 (+9). validate-plugin.sh 250/0.
111 lines
5.3 KiB
JavaScript
111 lines
5.3 KiB
JavaScript
// tests/kb-update/test-pre-commit-scan.test.mjs
|
|
// RX-OPS1 — deterministic Layer B enforcement at the commit boundary
|
|
// (scripts/kb-update/pre-commit-scan.mjs). This wraps scan-adversarial-content's scanPaths
|
|
// with two new responsibilities that are the actual point of the gate:
|
|
// (1) select ONLY staged skills/**/*.md (the untrusted-ingestion surface) from the raw
|
|
// `git diff --cached` list — never scan docs/, scripts/, tests/, or non-.md files;
|
|
// (2) map the scan disposition to a commit-blocking exit code — 0 clean, 1 block, 2 warn —
|
|
// so BOTH a BLOCK and a WARN abort the commit (a WARN is "flag → human, never
|
|
// auto-commit", so it must stop an unattended commit too).
|
|
//
|
|
// The git invocation and the detector are dependency-injected, so these tests exercise the
|
|
// selection + exit-code contract without touching git, disk, or llm-security. FAIL-CLOSED
|
|
// behaviour is inherited from scanPaths and re-asserted here at the gate layer.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { selectStagedKbFiles, runGate } from '../../scripts/kb-update/pre-commit-scan.mjs';
|
|
|
|
const CLEAN = '# T\n\n**Source:** https://learn.microsoft.com/azure/x\n\nProse.\n';
|
|
|
|
function reader(map) {
|
|
return (p) => {
|
|
if (!(p in map)) throw new Error(`ENOENT: ${p}`);
|
|
return map[p];
|
|
};
|
|
}
|
|
const detectClean = async () => [];
|
|
const detectBlock = async () => [{ class: 'injection', severity: 'critical', line: 5, evidence: 'spoofed tag: <system>' }];
|
|
const detectWarn = async () => [{ class: 'injection', severity: 'high', line: 5, evidence: 'imperative in prose' }];
|
|
|
|
// --- selection: only staged skills/**/*.md reach the scanner ---------------------------
|
|
|
|
test('selectStagedKbFiles — keeps skills/**/*.md, drops everything else', () => {
|
|
const staged = [
|
|
'skills/ms-ai-engineering/references/rag/patterns.md', // kept
|
|
'skills/ms-ai-advisor/SKILL.md', // kept
|
|
'docs/v3.1-fanout-runbook.md', // dropped (not under skills/)
|
|
'scripts/kb-update/pre-commit-scan.mjs', // dropped (not .md)
|
|
'tests/kb-update/test-pre-commit-scan.test.mjs', // dropped
|
|
'skills/x/notes.mdx', // dropped (.mdx, not .md)
|
|
'skills/x/README', // dropped (no extension)
|
|
'notskills/y.md', // dropped (prefix, not anchored)
|
|
];
|
|
assert.deepEqual(selectStagedKbFiles(staged), [
|
|
'skills/ms-ai-engineering/references/rag/patterns.md',
|
|
'skills/ms-ai-advisor/SKILL.md',
|
|
]);
|
|
});
|
|
|
|
test('selectStagedKbFiles — empty / nullish input → []', () => {
|
|
assert.deepEqual(selectStagedKbFiles([]), []);
|
|
assert.deepEqual(selectStagedKbFiles(undefined), []);
|
|
});
|
|
|
|
// --- exit-code contract: BLOCK→1, WARN→2, clean→0 --------------------------------------
|
|
|
|
test('runGate — no staged KB files → exit 0 (nothing to gate)', async () => {
|
|
const r = await runGate(['docs/x.md', 'scripts/y.mjs'], { readFile: reader({}), detect: detectClean });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.deepEqual(r.kb, []);
|
|
});
|
|
|
|
test('runGate — all staged KB files clean → exit 0', async () => {
|
|
const staged = ['skills/a/x.md', 'skills/b/y.md'];
|
|
const r = await runGate(staged, { readFile: reader({ 'skills/a/x.md': CLEAN, 'skills/b/y.md': CLEAN }), detect: detectClean });
|
|
assert.equal(r.exitCode, 0);
|
|
assert.equal(r.blocked, false);
|
|
assert.equal(r.warned, false);
|
|
assert.deepEqual(r.kb, staged);
|
|
});
|
|
|
|
test('runGate — a BLOCKED staged KB file → exit 1 (commit aborts)', async () => {
|
|
const r = await runGate(['skills/a/bad.md'], { readFile: reader({ 'skills/a/bad.md': CLEAN }), detect: detectBlock });
|
|
assert.equal(r.exitCode, 1);
|
|
assert.equal(r.blocked, true);
|
|
});
|
|
|
|
test('runGate — a WARN-only staged KB file → exit 2 (commit aborts, flag→human)', async () => {
|
|
const r = await runGate(['skills/a/warn.md'], { readFile: reader({ 'skills/a/warn.md': CLEAN }), detect: detectWarn });
|
|
assert.equal(r.exitCode, 2);
|
|
assert.equal(r.warned, true);
|
|
assert.equal(r.blocked, false);
|
|
});
|
|
|
|
test('runGate — a poisoned NON-KB staged file is not scanned → gate stays 0', async () => {
|
|
// docs/evil.md is staged and would BLOCK if scanned, but it is outside skills/ → ignored.
|
|
const detect = async (content) => (content === 'POISON' ? detectBlock() : []);
|
|
const r = await runGate(['docs/evil.md', 'skills/a/ok.md'], {
|
|
readFile: reader({ 'docs/evil.md': 'POISON', 'skills/a/ok.md': CLEAN }),
|
|
detect,
|
|
});
|
|
assert.equal(r.exitCode, 0);
|
|
assert.deepEqual(r.kb, ['skills/a/ok.md']);
|
|
});
|
|
|
|
test('runGate — BLOCK takes precedence over WARN in a mixed batch → exit 1', async () => {
|
|
const detect = async (content) => (content === 'B' ? detectBlock() : content === 'W' ? detectWarn() : []);
|
|
const r = await runGate(['skills/a/w.md', 'skills/a/b.md'], {
|
|
readFile: reader({ 'skills/a/w.md': 'W', 'skills/a/b.md': 'B' }),
|
|
detect,
|
|
});
|
|
assert.equal(r.exitCode, 1);
|
|
assert.equal(r.blocked, true);
|
|
assert.equal(r.warned, true);
|
|
});
|
|
|
|
test('runGate — an unreadable staged KB file FAILS CLOSED → exit 1', async () => {
|
|
const r = await runGate(['skills/a/gone.md'], { readFile: () => { throw new Error('ENOENT'); }, detect: detectClean });
|
|
assert.equal(r.exitCode, 1);
|
|
assert.equal(r.blocked, true);
|
|
});
|