diff --git a/lib/innboks-frontmatter.mjs b/lib/innboks-frontmatter.mjs index 5bd8f2f..024b922 100644 --- a/lib/innboks-frontmatter.mjs +++ b/lib/innboks-frontmatter.mjs @@ -10,6 +10,8 @@ // jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter // (additiv array-gren fra Step 3). Zero npm dependencies. +import path from 'node:path'; + import { writeFrontmatter } from './frontmatter.mjs'; import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs'; @@ -18,11 +20,21 @@ const DESCRIPTION_MAX = 240; // Vokab sortert lengst-foerst saa "Overordnede OKR" matcher foer "OKR". const TYPE_BY_LENGTH = [...TYPE_VOCAB].sort((a, b) => b.length - a.length); -// Regel-utledning: foerste vokab-term som forekommer i title+sti -> kanonisk type. +// M3 (A1): vokab-term matcher kun som HELT ord (ikke substring -- kompound som +// «Statusnotat» skal ikke snappe til Status). Ord-grense = ikke-bokstav/-siffer +// paa begge sider (\b haandterer ikke ae/oe/aa -- derfor \p{L}\p{N}-lookaround). +function termMatches(hay, term) { + const esc = term.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(? kanonisk type. Full sti deltar ALDRI +// (M3: et /okr/-katalogsegment skal ikke forgifte routeLevel-rutingen). function deriveType(title, sourcePath) { - const hay = `${title} ${sourcePath}`.toLowerCase(); + const hay = `${title} ${path.basename(String(sourcePath))}`.toLowerCase(); for (const t of TYPE_BY_LENGTH) { - if (hay.includes(t.toLowerCase())) return snapType(t); + if (termMatches(hay, t)) return snapType(t); } return snapType(''); } diff --git a/lib/innboks-write.mjs b/lib/innboks-write.mjs index 8a36f17..3591cba 100644 --- a/lib/innboks-write.mjs +++ b/lib/innboks-write.mjs @@ -21,7 +21,7 @@ // Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40); // writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps. -import { writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs'; +import { writeFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs'; import path from 'node:path'; import { homedir } from 'node:os'; @@ -52,6 +52,26 @@ function resolveUnderBundle(resolvedBundle, rel) { return resolved; } +// M2 (A1): den leksikalske sjekken over slipper symlinks -- en symlinket +// katalog/original INNE i bundlen kan peke UT av den. realpathSync paa den +// faktiske noden (destinasjons-parent etter mkdir / original foer peker-skriv) +// maa ogsaa lande under bundle-rotas realpath, ellers avvises skrivet. +function assertRealUnderBundle(realBundle, p, what) { + const real = realpathSync(p); + if (real !== realBundle && !real.startsWith(realBundle + path.sep)) { + throw new Error(`innboks-write: ${what} resolverer utenfor bundle-rot (symlink-escape avvist): ${p}`); + } + return real; +} + +// Destinasjons-parent opprettes, realpath-sjekkes, DERETTER skrives det atomisk. +function writeConfined(realBundle, target, data, what) { + const dir = path.dirname(target); + mkdirSync(dir, { recursive: true }); + assertRealUnderBundle(realBundle, dir, what); + writeAtomic(target, data); +} + // Skrevet fil = frontmatter + body verbatim, med garantert trailing newline. function fileContent(concept) { const out = `${concept.frontmatter}${concept.body}`; @@ -86,13 +106,16 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`); } + // Bundle-rotas realpath er ankeret for alle M2-symlink-sjekker under. + const realBundle = realpathSync(resolvedBundle); + const writtenConcepts = []; for (const concept of concepts) { if (!concept.destRel) { throw new Error(`innboks-write: konsept mangler destRel: ${concept.slug ?? '?'}`); } const target = resolveUnderBundle(resolvedBundle, concept.destRel); - writeAtomic(target, fileContent(concept)); + writeConfined(realBundle, target, fileContent(concept), `destinasjons-katalog for ${concept.destRel}`); writtenConcepts.push(target); } @@ -107,6 +130,9 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) { throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`); } + // M2: en symlink-original i drop-zonen passerer den leksikalske sjekken + // over, men realpathen kan peke UT av bundlen -> avvis. + assertRealUnderBundle(realBundle, resolvedOriginal, `original ${original.path}`); const link = `/${relToBundle.split(path.sep).join('/')}`; // Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/. @@ -114,7 +140,7 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { const level = sibling ? path.dirname(sibling.destRel) : 'dokumenter'; const pointerRel = path.join(level, `${original.sourceSlug}.kilde.md`); const pointerTarget = resolveUnderBundle(resolvedBundle, pointerRel); - writeAtomic(pointerTarget, pointerContent(original, link)); + writeConfined(realBundle, pointerTarget, pointerContent(original, link), `peker-katalog for ${pointerRel}`); writtenPointers.push(pointerTarget); } diff --git a/scripts/okf-check.mjs b/scripts/okf-check.mjs index 877c720..20bc4e3 100644 --- a/scripts/okf-check.mjs +++ b/scripts/okf-check.mjs @@ -12,7 +12,7 @@ // Zero npm dependencies (node:-builtins). import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseFrontmatter } from '../lib/frontmatter.mjs'; import { TYPE_VOCAB } from '../lib/okf-vocab.mjs'; @@ -20,9 +20,15 @@ import { resolveBundleLink } from '../lib/okf-links.mjs'; const RECOMMENDED = ['resource', 'title', 'description', 'timestamp']; -// Markdown-lenker [tekst](maal) -- brukt av --strict-ingest til aa validere at -// hvert lenke-maal er en trygg, on-disk bundle-root-relativ .md (anti-RAG-poison). +// Lenke-deteksjon for --strict-ingest: hvert lenke-maal maa vaere en trygg, +// on-disk bundle-root-relativ .md (anti-RAG-poison). B3 (A1): ALLE fire +// standard lenkeformer fanges -- inline, referanse-definisjon, autolink og +// raa HTML-anker -- ikke bare inline (de tre siste lakk foer A1). const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g; +const REF_DEF_RE = /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(\S+)/gm; +const AUTOLINK_RE = /<([a-z][a-z0-9+.-]*:[^<>\s]*)>/gi; +const HTML_HREF_RE = /]*\bhref\s*=\s*["']?([^"'\s>]+)/gi; +const LINK_FORMS = [MD_LINK_RE, REF_DEF_RE, AUTOLINK_RE, HTML_HREF_RE]; // En title som selv baerer en markdown-lenke er en injeksjons-vektor -> avvises. const TITLE_LINK_RE = /\[[^\]]*\]\([^)]*\)/; @@ -60,8 +66,14 @@ function rootOkfVersion(root) { // strictIngest (default AV): paa skrivestien handheves det lukkede vokabularet // + lenke-allow-listen. Lese-siden (default) forblir tolerant (exit 0/1 kun paa // manglende type) -- denne porten er aktiv KUN ved ingestion (--strict-ingest). -export function checkBundle(root, { strictIngest = false } = {}) { - const concepts = walkConcepts(root); +// files (B2, A1): orkestratoren scoper strict til KUN kjoeringens skrevne filer +// (writeConcepts-returens {concepts, pointers}) -- ALDRI hele roten, som ville +// felt legitimt haandkuratert innhold (lovlig out-of-vocab/eksterne lenker paa +// lese-siden). Uten files: full-root-walk som foer (CLI/lese-siden). +export function checkBundle(root, { strictIngest = false, files } = {}) { + const concepts = Array.isArray(files) + ? files.map((f) => resolve(root, f)) + : walkConcepts(root); const missingType = []; const warnings = []; const strictErrors = []; @@ -84,12 +96,15 @@ export function checkBundle(root, { strictIngest = false } = {}) { if (title && TITLE_LINK_RE.test(title)) { strictErrors.push(`${rel}: lenke-baerende title «${title}»`); } - // 3. hver markdown-lenke maa resolvere til en trygg, on-disk bundle-fil. - for (const m of raw.matchAll(MD_LINK_RE)) { - const target = m[1]; - const resolved = resolveBundleLink(target, root); - if (!resolved) strictErrors.push(`${rel}: utrygg lenke ${target}`); - else if (!existsSync(resolved)) strictErrors.push(`${rel}: dangling lenke ${target}`); + // 3. hvert lenke-maal (alle fire lenkeformer, B3) maa resolvere til en + // trygg, on-disk bundle-fil. + for (const re of LINK_FORMS) { + for (const m of raw.matchAll(re)) { + const target = m[1]; + const resolved = resolveBundleLink(target, root); + if (!resolved) strictErrors.push(`${rel}: utrygg lenke ${target}`); + else if (!existsSync(resolved)) strictErrors.push(`${rel}: dangling lenke ${target}`); + } } } for (const field of RECOMMENDED) { diff --git a/tests/innboks-frontmatter.test.mjs b/tests/innboks-frontmatter.test.mjs index 7d223c9..b321844 100644 --- a/tests/innboks-frontmatter.test.mjs +++ b/tests/innboks-frontmatter.test.mjs @@ -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'); +}); diff --git a/tests/innboks-write.test.mjs b/tests/innboks-write.test.mjs index dcdb80e..1d09766 100644 --- a/tests/innboks-write.test.mjs +++ b/tests/innboks-write.test.mjs @@ -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'); + }); +}); diff --git a/tests/okf-check.test.mjs b/tests/okf-check.test.mjs index 887fac5..6376906 100644 --- a/tests/okf-check.test.mjs +++ b/tests/okf-check.test.mjs @@ -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' + + '\n', + ); + writeFileSync( + join(dir, 'dokumenter', 'anker.md'), + '---\ntype: Notat\ntitle: Anker\ndescription: x\n---\n# Anker\n\n' + + 'passord\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 }); + } +});