#!/usr/bin/env node // pre-commit-scan.mjs — RX-OPS1: deterministic Layer B enforcement at the commit boundary. // // Mechanizes G6 §8 / R6 punkt (d): the adversarial-content scan (scan-adversarial-content.mjs) // runs over the STAGED untrusted-ingestion surface (skills/**/*.md) BEFORE a commit lands, // regardless of model behaviour. A generator (kb-update apply, generate-skills, a future R7 // judge-pass) may already call Layer B in-process; this wrapper makes it a hard, model-independent // gate at the git boundary as well — a poisoned candidate that slips past the in-process call is // still stopped here. // // It scans ONLY staged skills/**/*.md — the surface fed by MS Learn fetches (docs/, scripts/, // tests/ are first-party and out of scope, matching the ingestion brief). It maps the scan // disposition to a commit-blocking exit code: any BLOCK or WARN aborts the commit (a WARN is // "flag → human, never auto-committed", so it must stop an unattended commit too). // // This is Layer B ONLY. It deliberately does NOT touch the frozen groundedness judge: per the // ingestion brief §5b.4, fencing the fetched content into the judge would be a judge-bump // (Non-goal §7). Foreground fetch + this scan are the compensating controls; the judge is // untouched. See docs/v3.1-fanout-runbook.md "Forutsetning 0" and docs/ingestion-security-brief-2026-07.md §5b. // // Install (dev): symlink or copy as .git/hooks/pre-commit, or wire via a PreToolUse Bash gate. // Exit code: 0 = clean (safe to commit); 1 = at least one BLOCK; 2 = at least one WARN. import { execFileSync } from 'node:child_process'; import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { scanPaths } from './scan-adversarial-content.mjs'; // Repo-relative, POSIX-separated paths as emitted by `git diff --cached --name-only`. // Anchored at skills/ so a sibling like notskills/ never matches; exact `.md` only (not .mdx). const KB_RE = /^skills\/.+\.md$/; /** * Keep only the staged files on the untrusted-ingestion surface (skills/**\/*.md). * @param {string[]} names — raw staged path list * @returns {string[]} */ export function selectStagedKbFiles(names) { return (names ?? []).filter((n) => KB_RE.test(n)); } /** * Run the Layer B gate over the staged path list. Pure + dependency-injected (scanPaths' deps * pass straight through), so it is unit-testable without git, disk, or llm-security. * * @param {string[]} stagedNames — raw `git diff --cached` path list * @param {object} [deps] — forwarded to scanPaths ({readFile, detect}) * @returns {Promise<{exitCode: 0|1|2, blocked: boolean, warned: boolean, kb: string[], results: object[]}>} */ export async function runGate(stagedNames, deps = {}) { const kb = selectStagedKbFiles(stagedNames); const { blocked, warned, results } = await scanPaths(kb, deps); const exitCode = blocked ? 1 : warned ? 2 : 0; return { exitCode, blocked, warned, kb, results }; } /** * Raw staged path list from git. NUL-separated (-z) so filenames with spaces/newlines survive. * @param {object} [deps] — {exec} injectable for tests * @returns {string[]} */ function stagedNames(deps = {}) { const exec = deps.exec ?? (() => execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM', '-z'], { encoding: 'utf8' })); return exec().split('\0').filter(Boolean); } function report({ kb, results }) { if (kb.length === 0) { process.stdout.write('Layer B pre-commit scan: no staged skills/**/*.md — nothing to gate.\n'); return; } process.stdout.write(`Layer B pre-commit scan: ${kb.length} staged KB file(s).\n`); for (const r of results) { if (r.disposition === 'clean') { process.stdout.write(` OK ${r.path}\n`); continue; } const marker = r.disposition === 'block' ? 'BLOCK' : 'WARN '; process.stdout.write(` ${marker} ${r.path}\n`); for (const f of r.findings) { if (f.disposition === 'clean' || f.disposition === 'pass') continue; const tier = f.tier ? ` [${f.tier}]` : ''; process.stdout.write(` ${(f.disposition || 'flag').toUpperCase()} ${f.class}/${f.subtype ?? f.severity}${tier} line ${f.line}: ${f.evidence}\n`); } } } async function main() { const { exitCode, kb, results, blocked, warned } = await runGate(stagedNames()); report({ kb, results }); if (blocked) process.stderr.write('\nCommit BLOCKED: adversarial content in a staged KB file. Nothing was committed.\n'); else if (warned) process.stderr.write('\nCommit HELD: a staged KB file needs operator review (flag → human). Nothing was committed.\n'); process.exit(exitCode); } const isMain = (() => { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })(); if (isMain) main();