feat(ms-ai-architect): Enhet A2 — Layer B baseline-adjudikering: innholds-basert allowlist (75 funn adjudikert, TDD) + 4 korpus-defekter fikset (2 homoglyph-ord, 2 U+00AD-filer); korpus 389/389 exit 0 [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 10:31:06 +02:00
commit af6c31c4c1
11 changed files with 1048 additions and 15 deletions

View file

@ -23,9 +23,30 @@
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { classifyFindings } from './lib/adversarial-scan.mjs';
import { detectAdversarial } from './lib/adversarial-detect.mjs';
/** Adjudicated Layer B allowlist (Enhet A2) — repo-relative, committed, human-reviewed. */
const ALLOWLIST_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'data/layerb-allowlist.json');
/**
* Load the adjudicated allowlist. FAIL-SAFE in the strict direction: a missing,
* unreadable, malformed, or mis-shaped file yields an EMPTY allowlist the gate can
* only be relaxed by a well-formed, committed file, never by an error.
* @param {{readFile?: (p: string) => string, path?: string}} [deps]
* @returns {Array<object>} allowlist entries
*/
export function loadAllowlist(deps = {}) {
const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
try {
const parsed = JSON.parse(readFile(deps.path ?? ALLOWLIST_PATH));
return Array.isArray(parsed?.entries) ? parsed.entries : [];
} catch {
return [];
}
}
/**
* Scan a batch of candidate KB files. Pure + dependency-injected (readFile + detect) so it is
* unit-testable without disk or llm-security. FAIL-CLOSED: a path that cannot be read, or a
@ -36,11 +57,14 @@ import { detectAdversarial } from './lib/adversarial-detect.mjs';
* @param {object} [deps]
* @param {(p: string) => string} [deps.readFile] reader (defaults to readFileSync utf8)
* @param {(content: string, opts: {path: string}) => Promise<object[]>} [deps.detect] raw detector
* @param {Array<object>} [deps.allowlist] adjudicated allowlist entries (defaults to the
* committed data/layerb-allowlist.json; missing/malformed empty = fully strict)
* @returns {Promise<{ok: boolean, blocked: boolean, warned: boolean, results: Array<{path: string, disposition: string, findings: object[]}>}>}
*/
export async function scanPaths(paths, deps = {}) {
const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
const detect = deps.detect ?? detectAdversarial;
const allowlist = deps.allowlist ?? loadAllowlist();
const results = [];
for (const path of paths ?? []) {
let content;
@ -65,7 +89,8 @@ export async function scanPaths(paths, deps = {}) {
});
continue;
}
const { disposition, findings } = classifyFindings(content, raw, {});
const allow = allowlist.filter((e) => e.path === path);
const { disposition, findings } = classifyFindings(content, raw, { allow });
results.push({ path, disposition, findings });
}
const blocked = results.some((r) => r.disposition === 'block');
@ -76,7 +101,9 @@ export async function scanPaths(paths, deps = {}) {
function report(results) {
for (const r of results) {
if (r.disposition === 'clean') {
process.stdout.write(`OK ${r.path}\n`);
const allowed = (r.findings ?? []).filter((f) => f.disposition === 'allow').length;
const note = allowed > 0 ? ` (${allowed} allowlisted)` : '';
process.stdout.write(`OK ${r.path}${note}\n`);
continue;
}
const marker = r.disposition === 'block' ? 'BLOCK' : 'WARN ';