From aa781b1b87564668cebd9b6fcae6dd7ea690a5be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 02:07:57 +0000 Subject: [PATCH] Detect symlink cycles in collectFiles directory walks Track visited directory device/inode pairs so symlink loops cannot recurse indefinitely. Add tests for cyclic and benign directory symlinks. Fixes AgentPostmortem/Skill-audit#10 Co-authored-by: Sharad. --- src/scan.js | 20 +++++++++++++++++++- test/skill-audit.test.js | 30 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/scan.js b/src/scan.js index 6b4719b..a6405a4 100644 --- a/src/scan.js +++ b/src/scan.js @@ -29,11 +29,29 @@ export function collectFiles(target) { const st = existsSync(target) ? statSync(target) : null; if (!st) return out; if (st.isFile()) { out.push(target); return out; } + const seenDirs = new Set(); + const dirInode = (dir) => { + try { + const s = statSync(dir); + if (!s.isDirectory()) return null; + return `${s.dev}:${s.ino}`; + } catch { + return null; + } + }; const walk = (dir) => { + const inode = dirInode(dir); + if (!inode || seenDirs.has(inode)) return; + seenDirs.add(inode); for (const name of readdirSync(dir)) { if (SKIP_DIR.has(name)) continue; const p = join(dir, name); - const s = statSync(p); + let s; + try { + s = statSync(p); + } catch { + continue; + } if (s.isDirectory()) walk(p); else if (s.isFile()) { const e = extname(name).toLowerCase(); diff --git a/test/skill-audit.test.js b/test/skill-audit.test.js index f4d8b3e..10f4498 100644 --- a/test/skill-audit.test.js +++ b/test/skill-audit.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { basename, dirname, join, relative } from "node:path"; @@ -195,6 +195,34 @@ test("hardening: browser creds, persistence, anti-forensics, dynamic exec", () = assert.ok(scanText(py, "x.py", null).some((x) => x.rule === "SKILL-OBF-003")); }); +test("collectFiles terminates when directory symlinks form a cycle", (t) => { + const root = mkdtempSync(join(tmpdir(), "skill-audit-symlink-cycle-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const nested = join(root, "nested"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, "SKILL.md"), "# skill\n"); + symlinkSync(root, join(nested, "loop"), "dir"); + + const start = Date.now(); + const files = collectFiles(root); + assert.ok(Date.now() - start < 2000, "collectFiles should not hang on symlink cycles"); + assert.deepEqual(files.map((file) => basename(file)).sort(), ["SKILL.md"]); +}); + +test("collectFiles follows benign directory symlinks without duplicating scans", (t) => { + const root = mkdtempSync(join(tmpdir(), "skill-audit-symlink-ok-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const real = join(root, "real"); + mkdirSync(real, { recursive: true }); + writeFileSync(join(real, "SKILL.md"), "# skill\n"); + writeFileSync(join(real, "run.sh"), "echo ok\n"); + symlinkSync(real, join(root, "alias"), "dir"); + + const files = collectFiles(root).map((file) => relative(root, file)).sort(); + assert.equal(files.length, 2); + assert.deepEqual(files.map((file) => basename(file)).sort(), ["SKILL.md", "run.sh"]); +}); + test("directory walks scan batch, fish, and PowerShell module scripts", (t) => { const root = mkdtempSync(join(tmpdir(), "skill-audit-extensions-")); t.after(() => rmSync(root, { recursive: true, force: true }));