diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index 36753d4..376711c 100644
--- a/playground/voyage-playground.html
+++ b/playground/voyage-playground.html
@@ -1393,6 +1393,30 @@ playground first-run shows a complete round-trip-able artifact.
// ProjectArtifacts. Mirrors lib/parsers/project-discovery.mjs:15-24
// typedef on the browser side; storage-key derived deterministically
// from basePath so per-project draft state cannot leak across projects.
+ // v4.3 Step 26 — path-traversal + symlink/dotfile filter.
+ // Pure predicate: returns true if `inside` (the path RELATIVE to the
+ // project root, i.e. webkitRelativePath with the leading basePath
+ // stripped) is safe to read. Rejects:
+ // 1. Path-traversal (`..` anywhere) — covers symlinks pointing
+ // outside the chosen directory: webkitRelativePath returns the
+ // symlink's resolved relative path, so `../etc/passwd` shows up
+ // with literal `..`.
+ // 2. Dotfiles at root or any nested level (`.git/`, `.gitignore`,
+ // `.DS_Store`, `.env`, etc.).
+ // 3. Known unwanted directories at any depth (`node_modules/`,
+ // `dist/`, `build/`).
+ // Pure string-in-bool-out — no DOM, no I/O.
+ function isProjectPathSafe(inside) {
+ if (typeof inside !== 'string' || !inside) return false;
+ if (inside.indexOf('..') !== -1) return false;
+ if (inside.charAt(0) === '.') return false;
+ if (inside.indexOf('/.') !== -1) return false;
+ if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false;
+ if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false;
+ if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false;
+ return true;
+ }
+
async function loadProjectDirectory(files, basePath) {
if (!files || !files.length) return null;
var prefix = basePath ? basePath + '/' : '';
@@ -1410,14 +1434,15 @@ playground first-run shows a complete round-trip-able artifact.
// Phase 1 — validate + classify (sync).
var classified = [];
+ var filteredCount = 0; // v4.3 Step 26 — track suppressed entries
for (var i = 0; i < files.length; i++) {
var f = files[i];
var rel = f.webkitRelativePath || '';
- // Validate: path must start with basePath prefix; reject parent traversal.
- if (basePath && rel.indexOf(prefix) !== 0) continue;
- if (rel.indexOf('..') !== -1) continue;
+ // Validate: path must start with basePath prefix.
+ if (basePath && rel.indexOf(prefix) !== 0) { filteredCount++; continue; }
var inside = rel.slice(prefix.length);
- if (!inside) continue;
+ // v4.3 Step 26 — reject path-traversal, dotfiles, node_modules, dist, build.
+ if (!isProjectPathSafe(inside)) { filteredCount++; continue; }
var parts = inside.split('/');
var name = parts[parts.length - 1];
var bucket = null;
@@ -1462,6 +1487,13 @@ playground first-run shows a complete round-trip-able artifact.
if (!artifacts.brief) {
announce('Advarsel: brief.md mangler i prosjektmappen.');
}
+ // v4.3 Step 26 — surface filter-rejection count via aria-live so
+ // screen-readers (and curious operators) know what was suppressed.
+ // Treat as informational, not error: dotfile + node_modules omission
+ // is the common case for a clean dev directory.
+ if (filteredCount > 0) {
+ announce(filteredCount + ' fil(er) filtrert (dotfiles / node_modules / path-traversal).');
+ }
// Phase 5 — render dashboard if available, else log.
if (typeof renderDashboard === 'function') {
diff --git a/tests/integration/annotation-export-schema.test.mjs b/tests/integration/annotation-export-schema.test.mjs
index 7d936f9..b8570d5 100644
--- a/tests/integration/annotation-export-schema.test.mjs
+++ b/tests/integration/annotation-export-schema.test.mjs
@@ -98,5 +98,66 @@ test('voyage-playground.html stripUnsafeComments wired into renderArtifact (v4.3
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.)
+// --- Step 26 — path-traversal + symlink/dotfile filter ------------------
+// Mirror of the browser-side isProjectPathSafe predicate. Kept verbatim so
+// the playground's filter cannot drift without breaking this test.
+function isProjectPathSafe(inside) {
+ if (typeof inside !== 'string' || !inside) return false;
+ if (inside.indexOf('..') !== -1) return false;
+ if (inside.charAt(0) === '.') return false;
+ if (inside.indexOf('/.') !== -1) return false;
+ if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false;
+ if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false;
+ if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false;
+ return true;
+}
+
+test('isProjectPathSafe — rejects path-traversal (v4.3 Step 26)', () => {
+ assert.equal(isProjectPathSafe('../etc/passwd'), false);
+ assert.equal(isProjectPathSafe('foo/../etc/passwd'), false);
+ assert.equal(isProjectPathSafe('a/b/../c'), false);
+});
+
+test('isProjectPathSafe — rejects dotfiles at root + nested (v4.3 Step 26)', () => {
+ assert.equal(isProjectPathSafe('.gitignore'), false);
+ assert.equal(isProjectPathSafe('.git/config'), false);
+ assert.equal(isProjectPathSafe('.DS_Store'), false);
+ assert.equal(isProjectPathSafe('.env'), false);
+ assert.equal(isProjectPathSafe('docs/.hidden/file'), false);
+ assert.equal(isProjectPathSafe('research/.git/HEAD'), false);
+});
+
+test('isProjectPathSafe — rejects node_modules / dist / build at any depth (v4.3 Step 26)', () => {
+ assert.equal(isProjectPathSafe('node_modules/foo/index.js'), false);
+ assert.equal(isProjectPathSafe('packages/sub/node_modules/x'), false);
+ assert.equal(isProjectPathSafe('dist/bundle.js'), false);
+ assert.equal(isProjectPathSafe('packages/x/dist/y.js'), false);
+ assert.equal(isProjectPathSafe('build/output.js'), false);
+ assert.equal(isProjectPathSafe('packages/x/build/y.js'), false);
+});
+
+test('isProjectPathSafe — accepts valid project artifacts (v4.3 Step 26)', () => {
+ assert.equal(isProjectPathSafe('brief.md'), true);
+ assert.equal(isProjectPathSafe('plan.md'), true);
+ assert.equal(isProjectPathSafe('review.md'), true);
+ assert.equal(isProjectPathSafe('progress.json'), true);
+ assert.equal(isProjectPathSafe('research/01-foo.md'), true);
+ assert.equal(isProjectPathSafe('architecture/overview.md'), true);
+ assert.equal(isProjectPathSafe('architecture/gaps.md'), true);
+});
+
+test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v4.3 Step 26)', () => {
+ // Fixture mirroring Step 26 plan-Verify scenario: load a directory
+ // containing the four hostile entries plus a valid brief.md and verify
+ // only brief.md survives.
+ const fixture = [
+ '../etc/passwd',
+ '.git/config',
+ 'node_modules/foo/index.js',
+ 'brief.md',
+ '.DS_Store',
+ 'dist/junk.js',
+ ];
+ const survivors = fixture.filter(isProjectPathSafe);
+ assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter');
+});
diff --git a/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index 27cd1a2..2aea598 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/tests/playground/voyage-playground.test.mjs
@@ -482,3 +482,23 @@ test('voyage-playground.html renderArtifact strips comments before md.render (v4
const renderIdx = body.indexOf('md.render');
assert.ok(stripIdx > 0 && stripIdx < renderIdx, 'stripUnsafeComments must run before md.render');
});
+
+// v4.3 Step 26 — path-traversal + symlink/dotfile filter.
+test('voyage-playground.html declares isProjectPathSafe filter (v4.3 Step 26)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ assert.match(text, /function\s+isProjectPathSafe\s*\(/, 'isProjectPathSafe() function required');
+ // Must reject the four documented threat-classes
+ assert.match(text, /indexOf\('\.\.'\)/, '..-rejection required');
+ assert.match(text, /indexOf\('node_modules\//, 'node_modules/-rejection required');
+ assert.match(text, /indexOf\('dist\//, 'dist/-rejection required');
+ assert.match(text, /indexOf\('build\//, 'build/-rejection required');
+});
+
+test('voyage-playground.html loadProjectDirectory wires isProjectPathSafe filter (v4.3 Step 26)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ // Must call the filter before classification, AND track filteredCount
+ assert.match(text, /isProjectPathSafe\(inside\)/, 'isProjectPathSafe must be called on `inside` path');
+ assert.match(text, /filteredCount\+\+/, 'filteredCount tracking required');
+ // aria-live announce must fire when something is filtered
+ assert.match(text, /announce\(filteredCount/, 'filteredCount announce required');
+});