From 14f7cda8a31b028756c3a32920bfd6a0487a71af Mon Sep 17 00:00:00 2001 From: clagentic <10177887+akuehner@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:42:46 -0400 Subject: [PATCH 1/3] fix(lr-d049): validate loop registry ID before using as filesystem path msg.id from WS messages loop_registry_files and loop_registry_save_files was used directly in path.join without validation, allowing a client to supply a path-traversal payload (e.g. ../../../etc/passwd) and read or write files outside .claude/loops/. Fix: enforce ^loop_[A-Za-z0-9_-]+$ on the ID before any path operation, plus a defense-in-depth path.resolve/startsWith check. Both handlers (files read and save) are guarded. Invalid IDs receive loop_registry_error. Regression tests added to test/security.test.js section 18 (7 tests). All 260 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- lib/project-loop.js | 24 +++++++- test/security.test.js | 131 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/lib/project-loop.js b/lib/project-loop.js index 1bc1a329..ef295fc6 100644 --- a/lib/project-loop.js +++ b/lib/project-loop.js @@ -1010,7 +1010,17 @@ function attachLoop(ctx) { if (msg.type === "loop_registry_files") { var recId = msg.id; - var lDir = path.join(cwd, ".claude", "loops", recId); + // Validate ID before using it as a path component (path traversal guard). + if (!/^loop_[A-Za-z0-9_-]+$/.test(recId || "")) { + send({ type: "loop_registry_error", text: "invalid_loop_id" }); + return true; + } + var loopBase = path.resolve(cwd, ".claude", "loops"); + var lDir = path.resolve(loopBase, recId); + if (!lDir.startsWith(loopBase + path.sep)) { + send({ type: "loop_registry_error", text: "invalid_loop_id" }); + return true; + } var promptContent = ""; var judgeContent = ""; var loopSettings = null; @@ -1032,7 +1042,17 @@ function attachLoop(ctx) { if (msg.type === "loop_registry_save_files") { var recId2 = msg.id; - var lDir2 = path.join(cwd, ".claude", "loops", recId2); + // Validate ID before using it as a path component (path traversal guard). + if (!/^loop_[A-Za-z0-9_-]+$/.test(recId2 || "")) { + send({ type: "loop_registry_error", text: "invalid_loop_id" }); + return true; + } + var loopBase2 = path.resolve(cwd, ".claude", "loops"); + var lDir2 = path.resolve(loopBase2, recId2); + if (!lDir2.startsWith(loopBase2 + path.sep)) { + send({ type: "loop_registry_error", text: "invalid_loop_id" }); + return true; + } try { fs.mkdirSync(lDir2, { recursive: true }); if (msg.prompt !== undefined) { diff --git a/test/security.test.js b/test/security.test.js index 46d8924b..108be5f7 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -1324,6 +1324,137 @@ test("server-dm.js dm_typing handler: silently drops traversal dmKey", function assert.strictEqual(forEachClientCalled, false, "forEachClient must not be called for traversal key"); }); +// ============================================================ +// 18. Loop registry path traversal prevention (lr-d049) +// Exercises the ID validation guard added to the loop_registry_files +// and loop_registry_save_files handlers in project-loop.js. +// The attachLoop function is not easily unit-testable in isolation +// (it requires a full ctx object), so we test the guard logic directly +// using the same regex and path.resolve double-check that production uses. +// Any regression that removes the guard will break these tests. +// ============================================================ + +var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; + +// Simulate the guard logic as it appears in production (project-loop.js). +function validateLoopId(recId, cwd) { + if (!LOOP_ID_RE.test(recId || "")) return "invalid_loop_id"; + var loopBase = path.resolve(cwd, ".claude", "loops"); + var lDir = path.resolve(loopBase, recId); + if (!lDir.startsWith(loopBase + path.sep)) return "invalid_loop_id"; + return null; // valid +} + +test("loop_registry_files: rejects path traversal payload in msg.id", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + var result = validateLoopId("../../../etc/passwd", tmpDir); + assert.strictEqual(result, "invalid_loop_id", + "../../../etc/passwd must be rejected by the loop ID guard"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_files: rejects null/undefined msg.id", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + assert.strictEqual(validateLoopId(null, tmpDir), "invalid_loop_id", + "null id must be rejected"); + assert.strictEqual(validateLoopId(undefined, tmpDir), "invalid_loop_id", + "undefined id must be rejected"); + assert.strictEqual(validateLoopId("", tmpDir), "invalid_loop_id", + "empty string must be rejected"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_files: rejects id without loop_ prefix", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + assert.strictEqual(validateLoopId("abc123", tmpDir), "invalid_loop_id", + "id without loop_ prefix must be rejected"); + assert.strictEqual(validateLoopId("loop", tmpDir), "invalid_loop_id", + "bare 'loop' without trailing content must be rejected"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_files: rejects id with dots or slashes", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + var dotDot = validateLoopId("loop_abc/../../../etc/shadow", tmpDir); + assert.strictEqual(dotDot, "invalid_loop_id", + "id with ../ must be rejected"); + var withSlash = validateLoopId("loop_abc/subdir", tmpDir); + assert.strictEqual(withSlash, "invalid_loop_id", + "id with embedded slash must be rejected"); + var withDot = validateLoopId("loop_abc.evil", tmpDir); + assert.strictEqual(withDot, "invalid_loop_id", + "id with dot must be rejected (dots not in allowlist)"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_files: rejects id with null byte or shell metacharacters", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + var nullByte = validateLoopId("loop_abc\x00def", tmpDir); + assert.strictEqual(nullByte, "invalid_loop_id", "null byte must be rejected"); + var semi = validateLoopId("loop_abc;whoami", tmpDir); + assert.strictEqual(semi, "invalid_loop_id", "semicolon must be rejected"); + var space = validateLoopId("loop_abc def", tmpDir); + assert.strictEqual(space, "invalid_loop_id", "space must be rejected"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_files: accepts well-formed loop IDs", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); + try { + var valid = [ + "loop_abc", + "loop_ABC123", + "loop_my-task", + "loop_task_2025", + "loop_A-b_C-d", + ]; + for (var i = 0; i < valid.length; i++) { + assert.strictEqual(validateLoopId(valid[i], tmpDir), null, + valid[i] + " should be accepted"); + } + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + +test("loop_registry_save_files: path-traversal id does not reach filesystem", function () { + // Drive the production handler indirectly: verify that an id accepted by the + // regex cannot escape the base directory via path.resolve. + // This acts as the defense-in-depth check (the second guard in the handler). + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-traversal-")); + try { + // Hypothetical attacker id that passes naive prefix check but uses traversal. + // None of these should pass LOOP_ID_RE, but even if they did the resolve + // check would catch them. + var attackIds = [ + "loop_ok/../../../tmp/evil", + "loop_ok%2F..%2F..%2Fetc%2Fpasswd", // URL-encoded slash — remains literal + ]; + for (var i = 0; i < attackIds.length; i++) { + var result = validateLoopId(attackIds[i], tmpDir); + assert.strictEqual(result, "invalid_loop_id", + "attack payload '" + attackIds[i] + "' must be rejected"); + } + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } +}); + test("server-dm.js dm_send handler: allows valid key and calls dm.sendMessage", function () { var { attachDm } = require("../lib/server-dm"); From 725adbe38787c7696e63f6d3221e5df713561758 Mon Sep 17 00:00:00 2001 From: clagentic <10177887+akuehner@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:55:59 -0400 Subject: [PATCH 2/3] fix(lr-d049): close two missed path-traversal sinks and rewrite tautological tests - Extract module-level validateLoopId() from project-loop.js and export it; all loop-dir path construction routes through the shared helper. - Guard triggerFromQueue() in project-loop.js: validate loopFilesId (which includes client-supplied linkedTaskId from schedule_create) before any path.join call. - Guard register() in scheduler.js: validate caller-supplied data.id at store time so a tainted ID cannot persist to later path construction. - Rewrite section 18 tests to call the real exported validateLoopId and drive handleLoopMessage via a minimal ctx stub; deleting the local re-implementation that made the tests tautological. - All 261 tests pass (0 fail). Co-Authored-By: Claude Sonnet 4.6 --- lib/project-loop.js | 27 +++++- lib/scheduler.js | 11 ++- test/security.test.js | 209 +++++++++++++++++++++++------------------- 3 files changed, 149 insertions(+), 98 deletions(-) diff --git a/lib/project-loop.js b/lib/project-loop.js index ef295fc6..7f04910c 100644 --- a/lib/project-loop.js +++ b/lib/project-loop.js @@ -6,6 +6,20 @@ var { createLoopRegistry } = require("./scheduler"); var store = require("./store"); var usersModule = require("./users"); +var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; + +/** + * Validate a loop ID before using it as a path component. + * + * Returns true if the id passes the allowlist regex. + * Returns false for any value that could enable path traversal or injection. + * All handlers that build a loop directory path from a user-supplied value + * must route through this function before any path.join / path.resolve call. + */ +function validateLoopId(id) { + return LOOP_ID_RE.test(id || ""); +} + /** * Attach loop engine to a project context. * @@ -284,6 +298,13 @@ function attachLoop(ctx) { console.log("[loop-registry] Schedule triggered: " + record.name + " -> linked task " + loopFilesId); } + // Validate the loop ID before using it as a path component (path traversal guard). + // linkedTaskId flows from client-supplied sData.taskId via schedule_create. + if (!validateLoopId(loopFilesId)) { + console.error("[loop-registry] Invalid loop ID rejected: " + loopFilesId); + return; + } + // Verify the loop directory and PROMPT.md exist var recDir = path.join(cwd, ".claude", "loops", loopFilesId); try { @@ -1011,7 +1032,7 @@ function attachLoop(ctx) { if (msg.type === "loop_registry_files") { var recId = msg.id; // Validate ID before using it as a path component (path traversal guard). - if (!/^loop_[A-Za-z0-9_-]+$/.test(recId || "")) { + if (!validateLoopId(recId)) { send({ type: "loop_registry_error", text: "invalid_loop_id" }); return true; } @@ -1043,7 +1064,7 @@ function attachLoop(ctx) { if (msg.type === "loop_registry_save_files") { var recId2 = msg.id; // Validate ID before using it as a path component (path traversal guard). - if (!/^loop_[A-Za-z0-9_-]+$/.test(recId2 || "")) { + if (!validateLoopId(recId2)) { send({ type: "loop_registry_error", text: "invalid_loop_id" }); return true; } @@ -1368,4 +1389,4 @@ function attachLoop(ctx) { }; } -module.exports = { attachLoop: attachLoop }; +module.exports = { attachLoop: attachLoop, validateLoopId: validateLoopId }; diff --git a/lib/scheduler.js b/lib/scheduler.js index 0a734f6d..cb01b9df 100644 --- a/lib/scheduler.js +++ b/lib/scheduler.js @@ -12,6 +12,8 @@ var path = require("path"); var { CONFIG_DIR } = require("./config"); var { encodeCwd } = require("./utils"); +var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; + // --- Cron parser (5-field: minute hour day-of-month month day-of-week) --- function parseCronField(field, min, max) { @@ -297,8 +299,15 @@ function createLoopRegistry(opts) { // --- CRUD --- function register(data) { + // Validate caller-supplied id before storing it. + // Auto-generated IDs (the default) always pass the regex; this guard + // blocks client-supplied IDs that could later be used in path construction. + var suppliedId = data.id || null; + if (suppliedId !== null && !LOOP_ID_RE.test(suppliedId)) { + throw new Error("invalid_loop_id: " + suppliedId); + } var rec = { - id: data.id || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")), + id: suppliedId || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")), name: data.name || "Untitled", task: data.task || "", cron: data.cron || null, diff --git a/test/security.test.js b/test/security.test.js index 108be5f7..ac5eccf0 100644 --- a/test/security.test.js +++ b/test/security.test.js @@ -1326,129 +1326,150 @@ test("server-dm.js dm_typing handler: silently drops traversal dmKey", function // ============================================================ // 18. Loop registry path traversal prevention (lr-d049) -// Exercises the ID validation guard added to the loop_registry_files -// and loop_registry_save_files handlers in project-loop.js. -// The attachLoop function is not easily unit-testable in isolation -// (it requires a full ctx object), so we test the guard logic directly -// using the same regex and path.resolve double-check that production uses. -// Any regression that removes the guard will break these tests. +// Tests the REAL production validateLoopId exported from project-loop.js +// and drives the real handleLoopMessage handler through a minimal ctx stub +// to confirm the guard fires before any filesystem access. // ============================================================ -var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; +var { validateLoopId: prodValidateLoopId, attachLoop } = require("../lib/project-loop"); -// Simulate the guard logic as it appears in production (project-loop.js). -function validateLoopId(recId, cwd) { - if (!LOOP_ID_RE.test(recId || "")) return "invalid_loop_id"; - var loopBase = path.resolve(cwd, ".claude", "loops"); - var lDir = path.resolve(loopBase, recId); - if (!lDir.startsWith(loopBase + path.sep)) return "invalid_loop_id"; - return null; // valid +// --- helpers --- + +// Build a minimal ctx stub sufficient for attachLoop. +// The invalid-ID guard fires before any sm/sdk/fs access, so most +// ctx fields can be no-ops for the invalid-ID tests. +function makeLoopCtx(cwd, sendSpy) { + var sessions = new Map(); + return { + cwd: cwd, + slug: "test", + sm: { + sessions: sessions, + createSession: function () { return { localId: "s1", history: [], loop: null }; }, + saveSessionFile: function () {}, + appendToSessionFile: function () {}, + switchSession: function () {}, + broadcastSessionList: function () {}, + deleteSessionQuiet: function () {}, + setResolveLoopInfo: function () {}, + }, + sdk: { startQuery: function () {} }, + send: sendSpy, + sendTo: function (_ws, msg) { sendSpy(msg); }, + sendToSession: function () {}, + pushModule: null, + notificationsModule: null, + getHubSchedules: function () { return []; }, + getAllProjectSessions: function () { return []; }, + getStatus: function () { return null; }, + getLinuxUserForSession: function () { return null; }, + onProcessingChanged: function () {}, + hydrateImageRefs: function () {}, + }; } -test("loop_registry_files: rejects path traversal payload in msg.id", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); - try { - var result = validateLoopId("../../../etc/passwd", tmpDir); - assert.strictEqual(result, "invalid_loop_id", - "../../../etc/passwd must be rejected by the loop ID guard"); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } +// --- validateLoopId unit tests (call the REAL production export) --- + +test("validateLoopId: rejects path traversal payload", function () { + assert.strictEqual(prodValidateLoopId("../../../etc/passwd"), false, + "../../../etc/passwd must be rejected"); }); -test("loop_registry_files: rejects null/undefined msg.id", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); - try { - assert.strictEqual(validateLoopId(null, tmpDir), "invalid_loop_id", - "null id must be rejected"); - assert.strictEqual(validateLoopId(undefined, tmpDir), "invalid_loop_id", - "undefined id must be rejected"); - assert.strictEqual(validateLoopId("", tmpDir), "invalid_loop_id", - "empty string must be rejected"); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } +test("validateLoopId: rejects null/undefined/empty id", function () { + assert.strictEqual(prodValidateLoopId(null), false, "null must be rejected"); + assert.strictEqual(prodValidateLoopId(undefined), false, "undefined must be rejected"); + assert.strictEqual(prodValidateLoopId(""), false, "empty string must be rejected"); }); -test("loop_registry_files: rejects id without loop_ prefix", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); - try { - assert.strictEqual(validateLoopId("abc123", tmpDir), "invalid_loop_id", - "id without loop_ prefix must be rejected"); - assert.strictEqual(validateLoopId("loop", tmpDir), "invalid_loop_id", - "bare 'loop' without trailing content must be rejected"); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } +test("validateLoopId: rejects id without loop_ prefix", function () { + assert.strictEqual(prodValidateLoopId("abc123"), false, "no prefix must be rejected"); + assert.strictEqual(prodValidateLoopId("loop"), false, "bare 'loop' must be rejected"); }); -test("loop_registry_files: rejects id with dots or slashes", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); - try { - var dotDot = validateLoopId("loop_abc/../../../etc/shadow", tmpDir); - assert.strictEqual(dotDot, "invalid_loop_id", - "id with ../ must be rejected"); - var withSlash = validateLoopId("loop_abc/subdir", tmpDir); - assert.strictEqual(withSlash, "invalid_loop_id", - "id with embedded slash must be rejected"); - var withDot = validateLoopId("loop_abc.evil", tmpDir); - assert.strictEqual(withDot, "invalid_loop_id", - "id with dot must be rejected (dots not in allowlist)"); - } finally { - fs.rmSync(tmpDir, { recursive: true }); - } +test("validateLoopId: rejects id with dots or slashes", function () { + assert.strictEqual(prodValidateLoopId("loop_abc/../../../etc/shadow"), false, + "id with ../ must be rejected"); + assert.strictEqual(prodValidateLoopId("loop_abc/subdir"), false, + "id with embedded slash must be rejected"); + assert.strictEqual(prodValidateLoopId("loop_abc.evil"), false, + "id with dot must be rejected"); }); -test("loop_registry_files: rejects id with null byte or shell metacharacters", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); - try { - var nullByte = validateLoopId("loop_abc\x00def", tmpDir); - assert.strictEqual(nullByte, "invalid_loop_id", "null byte must be rejected"); - var semi = validateLoopId("loop_abc;whoami", tmpDir); - assert.strictEqual(semi, "invalid_loop_id", "semicolon must be rejected"); - var space = validateLoopId("loop_abc def", tmpDir); - assert.strictEqual(space, "invalid_loop_id", "space must be rejected"); - } finally { - fs.rmSync(tmpDir, { recursive: true }); +test("validateLoopId: rejects null byte and shell metacharacters", function () { + assert.strictEqual(prodValidateLoopId("loop_abc\x00def"), false, "null byte must be rejected"); + assert.strictEqual(prodValidateLoopId("loop_abc;whoami"), false, "semicolon must be rejected"); + assert.strictEqual(prodValidateLoopId("loop_abc def"), false, "space must be rejected"); +}); + +test("validateLoopId: accepts well-formed loop IDs", function () { + var valid = [ + "loop_abc", + "loop_ABC123", + "loop_my-task", + "loop_task_2025", + "loop_A-b_C-d", + ]; + for (var i = 0; i < valid.length; i++) { + assert.strictEqual(prodValidateLoopId(valid[i]), true, + valid[i] + " should be accepted"); } }); -test("loop_registry_files: accepts well-formed loop IDs", function () { - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-test-")); +// --- handleLoopMessage integration tests --- +// Drive the REAL handler dispatch path through a minimal ctx stub. +// For invalid IDs the guard fires before any fs operation; we assert the +// correct error is emitted and no exception propagates. + +test("loop_registry_files handler: sends loop_registry_error for invalid id via real handleLoopMessage", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-h-test-")); try { - var valid = [ - "loop_abc", - "loop_ABC123", - "loop_my-task", - "loop_task_2025", - "loop_A-b_C-d", + var sent = []; + var ctx = makeLoopCtx(tmpDir, function (msg) { sent.push(msg); }); + var loop = attachLoop(ctx); + + var attackIds = [ + "../../../etc/passwd", + "loop_ok/../../../tmp/evil", + "loop_ok%2F..%2F..%2Fetc%2Fpasswd", + null, + "", + "abc123", ]; - for (var i = 0; i < valid.length; i++) { - assert.strictEqual(validateLoopId(valid[i], tmpDir), null, - valid[i] + " should be accepted"); + for (var i = 0; i < attackIds.length; i++) { + sent = []; + loop.handleLoopMessage({}, { type: "loop_registry_files", id: attackIds[i] }); + assert.ok(sent.length >= 1, "handler must emit a message for id=" + attackIds[i]); + assert.strictEqual(sent[0].type, "loop_registry_error", + "must emit loop_registry_error for id=" + attackIds[i]); + assert.strictEqual(sent[0].text, "invalid_loop_id", + "error text must be invalid_loop_id for id=" + attackIds[i]); } } finally { fs.rmSync(tmpDir, { recursive: true }); } }); -test("loop_registry_save_files: path-traversal id does not reach filesystem", function () { - // Drive the production handler indirectly: verify that an id accepted by the - // regex cannot escape the base directory via path.resolve. - // This acts as the defense-in-depth check (the second guard in the handler). - var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-traversal-")); +test("loop_registry_save_files handler: sends loop_registry_error for invalid id via real handleLoopMessage", function () { + var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "loop-h-test-")); try { - // Hypothetical attacker id that passes naive prefix check but uses traversal. - // None of these should pass LOOP_ID_RE, but even if they did the resolve - // check would catch them. + var sent = []; + var ctx = makeLoopCtx(tmpDir, function (msg) { sent.push(msg); }); + var loop = attachLoop(ctx); + var attackIds = [ + "../../../etc/passwd", "loop_ok/../../../tmp/evil", - "loop_ok%2F..%2F..%2Fetc%2Fpasswd", // URL-encoded slash — remains literal + null, + "", ]; for (var i = 0; i < attackIds.length; i++) { - var result = validateLoopId(attackIds[i], tmpDir); - assert.strictEqual(result, "invalid_loop_id", - "attack payload '" + attackIds[i] + "' must be rejected"); + sent = []; + loop.handleLoopMessage({}, { type: "loop_registry_save_files", id: attackIds[i] }); + assert.ok(sent.length >= 1, "handler must emit a message for id=" + attackIds[i]); + assert.strictEqual(sent[0].type, "loop_registry_error", + "must emit loop_registry_error for id=" + attackIds[i]); + assert.strictEqual(sent[0].text, "invalid_loop_id", + "error text must be invalid_loop_id for id=" + attackIds[i]); } } finally { fs.rmSync(tmpDir, { recursive: true }); From c3e0e882128d4f2f550f2837b37576f1c5c37e3b Mon Sep 17 00:00:00 2001 From: clagentic <10177887+akuehner@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:59:47 -0400 Subject: [PATCH 3/3] fix(lr-d049): document LOOP_ID_RE duplication in scheduler.js (Peaches nit) Circular import prevents scheduler.js from calling validateLoopId() from project-loop.js directly. Added a comment noting the mirror relationship and the drift risk so future changes to the allowlist don't miss one site. Co-Authored-By: Claude Sonnet 4.6 --- lib/scheduler.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/scheduler.js b/lib/scheduler.js index cb01b9df..ec0a800a 100644 --- a/lib/scheduler.js +++ b/lib/scheduler.js @@ -12,6 +12,9 @@ var path = require("path"); var { CONFIG_DIR } = require("./config"); var { encodeCwd } = require("./utils"); +// NOTE: this regex mirrors validateLoopId() in project-loop.js. A circular +// import prevents sharing the function directly (project-loop requires this +// module). If the allowlist changes, update both. Tracked: lr-d049 nit. var LOOP_ID_RE = /^loop_[A-Za-z0-9_-]+$/; // --- Cron parser (5-field: minute hour day-of-month month day-of-week) ---