From 6293775f300d7071475df4a705088ea502966458 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 10 May 2026 18:03:37 +0200 Subject: [PATCH] feat(voyage): implement HTML-comment indirect prompt injection mitigation (Sec T4) --- .../voyage/playground/voyage-playground.html | 21 +++- .../annotation-export-schema.test.mjs | 102 ++++++++++++++++++ .../playground/voyage-playground.test.mjs | 22 ++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 plugins/voyage/tests/integration/annotation-export-schema.test.mjs diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index 1f249a2..36753d4 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -1225,10 +1225,29 @@ playground first-run shows a complete round-trip-able artifact. console.warn('markdown-it-front-matter plugin not loaded:', e && e.message); } + // ---- v4.3 Step 25 — Sec T4 HTML-comment indirect prompt-injection + // mitigation. ----------------------------------- + // Strip every comment from the source text BEFORE + // markdown-it render, except those matching the VOYAGE_ANCHOR_RE + // allowlist (Step 16). Uses parseAnchor as the negative-form filter: + // if parseAnchor returns a non-null value, the comment is a valid + // voyage:anchor and survives; everything else (including + // "" and similar prompt-injection + // payloads embedded in artifacts) is dropped silently. Pure + // string-in-string-out — no DOM access, no I/O. + function stripUnsafeComments(text) { + if (typeof text !== 'string') return text; + return text.replace(//g, function (match) { + return parseAnchor(match) ? match : ''; + }); + } + // ---- render pipeline ---------------------------------------------- function renderArtifact(text) { capturedFrontmatter = ''; - var bodyHtml = md.render(text || ''); + // v4.3 Step 25 — strip unsafe HTML-comments before markdown-it sees them. + var safeText = stripUnsafeComments(text || ''); + var bodyHtml = md.render(safeText); // Pre-render-then-wrap for
: prepend a folded frontmatter //
block at the top if the front-matter plugin captured one. var fmHtml = ''; diff --git a/plugins/voyage/tests/integration/annotation-export-schema.test.mjs b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs new file mode 100644 index 0000000..7d936f9 --- /dev/null +++ b/plugins/voyage/tests/integration/annotation-export-schema.test.mjs @@ -0,0 +1,102 @@ +// tests/integration/annotation-export-schema.test.mjs +// v4.3 Sesjon 5 — STUB. Full schema-validation tests land in Sesjon 6 (Wave 7 +// Step 29). Sesjon 5 seeds this file with the behavioral fixtures for: +// - Step 25 — HTML-comment indirect prompt-injection mitigation (Sec T4) +// - Step 26 — path-traversal + symlink/dotfile filter on loaded files +// +// These tests re-implement the browser-side filter logic locally so we can +// validate behavior without spinning up a headless browser. The voyage +// playground HTML carries the same logic inline; tests/playground/ +// voyage-playground.test.mjs covers the static-grep that the inline +// implementations exist. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..', '..'); +const HTML = join(ROOT, 'playground', 'voyage-playground.html'); + +// Mirror of the browser-side VOYAGE_ANCHOR_RE / parseAnchor / stripUnsafeComments +// (Step 16 + Step 25). Kept verbatim so a regression in browser parseAnchor +// surfaces here too. If you change the regex in the playground, mirror it +// here. +const VOYAGE_ANCHOR_RE = /^(\s*)\s*$/; +const VOYAGE_ANCHOR_ATTR_RE = /(\w+)="([^"]*)"/g; +const VOYAGE_ANCHOR_ID_RE = /^ANN-\d{4}$/; +const VOYAGE_ANCHOR_INTENTS = ['fix', 'change', 'question', 'block']; + +function parseAnchor(line) { + if (typeof line !== 'string') return null; + const m = line.match(VOYAGE_ANCHOR_RE); + if (!m) return null; + const attrs = {}; + VOYAGE_ANCHOR_ATTR_RE.lastIndex = 0; + let a; + while ((a = VOYAGE_ANCHOR_ATTR_RE.exec(m[2])) !== null) attrs[a[1]] = a[2]; + if (!attrs.id || !VOYAGE_ANCHOR_ID_RE.test(attrs.id)) return null; + if (typeof attrs.target !== 'string' || attrs.target.length === 0) return null; + if (attrs.line !== undefined) { + const n = parseInt(attrs.line, 10); + if (!Number.isInteger(n) || n <= 0) return null; + } + if (attrs.snippet && attrs.snippet.length > 80) return null; + if (attrs.intent && VOYAGE_ANCHOR_INTENTS.indexOf(attrs.intent) === -1) return null; + return { id: attrs.id, target: attrs.target }; +} + +function stripUnsafeComments(text) { + if (typeof text !== 'string') return text; + return text.replace(//g, (match) => parseAnchor(match) ? match : ''); +} + +// --- Step 25 — HTML-comment indirect prompt-injection mitigation --------- + +test('stripUnsafeComments — drops prompt-injection comment, keeps voyage:anchor (v4.3 Step 25)', () => { + const fixture = [ + '# Document', + '', + '', + '', + '', + 'Body text.', + ].join('\n'); + const out = stripUnsafeComments(fixture); + assert.ok(!out.includes('IGNORE PREVIOUS INSTRUCTIONS'), 'malicious comment must be stripped'); + assert.ok(out.includes('voyage:anchor id="ANN-0001"'), 'valid voyage:anchor must survive'); +}); + +test('stripUnsafeComments — strips arbitrary HTML comments (v4.3 Step 25)', () => { + const fixture = '

