fix(okr): gate-herding B2 scoped strict + B3 alle lenkeformer + M2 realpath-confinement + M3 deriveType basename/ord-grense

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:12:35 +02:00
commit 7f9c790f43
6 changed files with 222 additions and 18 deletions

View file

@ -122,3 +122,32 @@ test('projectFrontmatter: tags er multi-linje liste (writeFrontmatter array-gren
assert.match(e.frontmatter, /^tags:$/m, 'multi-linje tags-noekkel');
assert.match(e.frontmatter, /^ {2}- Tildelingsbrev$/m, 'tag-element i vokab');
});
// --- M3 (A1): deriveType matcher kun basename + ord-grenser, aldri full sti ---
test('projectFrontmatter: /okr/-segment i full sti gir IKKE type OKR (M3)', () => {
const concept = { sourceSlug: 'notat', title: 'Handlingsplan', slug: 'handlingsplan', level: 1, body: 'x' };
const e = projectFrontmatter(concept, {
sourcePath: '/x/.claude/okr/y/notat.md',
sourceMtime: MTIME,
});
const type = parseFrontmatter(e.frontmatter).get('type');
assert.notEqual(type, 'OKR', 'sti-segmentet /okr/ skal ikke forgifte type-utledningen');
assert.equal(type, 'Notat', 'basename notat.md gir Notat (ord-grense mot punktum)');
});
test('projectFrontmatter: kompound-ord i title matcher ikke vokab-term (Statusnotat != Status)', () => {
const concept = { sourceSlug: 'opps', title: 'Statusnotat mai', slug: 'statusnotat-mai', level: 1, body: 'x' };
const e = projectFrontmatter(concept, { sourcePath: 'innboks/oppsummering.txt', sourceMtime: MTIME });
assert.equal(
parseFrontmatter(e.frontmatter).get('type'),
'Dokument',
'kompound-treff skal ikke snappe til Status -- safe default Dokument',
);
});
test('projectFrontmatter: helt ord i title matcher fortsatt (Status for KR -> Status)', () => {
const concept = { sourceSlug: 's', title: 'Status for KR', slug: 'status-for-kr', level: 1, body: 'x' };
const e = projectFrontmatter(concept, { sourcePath: 'innboks/s.txt', sourceMtime: MTIME });
assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Status');
});

View file

@ -14,7 +14,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join, dirname } from 'node:path';
@ -150,3 +150,47 @@ test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => {
assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync');
});
});
// --- M2 (A1): realpathSync-confinement -- leksikalsk sjekk alene slipper symlinks ---
test('writeConcepts: symlink-original som peker UT av bundlen avvises (M2)', () => {
withBundle(({ tmp, bundleRoot, inbox }) => {
// Fil UTENFOR bundle-rota; symlink i drop-zonen passerer den leksikalske sjekken.
const outside = join(tmp, 'utenfor.txt');
writeFileSync(outside, 'sensitivt innhold utenfor bundlen');
symlinkSync(outside, join(inbox, 'lenket.txt'));
assert.throws(
() => writeConcepts([], {
bundleRoot,
originals: [{ sourceSlug: 'lenket', path: join(inbox, 'lenket.txt') }],
}),
/symlink|utenfor bundle/,
'symlink-original skal avvises via realpathSync',
);
});
});
test('writeConcepts: symlinket destinasjons-katalog som peker UT av bundlen avvises (M2)', () => {
withBundle(({ tmp, bundleRoot }) => {
// dokumenter/ er en symlink til en katalog utenfor bundlen -> skriv gjennom
// den ville landet utenfor tross leksikalsk '..'-sjekk paa destRel.
const outsideDir = join(tmp, 'ute');
mkdirSync(outsideDir, { recursive: true });
symlinkSync(outsideDir, join(bundleRoot, 'dokumenter'));
const concept = {
slug: 'x',
sourceSlug: 'x',
destRel: 'dokumenter/x.md',
frontmatter: '---\ntype: Dokument\n---\n',
body: 'innhold',
};
assert.throws(
() => writeConcepts([concept], { bundleRoot }),
/symlink|utenfor bundle/,
'destinasjons-parent skal realpath-sjekkes foer skriv',
);
assert.deepEqual(readdirSync(outsideDir), [], 'ingenting skrevet utenfor bundlen');
});
});

