diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index 1f249a2..36753d4 100644
--- a/playground/voyage-playground.html
+++ b/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/tests/integration/annotation-export-schema.test.mjs b/tests/integration/annotation-export-schema.test.mjs
new file mode 100644
index 0000000..7d936f9
--- /dev/null
+++ b/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/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index 8316231..27cd1a2 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/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');
+});