Hi

'; + const out = stripUnsafeComments(fixture); + assert.equal(out, '

Hi

', 'all non-voyage comments must be stripped'); +}); + +test('stripUnsafeComments — rejects malformed voyage:anchor (Sec T4) (v4.3 Step 25)', () => { + // A comment that LOOKS like voyage:anchor but fails the strict allowlist + // (missing id, bad id format, missing target, bogus intent). + const cases = [ + '', // no id + '', // bad id format + '', // no target + '', // bad intent + ]; + for (const c of cases) { + const out = stripUnsafeComments('A\n' + c + '\nB'); + assert.ok(!out.includes('voyage:anchor'), 'malformed comment "' + c + '" must be stripped'); + } +}); + +test('voyage-playground.html stripUnsafeComments wired into renderArtifact (v4.3 Step 25)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Function declared + assert.match(text, /function\s+stripUnsafeComments\s*\(/, 'stripUnsafeComments() function required'); + // Renderer must call it before md.render to enforce the allowlist + assert.match(text, /var\s+safeText\s*=\s*stripUnsafeComments\(/, 'renderArtifact must call stripUnsafeComments before md.render'); +}); + +// --- Step 26 placeholder — full filter test added by Sesjon 5 Step 26 ---- +// (Test below activates after Step 26 lands; kept as documentation stub.) diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index 8316231..27cd1a2 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -460,3 +460,25 @@ test('voyage-playground.html bundle stays under 460 KB HALT-gate (v4.3 Step 24)' const total = htmlSize + libTotal; assert.ok(total < 460000, 'bundle size ' + total + ' bytes exceeds 460 KB HALT-gate (' + libFiles.length + ' lib files)'); }); + +// v4.3 Step 25 — HTML-comment indirect prompt-injection mitigation (Sec T4). +// (Behavioral fixture-tests live in tests/integration/annotation-export-schema.test.mjs.) +test('voyage-playground.html declares stripUnsafeComments anchor-allowlist (v4.3 Step 25)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /function\s+stripUnsafeComments\s*\(/, 'stripUnsafeComments() required'); + // Filter must use parseAnchor as the allowlist gate + assert.match(text, /parseAnchor\(match\)\s*\?\s*match\s*:\s*''/, 'parseAnchor allowlist gate required'); +}); + +test('voyage-playground.html renderArtifact strips comments before md.render (v4.3 Step 25)', () => { + const text = readFileSync(HTML, 'utf-8'); + // The Step 25 hook must precede the md.render call inside renderArtifact. + // Locate renderArtifact body and assert ordering. + const bodyStart = text.indexOf('function renderArtifact'); + assert.ok(bodyStart > 0, 'renderArtifact() must exist'); + const bodyEnd = text.indexOf('}', bodyStart + 200); + const body = text.slice(bodyStart, bodyEnd + 1); + const stripIdx = body.indexOf('stripUnsafeComments'); + const renderIdx = body.indexOf('md.render'); + assert.ok(stripIdx > 0 && stripIdx < renderIdx, 'stripUnsafeComments must run before md.render'); +});