View file

@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateIndexes } from '../scripts/okf-index.mjs';
import { checkBundle } from '../scripts/okf-check.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CHECK = join(ROOT, 'scripts', 'okf-check.mjs');
@ -310,3 +311,80 @@ test('okf-index: innboks/ + dot-katalog -> ingen egen index.md OG ikke listet i
rmSync(dir, { recursive: true, force: true });
}
});
// --- B3 (A1): strict-gaten fanger ALLE standard lenkeformer, ikke bare inline ---
test('okf-check --strict-ingest: referanse-def/autolink/HTML-anker fanges (B3)', () => {
const dir = tmpRoot();
try {
buildCleanIngest(dir);
writeFileSync(
join(dir, 'dokumenter', 'refdef.md'),
'---\ntype: Notat\ntitle: Refdef\ndescription: x\n---\n# Refdef\n\n'
+ 'Se [rapporten][r].\n\n[r]: https://evil.example/refdef-exfil\n',
);
writeFileSync(
join(dir, 'dokumenter', 'autolenke.md'),
'---\ntype: Notat\ntitle: Autolenke\ndescription: x\n---\n# Autolenke\n\n'
+ '<https://evil.example/auto-exfil>\n',
);
writeFileSync(
join(dir, 'dokumenter', 'anker.md'),
'---\ntype: Notat\ntitle: Anker\ndescription: x\n---\n# Anker\n\n'
+ '<a href="file:///etc/passwd">passord</a>\n',
);
const { status, stdout } = runCheck(dir, '--strict-ingest');
assert.equal(status, 1, `alle tre lenkeformene skal felle strict:\n${stdout}`);
assert.match(stdout, /refdef-exfil/, 'referanse-definisjon fanges');
assert.match(stdout, /auto-exfil/, 'autolink fanges');
assert.match(stdout, /file:\/\/\/etc\/passwd/, 'raa HTML-anker fanges');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('okf-check --strict-ingest: referanse-def med trygt on-disk bundle-maal passerer', () => {
const dir = tmpRoot();
try {
buildCleanIngest(dir);
writeFileSync(
join(dir, 'dokumenter', 'trygg-ref.md'),
'---\ntype: Notat\ntitle: Trygg ref\ndescription: x\n---\n# Trygg ref\n\n'
+ 'Se [notatet][n].\n\n[n]: /dokumenter/notat.md\n',
);
const { status, stdout } = runCheck(dir, '--strict-ingest');
assert.equal(status, 0, `trygg bundle-ref-def skal passere:\n${stdout}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- B2 (A1): strict scopes til KUN kjoeringens skrevne filer via { files } ---
test('checkBundle { files }: strict validerer KUN oppgitte filer, aldri hele roten (B2)', () => {
const dir = tmpRoot();
try {
buildCleanIngest(dir);
// Haandkuratert pre-eksisterende fil: out-of-vocab type + ekstern lenke er
// LOVLIG paa lese-siden (okf-vocab haandheves kun paa skrivestien).
mkdirSync(join(dir, 'syklus'), { recursive: true });
writeFileSync(
join(dir, 'syklus', 'kuratert.md'),
'---\ntype: Egne notater\ntitle: Kuratert\ndescription: x\n---\n# Kuratert\n\n'
+ 'Se [foeringene](https://regjeringen.no/dok).\n',
);
// Full-root strict feller den kuraterte fila (dokumentert B2-problem)...
const full = checkBundle(dir, { strictIngest: true });
assert.ok(full.strictErrors.length > 0, 'full-root strict feller kuratert innhold');
// ...men scoped strict (kjoeringens skrevne filer) er GROENN og teller kun dem.
const scoped = checkBundle(dir, {
strictIngest: true,
files: [join(dir, 'dokumenter', 'notat.md'), join(dir, 'strategisk-kontekst', 'tildelingsbrev.md')],
});
assert.equal(scoped.scanned, 2, 'scoped walk teller kun oppgitte filer');
assert.deepEqual(scoped.strictErrors, [], 'skrevne filer er rene -> ingen strict-feil');
assert.deepEqual(scoped.missingType, [], 'type-sjekk gjelder samme scope');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});