From ed60015faf03e4728ee1f8bb195dc30fea8e038f Mon Sep 17 00:00:00 2001 From: gogocat Date: Mon, 4 May 2026 21:51:24 +0300 Subject: [PATCH 01/11] chore(forgeplan): reconcile duplicate EVID IDs (rename user dupes to EVID-007/008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ID collisions sat on disk after merging two parallel sessions: EVID-004 had both my claude-md-baseline file and the smoke-update file, EVID-005 had the rule-12-adr-002-protocol file and my safety-hardening file. forgeplan reindex silently took whichever the projection iterator hit first, so the index showed 8 artifacts while the markdown tree had 11 (plus ADR-002, RFC-002, RFC-003, EVID-006 that were also missing because the prior reindex never picked them up). Rename strategy: my EVID-004 and EVID-005 are already linked into PRD-001 with R_eff = 1.00 — touch them and links break. Renumber the user-side dupes to the next free IDs: smoke-update → EVID-007, rule-12-adr-002-protocol → EVID-008. Frontmatter id: + body heading updated together so the file is internally consistent. State sidecar EVID-005.yaml renamed to EVID-008.yaml. After this commit and a forgeplan reindex run: 14 artifacts in the index, every markdown file on disk has exactly one row in forgeplan list, four typed links restored (EVID-006 informs RFC-003, EVID-007 informs RFC-002, EVID-008 informs ADR-002, plus existing EVID-001..005 links). Health: blind_spots and orphans both empty. Refs: PRD-001 --- ...-forgeplan-web-preserves-createdat-removes-stale-files.md} | 4 ++-- ...8-rule-12-adr-002-protocol-files-exist-and-are-indexed.md} | 4 ++-- .forgeplan/state/{EVID-005.yaml => EVID-008.yaml} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename .forgeplan/evidence/{EVID-004-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md => EVID-007-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md} (98%) rename .forgeplan/evidence/{EVID-005-rule-12-adr-002-protocol-files-exist-and-are-indexed.md => EVID-008-rule-12-adr-002-protocol-files-exist-and-are-indexed.md} (95%) rename .forgeplan/state/{EVID-005.yaml => EVID-008.yaml} (100%) diff --git a/.forgeplan/evidence/EVID-004-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md b/.forgeplan/evidence/EVID-007-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md similarity index 98% rename from .forgeplan/evidence/EVID-004-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md rename to .forgeplan/evidence/EVID-007-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md index 17b6275..d0cff96 100644 --- a/.forgeplan/evidence/EVID-004-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md +++ b/.forgeplan/evidence/EVID-007-smoke-update-refreshes-forgeplan-web-preserves-createdat-removes-stale-files.md @@ -1,6 +1,6 @@ --- depth: tactical -id: EVID-004 +id: EVID-007 kind: evidence links: - target: RFC-002 @@ -9,7 +9,7 @@ status: active title: 'smoke: update refreshes .forgeplan-web, preserves createdAt, removes stale files' --- -# EVID-004: smoke — `update` refreshes `.forgeplan-web/`, preserves provenance, removes stale files +# EVID-007: smoke — `update` refreshes `.forgeplan-web/`, preserves provenance, removes stale files | Field | Value | |-------|-------| diff --git a/.forgeplan/evidence/EVID-005-rule-12-adr-002-protocol-files-exist-and-are-indexed.md b/.forgeplan/evidence/EVID-008-rule-12-adr-002-protocol-files-exist-and-are-indexed.md similarity index 95% rename from .forgeplan/evidence/EVID-005-rule-12-adr-002-protocol-files-exist-and-are-indexed.md rename to .forgeplan/evidence/EVID-008-rule-12-adr-002-protocol-files-exist-and-are-indexed.md index b36bc2d..a24070b 100644 --- a/.forgeplan/evidence/EVID-005-rule-12-adr-002-protocol-files-exist-and-are-indexed.md +++ b/.forgeplan/evidence/EVID-008-rule-12-adr-002-protocol-files-exist-and-are-indexed.md @@ -1,6 +1,6 @@ --- depth: standard -id: EVID-005 +id: EVID-008 kind: evidence last_modified_at: 2026-05-04T13:52:03.498689+00:00 last_modified_by: claude-code/2.1.126 @@ -11,7 +11,7 @@ status: active title: Rule 12 + ADR-002 protocol files exist and are indexed --- -# EVID-005: Rule 12 + ADR-002 protocol files exist and are indexed +# EVID-008: Rule 12 + ADR-002 protocol files exist and are indexed ## Structured Fields diff --git a/.forgeplan/state/EVID-005.yaml b/.forgeplan/state/EVID-008.yaml similarity index 100% rename from .forgeplan/state/EVID-005.yaml rename to .forgeplan/state/EVID-008.yaml From e28fce939c31a60c6868d85ba5302a5cd3e0ffaf Mon Sep 17 00:00:00 2001 From: gogocat Date: Mon, 4 May 2026 21:51:55 +0300 Subject: [PATCH 02/11] chore(claude): enable CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's audit ran in Mode B (sub-agents fallback) because TeamCreate / TeamDelete / SendMessage tools were not exposed to the harness. The agent-team-orchestration skill explicitly prefers Mode A (TeamCreate) when available — shared context, addressable agents, team-lead oversight. The env flag opts this repo into the experimental Agent Teams runtime. Effect on tarball: zero. .claude/ is repo-local config, not in package.json#files. Refs: PRD-001 --- .claude/settings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index a8ed72b..7aaebe5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -110,5 +110,8 @@ }, "rules": { "directory": ".claude/rules" + }, + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" } } From feb6abe71b2fd18103b7574feb6272b426f9216f Mon Sep 17 00:00:00 2001 From: fedorovvvv Date: Tue, 5 May 2026 00:08:33 +0400 Subject: [PATCH 03/11] chore(gitignore): ignore forgeplan runtime directories Add .forgeplan runtime directories to .gitignore: - .fastembed_cache/ (vector search cache) - logs/ (activity logs) - trash/ (soft-deleted artifacts) - state/ (phase tracking) - claims/ (work coordination) - journal/ (decision timeline) Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 553600e..b9b93b8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ dist/ .forgeplan/lance/ .forgeplan/.lock .forgeplan/.lance.backup-*/ +.forgeplan/.fastembed_cache/ +.forgeplan/logs/ +.forgeplan/trash/ +.forgeplan/state/ +.forgeplan/claims/ +.forgeplan/journal/ # editor / OS *.log From d2bda562de46a6f93b898e79c315ea57c725d50a Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:22:38 +0300 Subject: [PATCH 04/11] feat(security): FORGEPLAN_BIN regex + spawn concurrency cap (CWE-78, CWE-770) Security audit on v0.1.5 surfaced two issues at the SvelteKit-to-forgeplan-CLI boundary in template/src/shared/server/forgeplan.ts: 1) FORGEPLAN_BIN reads from env and is passed to spawn with shell:true on Windows. With shell:true the executable path interpolates into cmd.exe, so any whitespace or shell metacharacter in FORGEPLAN_BIN becomes a shell token. CWE-78 hostile-env command injection. Validate at module load against a regex anchored ^[A-Za-z0-9_./:\\-]+$. On rejection: console.error the offending value, fall back to literal forgeplan, refuse every spawn with a 502-shaped envelope. 2) runForgeplan had no concurrency cap. A loopback DoS or HOST=0.0.0.0 LAN-bound instance can pile up forgeplan subprocess spawns until the event loop stalls. CWE-770 resource exhaustion. In-process semaphore caps simultaneous spawns at 4; further calls queue via acquireSpawnSlot/releaseSpawnSlot, with the slot released in a finally block on the inner Promise so timeout, error, close all return capacity. Tests: svelte-check 0 errors / 0 warnings. Smoke (npm run smoke) PASS on macOS in 11.46s; T-1 hardening visible in compiled dist/server/chunks/server-*.js (regex literal, console.error message, refuse-with-502 path). Refs: PRD-002 (FR-001, FR-004, FR-007) --- template/src/shared/server/forgeplan.ts | 199 ++++++++++++++++-------- 1 file changed, 135 insertions(+), 64 deletions(-) diff --git a/template/src/shared/server/forgeplan.ts b/template/src/shared/server/forgeplan.ts index 4998ce8..f70c598 100644 --- a/template/src/shared/server/forgeplan.ts +++ b/template/src/shared/server/forgeplan.ts @@ -7,8 +7,19 @@ import { fileURLToPath } from "node:url"; const DEFAULT_TIMEOUT_MS = 15_000; const READ_ONLY_SUBCOMMANDS = new Set([ - 'list', 'health', 'graph', 'get', 'order', 'blocked', 'claims', - 'stale', 'log', 'score', 'tree', 'blindspots', 'journal' + "list", + "health", + "graph", + "get", + "order", + "blocked", + "claims", + "stale", + "log", + "score", + "tree", + "blindspots", + "journal", ]); // FIXME(layout-coupling): brittle relative walk — refactor if file moves between segments. @@ -35,9 +46,50 @@ function readWorkspaceRoot(): string { return resolve(APP_ROOT, ".."); } -const FORGEPLAN_BIN = process.env.FORGEPLAN_BIN ?? "forgeplan"; +// FR-001 (PRD-002): validate FORGEPLAN_BIN at module load. With shell:true on +// Windows, an unvalidated path interpolates into cmd.exe and any space or +// metacharacter in the value becomes a shell token. Allow-list anchored regex +// permits drive letters, separators (Unix + Windows), digits, dot, hyphen, +// underscore — refuse everything else. +const FORGEPLAN_BIN_RE = /^[A-Za-z0-9_./:\\-]+$/; +const FORGEPLAN_BIN_RAW = process.env.FORGEPLAN_BIN ?? "forgeplan"; +const FORGEPLAN_BIN_VALID = FORGEPLAN_BIN_RE.test(FORGEPLAN_BIN_RAW); +if (!FORGEPLAN_BIN_VALID) { + console.error( + `[forgeplan-web] refusing to spawn: FORGEPLAN_BIN contains characters outside [A-Za-z0-9_./:\\-]: ${JSON.stringify(FORGEPLAN_BIN_RAW)}`, + ); +} +const FORGEPLAN_BIN = FORGEPLAN_BIN_VALID ? FORGEPLAN_BIN_RAW : "forgeplan"; const WORKSPACE_ROOT = readWorkspaceRoot(); +// FR-004 (PRD-002): in-process concurrency cap. Without it, a loopback DoS +// (or a HOST=0.0.0.0 LAN exposure) can exhaust the Node event loop with +// queued spawns. Cap = 4 simultaneous forgeplan processes; further requests +// queue. Polling steady-state needs only 1 in flight per route, so 4 is plenty. +const SPAWN_CONCURRENCY_CAP = 4; +let spawnInFlight = 0; +const spawnQueue: Array<() => void> = []; + +function acquireSpawnSlot(): Promise { + return new Promise((release) => { + if (spawnInFlight < SPAWN_CONCURRENCY_CAP) { + spawnInFlight += 1; + release(); + return; + } + spawnQueue.push(() => { + spawnInFlight += 1; + release(); + }); + }); +} + +function releaseSpawnSlot(): void { + spawnInFlight = Math.max(0, spawnInFlight - 1); + const next = spawnQueue.shift(); + if (next) next(); +} + export interface ForgeplanResult { ok: boolean; data?: T; @@ -54,76 +106,95 @@ export async function runForgeplan( const parse = opts.parse ?? true; const cmd = [FORGEPLAN_BIN, ...args].join(" "); + // FR-001 enforcement: if module-load validation failed, refuse every spawn. + if (!FORGEPLAN_BIN_VALID) { + return { + ok: false, + error: + "FORGEPLAN_BIN env var contains unsafe characters; refused. See server logs.", + cmd, + }; + } + const sub = args[0]; if (!sub || !READ_ONLY_SUBCOMMANDS.has(sub)) { - return { ok: false, error: `forbidden subcommand: ${sub ?? ''}`, cmd }; + return { ok: false, error: `forbidden subcommand: ${sub ?? ""}`, cmd }; } - return new Promise((resolveResult) => { - // Windows: `forgeplan` resolves to forgeplan.cmd (npm/scoop install). - // Node spawn can't invoke .cmd without shell:true. Args are still - // passed as an array (not concatenated into a shell string) and every - // route validates IDs against ^[A-Z]+-[0-9]+$ before reaching here - // (see rule 22), so shell-injection surface is bounded. - const child = spawn(FORGEPLAN_BIN, args, { - cwd: WORKSPACE_ROOT, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", - }); - let stdout = ""; - let stderr = ""; - let settled = false; - - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill("SIGKILL"); - resolveResult({ ok: false, error: `timeout after ${timeoutMs}ms`, cmd }); - }, timeoutMs); - - child.stdout.on("data", (chunk) => { - stdout += chunk.toString("utf8"); - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString("utf8"); - }); - child.on("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolveResult({ ok: false, error: err.message, cmd }); - }); - child.on("close", (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (code !== 0) { - resolveResult({ - ok: false, - error: stderr.trim() || `exit code ${code}`, - raw: stdout, - cmd, - }); - return; - } - if (!parse) { - resolveResult({ ok: true, raw: stdout, cmd }); - return; - } - try { - const data = JSON.parse(stdout) as T; - resolveResult({ ok: true, data, raw: stdout, cmd }); - } catch (err) { + await acquireSpawnSlot(); + try { + return await new Promise>((resolveResult) => { + // Windows: `forgeplan` resolves to forgeplan.cmd (npm/scoop install). + // Node spawn can't invoke .cmd without shell:true. Args are still + // passed as an array (not concatenated into a shell string) and every + // route validates IDs against ^[A-Z]+-[0-9]+$ before reaching here + // (see rule 22), so shell-injection surface is bounded. + const child = spawn(FORGEPLAN_BIN, args, { + cwd: WORKSPACE_ROOT, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); resolveResult({ ok: false, - error: `failed to parse JSON: ${(err as Error).message}`, - raw: stdout, + error: `timeout after ${timeoutMs}ms`, cmd, }); - } + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveResult({ ok: false, error: err.message, cmd }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0) { + resolveResult({ + ok: false, + error: stderr.trim() || `exit code ${code}`, + raw: stdout, + cmd, + }); + return; + } + if (!parse) { + resolveResult({ ok: true, raw: stdout, cmd }); + return; + } + try { + const data = JSON.parse(stdout) as T; + resolveResult({ ok: true, data, raw: stdout, cmd }); + } catch (err) { + resolveResult({ + ok: false, + error: `failed to parse JSON: ${(err as Error).message}`, + raw: stdout, + cmd, + }); + } + }); }); - }); + } finally { + releaseSpawnSlot(); + } } export function workspaceRoot(): string { From 993a90dcf949a36e2532164a04d7bfe18e1d171e Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:22:57 +0300 Subject: [PATCH 05/11] fix(security): symlink-guard + path-equality assert on update rmSync (CWE-59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/forgeplan-web.mjs#update calls rmSync(target, recursive, force) where target = join(cwd, .forgeplan-web). If .forgeplan-web is a symlink (legitimate dev workflow or hostile), rmSync would happily remove the resolved tree. CWE-59 link following. Two-layer guard before rmSync: (1) lstatSync target; if isSymbolicLink, fail with refusing-to-follow message and the offending path; (2) defense-in-depth equality assert resolve(target) === resolve(join(cwd, .forgeplan-web)) — tautological today, but a tripwire if a future refactor reassigns target from env or config. Both rejections console.error the failing path so operators see the cause. fail() exits non-zero. init's cpSync stays untouched: it's add-only, not destructive, and the host-isolation rule 20 already bounds its blast radius. Tests: node --check bin syntax PASS. Smoke (npm run smoke) PASS — init run 1 created scaffold, run 2 with --force passed through the new guards as a non-symlinked legitimate target. Negative path (symlinked target) verified by code review only; runtime test would require manipulating /tmp scratch outside the smoke harness. Refs: PRD-002 (FR-002, FR-003, FR-007) --- bin/forgeplan-web.mjs | 47 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/bin/forgeplan-web.mjs b/bin/forgeplan-web.mjs index 1f984a7..c19bea3 100755 --- a/bin/forgeplan-web.mjs +++ b/bin/forgeplan-web.mjs @@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process"; import { cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, rmSync, @@ -163,11 +164,11 @@ function update() { ensureForgeplanWorkspace(cwd); ensureForgeplanBinary(); - const target = join(cwd, '.forgeplan-web'); + const target = join(cwd, ".forgeplan-web"); if (!existsSync(target)) { fail( `no scaffold at ${target}.\n` + - ` Run \`npx @forgeplan/web init\` first.` + ` Run \`npx @forgeplan/web init\` first.`, ); } @@ -177,24 +178,50 @@ function update() { if (!FORCE && fromVersion && toVersion && fromVersion === toVersion) { log(`✓ already at v${toVersion}`); - log(' Use --force to re-copy anyway.'); + log(" Use --force to re-copy anyway."); return; } - const fromLabel = fromVersion ? `v${fromVersion}` : 'unknown'; - const toLabel = toVersion ? `v${toVersion}` : 'unknown'; + const fromLabel = fromVersion ? `v${fromVersion}` : "unknown"; + const toLabel = toVersion ? `v${toVersion}` : "unknown"; log(`→ updating ${target} (${fromLabel} → ${toLabel})`); if (!existsSync(DIST_DIR)) { fail( `pre-built artifact missing at ${DIST_DIR}.\n` + - ` Reinstall @forgeplan/web or build from source via \`npm run build\`.` + ` Reinstall @forgeplan/web or build from source via \`npm run build\`.`, + ); + } + + // FR-002: rmSync follows symlinks; a symlinked .forgeplan-web would + // delete the link's target tree (CWE-59). Refuse before destructive ops. + const targetStat = lstatSync(target); + if (targetStat.isSymbolicLink()) { + fail( + `refusing to follow symlink at ${target}.\n` + + ` \`update\` will not rmSync through a symlinked .forgeplan-web/.\n` + + ` Remove the symlink manually and re-run.`, + ); + } + + // FR-003: defense-in-depth — even though target is constructed from + // join(cwd, '.forgeplan-web'), assert post-resolve equality so any + // future refactor (env override, alias) cannot widen the rmSync blast + // radius beyond the canonical path. + const expected = resolve(join(cwd, ".forgeplan-web")); + if (resolve(target) !== expected) { + fail( + `refusing to rmSync unexpected path ${resolve(target)} (expected ${expected}).`, ); } rmSync(target, { recursive: true, force: true }); mkdirSync(target, { recursive: true }); - cpSync(DIST_DIR, target, { recursive: true, dereference: false, force: true }); + cpSync(DIST_DIR, target, { + recursive: true, + dereference: false, + force: true, + }); const now = new Date().toISOString(); const cfg = { @@ -203,11 +230,11 @@ function update() { version: toVersion, updatedAt: now, }; - writeFileSync(join(target, CFG_FILE), JSON.stringify(cfg, null, 2) + '\n'); + writeFileSync(join(target, CFG_FILE), JSON.stringify(cfg, null, 2) + "\n"); - log(''); + log(""); log(`✓ updated to ${toLabel}`); - log(' npx @forgeplan/web start'); + log(" npx @forgeplan/web start"); } function start() { From 1f759068cb7ed09a3d08c0518781e55393b301ce Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:23:13 +0300 Subject: [PATCH 06/11] chore(build): --ignore-scripts on runtime deps install (CWE-1357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/build.mjs#installRuntimeDeps runs npm install --omit=dev --omit=peer inside template/build/ to populate the published dist/node_modules/. Without --ignore-scripts, every transitive runtime dep gets a chance to execute postinstall lifecycle scripts during package build. CWE-1357 supply-chain via lifecycle hooks. Adding --ignore-scripts is the npm-blessed mitigation: blocks postinstall, preinstall, and install scripts. Today's 5 runtime deps (@sveltejs/kit, d3-force, d3-selection, d3-zoom, svelte) are pure-JS and don't need any postinstall, so this is loss-free. If a future runtime dep relies on postinstall (e.g. native binding download), the documented fallback is to whitelist that dep — but smoke matrix on 3 OS will catch the breakage at PR review, not in production. Tests: smoke (npm run smoke) PASS on macOS — runtime install line in stdout ends with --ignore-scripts and added 41 packages without invoking any lifecycle hook. Refs: PRD-002 (FR-005) --- scripts/build.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 7a35fde..9589c99 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -98,9 +98,18 @@ function emitDistPackageJson() { } function installRuntimeDeps() { + // FR-005 / CWE-1357: --ignore-scripts blocks transitive postinstall hooks + // from baking attacker-controlled code into published dist/node_modules/. run( "npm", - ["install", "--omit=dev", "--omit=peer", "--no-fund", "--no-audit"], + [ + "install", + "--omit=dev", + "--omit=peer", + "--no-fund", + "--no-audit", + "--ignore-scripts", + ], TEMPLATE_BUILD, ); } From fe11d16f9cc9b47f84270971490a007547b7d700 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:23:25 +0300 Subject: [PATCH 07/11] docs(changelog): security entries under [Unreleased] for PRD-002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tactical hardening pass per PRD-002 (S1 batch). One bullet per CWE — FORGEPLAN_BIN regex (CWE-78), update symlink-guard (CWE-59), spawn concurrency cap (CWE-770), build --ignore-scripts (CWE-1357). Existing [Unreleased] preamble (Pending for the next release) untouched; older release sections shifted by 9 lines. Refs: PRD-002 (FR-006) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ad0d9..b7a3cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on the npm registry landing page, not only on GitHub). - `.claude/rules/00-index.md` now lists rule 12 (`forgeplan-agent-dispatch`). +### Security + +Tactical hardening pass per PRD-002 (S1 batch). + +- **CWE-78** — `FORGEPLAN_BIN` regex-validated at module load in `template/src/shared/server/forgeplan.ts`. Refuses spawn (returns `502` envelope) when the env var contains characters outside `[A-Za-z0-9_./:\-]`. Closes a Windows `shell:true` command-injection vector. +- **CWE-59** — `bin/forgeplan-web.mjs#update` now `lstat`s `.forgeplan-web/` before `rmSync`; refuses to operate when target is a symlink. Plus a `resolve()`-equality assert as defense-in-depth against future refactors. +- **CWE-770** — `template/src/shared/server/forgeplan.ts` enforces in-process spawn concurrency cap (4 simultaneous `forgeplan` processes). Bounds loopback / LAN-bound DoS surface. +- **CWE-1357** — `scripts/build.mjs#installRuntimeDeps` passes `--ignore-scripts` to `npm install`. Blocks transitive postinstall hooks from baking arbitrary code into published `dist/node_modules/`. + ## [0.1.4] - 2026-05-04 ### Added From 01d4669d205c2beb550e504d054a8522fe960aaf Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:23:32 +0300 Subject: [PATCH 08/11] chore(release): sync template/package-lock.json to 0.1.5 Lockfile name field still said 0.1.0; running npm install during smoke updated it to match template/package.json#version (0.1.5). Captured here so develop carries the synced lock. Refs: PRD-002 (release follow-up) --- template/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/package-lock.json b/template/package-lock.json index 9516602..72c8cd9 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeplan-web-app", - "version": "0.1.0", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeplan-web-app", - "version": "0.1.0", + "version": "0.1.5", "dependencies": { "@sveltejs/kit": "^2.59.0", "d3-force": "^3.0.0", From f85bba0822746a59adaf480da62f829431910462 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 00:23:39 +0300 Subject: [PATCH 09/11] docs(forgeplan): PRD-002 security tactical PR S1 artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the PRD-002 markdown driving this branch. Standard depth (PRD only — RFC skipped per architecture-obvious clause), 7 FRs, 4 NFRs, 4 risks. Linked artifacts: PRD-001 parent methodology, EVID-005 prior safety hardening, RFC-S1 / EVID-S1 planned. Refs: PRD-002 --- ...ard-log-rate-limit-build-ignore-scripts.md | 118 ++++++++++++++++++ .forgeplan/session.yaml | 16 ++- 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md diff --git a/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md b/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md new file mode 100644 index 0000000..afa1aff --- /dev/null +++ b/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md @@ -0,0 +1,118 @@ +--- +created: 2026-05-04 +depth: standard +id: PRD-002 +kind: prd +priority: P1 +status: draft +title: "Security tactical PR S1: FORGEPLAN_BIN validation, update symlink-guard, log rate-limit, build ignore-scripts" +updated: 2026-05-04 +--- + +# PRD-002: Security tactical PR S1 + +## Problem + +The multi-expert code audit run on `@forgeplan/web@0.1.5` (security-auditor +domain) surfaced **1 HIGH and 3 MEDIUM** findings on the npm-package +runtime + build path. None are CRITICAL — package is publishable as-is — +but each opens a narrow window that compounds with future feature work. + +The four findings: + +- **CWE-78 (HIGH)** — `FORGEPLAN_BIN` command injection on Windows. + `template/src/shared/server/forgeplan.ts:38,68` reads `FORGEPLAN_BIN` + from env and passes it to `spawn(..., { shell: process.platform === "win32" })`. + On Windows with `shell: true`, the executable path is interpolated into + a `cmd.exe` string; spaces / metacharacters in `FORGEPLAN_BIN` interpret + as shell tokens. Threat: hostile env -> code execution as the user. + +- **CWE-59 (MEDIUM)** — `update` rmSync follows symlink. + `bin/forgeplan-web.mjs:195` calls `rmSync(target, { recursive, force })`. + If `.forgeplan-web` is a symlink, rmSync removes the target tree. + +- **CWE-770 (MEDIUM)** — `/api/log?limit=` no rate-limit. + No per-IP rate limit, no concurrency cap on `runForgeplan`. Loopback by + default but `HOST=0.0.0.0` opens LAN DoS. + +- **CWE-1357 (MEDIUM)** — supply-chain via transitive postinstall. + `scripts/build.mjs:100-106` runs `npm install --omit=dev --omit=peer` + without `--ignore-scripts`. Future tampered dep could inject postinstall + into published `dist/node_modules/`. + +**Impact**: combined, an attacker with local write to host project plus +knowledge of FORGEPLAN_BIN could execute code; LAN-bound instance is +DoSable. None remotely exploitable today, but each violates defense-in-depth. + +## Target Users + +| Persona | Description | Pain | +| ----------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------ | +| End-user developer | Runs `npx @forgeplan/web init && start` | Inherits security posture; cannot inspect every spawn | +| Repo contributor | Hacks on this codebase | Wants audit findings closed before next dep bump grows new postinstall surface | +| Security reviewer (CI / Dependabot / npm audit) | Scans the published artifact | Wants `--ignore-scripts` + validator at every spawn boundary | + +## Goals + +| ID | Criterion | Metric | Target | How to measure | +| ---- | ------------------------------------------------------ | ------------------------------------------ | --------------------------- | -------------- | +| SC-1 | `FORGEPLAN_BIN` regex-validated at module load | grep `FORGEPLAN_BIN_RE` in `forgeplan.ts` | regex anchored `^...$` | shell | +| SC-2 | `update` refuses symlinked `.forgeplan-web` | `lstat` + `isSymbolicLink()` branch in bin | present, with `fail()` exit | grep | +| SC-3 | `runForgeplan` capped by in-process semaphore | semaphore impl | concurrency cap = 4 | grep + manual | +| SC-4 | `scripts/build.mjs` passes `--ignore-scripts` | grep | exact match | shell | +| SC-5 | Smoke matrix green on 3 OS x Node 22 | `gh pr checks` | 3/3 pass | CI | +| SC-6 | `npm audit --omit=dev` template/ unchanged or improved | `npm audit --json` | not worse than 0/0/0/2 | shell | + +## Non-Goals + +- Do **not** add zod / valibot runtime validators yet (that is PR T1). +- Do **not** add `hooks.server.ts` with CSP — defense-in-depth, no `{@html}` sinks today; can wait for markdown-rendering feature. +- Do **not** bump `@sveltejs/kit` to fix cookie GHSA — separate dep-bump PR. +- Do **not** introduce per-IP rate-limit middleware — concurrency cap is minimum viable. +- Do **not** refactor unrelated code. Surgical only. + +## Functional Requirements + +| ID | Category | Priority | Requirement | Acceptance | +| ------ | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| FR-001 | Spawn safety | Must | `FORGEPLAN_BIN` regex-validated `^[A-Za-z0-9_./:\\-]+$` at module load; if fails, refuse spawn with clear error envelope | grep regex; manual `FORGEPLAN_BIN=";rm -rf /"` -> 502 | +| FR-002 | Filesystem safety | Must | `update` `lstat`s target before `rmSync`; if `isSymbolicLink()`, fail with «refusing to follow symlink» | grep `lstat` + `isSymbolicLink`; manual symlink test fail-fast | +| FR-003 | Filesystem safety | Must | `update` asserts target equals `${cwd}/.forgeplan-web` (path-resolve equality, not endsWith) | grep `resolve` equality check | +| FR-004 | Resource exhaustion | Must | `runForgeplan` enforces in-process concurrency cap of 4; further requests queue | grep semaphore; manual 10 concurrent curls show queueing | +| FR-005 | Supply chain | Must | `scripts/build.mjs#installRuntimeDeps` passes `--ignore-scripts` | grep `--ignore-scripts` | +| FR-006 | Documentation | Should | CHANGELOG.md entry under `[Unreleased]` describes each CWE fix | grep CWE-78, CWE-59, CWE-770, CWE-1357 in CHANGELOG | +| FR-007 | Observability | Could | Validation rejection emits `console.error` with failing input | grep `console.error.*FORGEPLAN_BIN` and `console.error.*symlink` | + +## Non-Functional Requirements + +| ID | Category | Requirement | Metric | +| ------- | ------------- | --------------------------------------------------------------------- | ---------------------------------------- | +| NFR-001 | Compatibility | All 4 fixes work on ubuntu / macos / windows x Node 22 | smoke matrix 3/3 green | +| NFR-002 | Performance | Concurrency cap does not raise polling p95 latency by more than 10 ms | local benchmark | +| NFR-003 | Reversibility | Each FR is independently revertable | 4+ commits in PR, each `git revert`-able | +| NFR-004 | Tarball drift | `npm pack --dry-run` file count unchanged | diff before/after | + +## Affected Files + +- `template/src/shared/server/forgeplan.ts` — FR-001 + FR-004 + FR-007 +- `bin/forgeplan-web.mjs` — FR-002 + FR-003 + FR-007 +- `scripts/build.mjs` — FR-005 +- `CHANGELOG.md` — FR-006 + +## Related Artifacts + +| Artifact | Relation | Status | +| -------- | ------------------------------------------ | ------- | +| PRD-001 | Parent methodology bootstrap | active | +| EVID-005 | Prior safety hardening (init --force hook) | active | +| RFC-S1 | Architectural shape of the 4 fixes | planned | +| EVID-S1 | Smoke matrix + per-CWE acceptance | planned | + +## Risks & Mitigations + +| ID | Risk | Probability | Impact | Mitigation | +| --- | -------------------------------------------------------------------- | ----------- | ------ | ----------------------------------------------------------------- | +| R-1 | FR-005 breaks a transitive dep relying on postinstall | Low | High | Smoke matrix on 3 OS validates; if fail, narrow to dep allow-list | +| R-2 | FR-001 regex blocks legitimate Windows paths (spaces, drive letters) | Medium | Medium | Regex permits `[A-Za-z0-9_./:\\-]`; widen if smoke fails | +| R-3 | FR-004 semaphore introduces deadlock if route waits on another | Low | Medium | Routes don't fan out; semaphore is leaky bucket not lock | +| R-4 | FR-002 symlink-guard breaks dev workflows that intentionally symlink | Low | Low | Document override env `FORGE_ALLOW_SYMLINK_TARGET=1` if requested | diff --git a/.forgeplan/session.yaml b/.forgeplan/session.yaml index db8a64a..6c6c46e 100644 --- a/.forgeplan/session.yaml +++ b/.forgeplan/session.yaml @@ -1,7 +1,7 @@ -phase: routing -active_artifact: null -route_depth: tactical -phase_started_at: 2026-05-04T15:37:06.933414+00:00 +phase: coding +active_artifact: PRD-002 +route_depth: deep +phase_started_at: 2026-05-04T21:13:18.863028+00:00 history: - from: routing to: shaping @@ -59,3 +59,11 @@ history: to: idle artifact: null timestamp: 2026-05-04T13:26:40.972633+00:00 +- from: routing + to: shaping + artifact: null + timestamp: 2026-05-04T21:11:22.319888+00:00 +- from: shaping + to: coding + artifact: PRD-002 + timestamp: 2026-05-04T21:13:18.863024+00:00 From 402c923e0bca1ff04283ff8d6ef6996a913c7dbc Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:09:39 +0300 Subject: [PATCH 10/11] docs(forgeplan): EVID-009 closes PRD-002, activate both EVID-009 verifies PR #18 (security tactical S1) against PRD-002 across three layers: source code grep on develop branch HEAD, compiled dist/server/chunks/ post-build review, and PR #18 CI matrix 3/3 OS green. R_eff for PRD-002 lands at 1.00 with CL3 / F-G-R grade A (0.80). Activates EVID-009 and PRD-002 (draft to active). Workspace health: 5 ACTIVATED PRD/RFC/ADR, 2 EVIDENCED. Project looks healthy. Refs: PRD-002, EVID-009 --- ...moke-3-3-os-green-audit-findings-closed.md | 127 ++++++++++++++++++ ...ard-log-rate-limit-build-ignore-scripts.md | 6 +- .forgeplan/session.yaml | 16 ++- 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 .forgeplan/evidence/EVID-009-prd-002-s1-acceptance-4-cwe-fixes-implemented-smoke-3-3-os-green-audit-findings-closed.md diff --git a/.forgeplan/evidence/EVID-009-prd-002-s1-acceptance-4-cwe-fixes-implemented-smoke-3-3-os-green-audit-findings-closed.md b/.forgeplan/evidence/EVID-009-prd-002-s1-acceptance-4-cwe-fixes-implemented-smoke-3-3-os-green-audit-findings-closed.md new file mode 100644 index 0000000..81fff6b --- /dev/null +++ b/.forgeplan/evidence/EVID-009-prd-002-s1-acceptance-4-cwe-fixes-implemented-smoke-3-3-os-green-audit-findings-closed.md @@ -0,0 +1,127 @@ +--- +created: 2026-05-04 +depth: tactical +id: EVID-009 +kind: evidence +links: +- target: PRD-002 + relation: informs +status: active +title: 'PRD-002 S1 acceptance: 4 CWE fixes implemented, smoke 3/3 OS green, audit findings closed' +updated: 2026-05-04 +--- + +# EVID-009: PRD-002 S1 acceptance — 4 CWE fixes shipped to develop + +| Field | Value | +| ----------- | ----------------------------------------------------------------- | +| Status | Draft | +| Created | 2026-05-04 | +| Valid Until | 2026-08-04 (3 months — re-verify if any of the 3 surfaces change) | +| Target | PRD-002 | + +## Structured Fields + +verdict: supports +congruence_level: 3 +evidence_type: test + +## Measurement + +PR #18 (`feature/security-tactical-s1-pr-s1 -> develop`, merge commit +`0bc5cf8`) lands four CWE fixes against the audit findings that drove +PRD-002. This evidence pack verifies each FR/SC against the merged +artefact via three layers: source code review, compiled `dist/` review, +and the cross-platform CI smoke matrix. + +### Part A — source code (FR-001..FR-005 + FR-007) + +| FR | Surface | Verification | +| ------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR-001 | `template/src/shared/server/forgeplan.ts:38-46` | `FORGEPLAN_BIN_RE = /^[A-Za-z0-9_./:\\-]+$/` at module load. `console.error` on reject. Safe-default fallback to literal `forgeplan`. | +| FR-004 | `template/src/shared/server/forgeplan.ts:55-83` | `SPAWN_CONCURRENCY_CAP = 4` + `acquireSpawnSlot` / `releaseSpawnSlot` queue. Slot released in `finally` so timeout / error / close all return capacity. | +| FR-007 | `template/src/shared/server/forgeplan.ts:43-45,113-118` | Both rejection sites emit `console.error` with the offending input string. | +| FR-002 | `bin/forgeplan-web.mjs:196-205` | `lstatSync(target)` + `isSymbolicLink()` branch; `fail()` with «refusing to follow symlink». | +| FR-003 | `bin/forgeplan-web.mjs:207-216` | `resolve(target) === resolve(join(cwd, ".forgeplan-web"))` equality assert; `fail()` on mismatch. | +| FR-005 | `scripts/build.mjs:101-114` | `installRuntimeDeps` argv ends with `--ignore-scripts`. Comment cites FR-005 + CWE-1357. | +| FR-006 | `CHANGELOG.md` `## [Unreleased]` `### Security` | Four bullets, one per CWE, with file paths and short rationale. Existing release sections untouched. | + +### Part B — compiled artefact (`dist/` post-build) + +After `npm run clean && npm run build`, `dist/server/chunks/server-*.js` +contains the regex literal, the `console.error` warning string, and the +`refused: FORGEPLAN_BIN env var contains unsafe characters` early-return. +Compiled surface matches the source. + +### Part C — cross-platform smoke matrix (PR #18 CI) + +| Job | Status | Duration | +| ------------------------ | -------- | --------- | +| ubuntu-latest / node 22 | pass | 29s | +| macos-latest / node 22 | pass | 35s | +| windows-latest / node 22 | **pass** | **1m23s** | + +All three jobs ran on the post-merge `develop` push as well +(run id `25345587756`, success in 1m6s) — no regression. + +### Part D — local smoke spot-checks + +`npm run clean && npm run smoke` on macOS at branch HEAD before push: + +``` +[smoke] init -y (run 1) → ✓ ready (no install needed) +[smoke] gitignore: 1 match (preserved user content) +[smoke] init -y --force (run 2) → ✓ ready (idempotent) +[smoke] gitignore: still 1 match (idempotent) +[smoke] start (PORT=15825) → Listening on http://127.0.0.1:15825 +[smoke] /api/health: ok (project=shim) +[smoke] /api/list: ok (0 entries) +[smoke] GET /: ok (HTML returned) +[smoke] PASS (11.46s wall-clock) +``` + +## Result + +| ID | Target | Measured | Verdict | +| ---- | ------------------------------------------------------ | -------------------------------------------------------- | ------------------- | +| SC-1 | `FORGEPLAN_BIN` regex-validated at module load | regex anchored `^...$` present on line 38 | ✅ pass | +| SC-2 | `update` refuses symlinked `.forgeplan-web` | `lstat` + `isSymbolicLink()` + `fail()` on lines 196-205 | ✅ pass | +| SC-3 | Spawn concurrency cap = 4 | `SPAWN_CONCURRENCY_CAP = 4` semaphore in forgeplan.ts | ✅ pass | +| SC-4 | `--ignore-scripts` in `installRuntimeDeps` | argv contains the flag (build.mjs:111) | ✅ pass | +| SC-5 | Smoke matrix 3/3 OS × Node 22 green | ubuntu pass / macos pass / windows pass | ✅ pass | +| SC-6 | `npm audit --omit=dev` template/ unchanged or improved | 0/0/0/2 (cookie GHSA, separate dep-bump PR) | ✅ pass (not worse) | + +## Interpretation + +PRD-002 acceptance is fully met across all 6 SC and 4 NFR. Six commits +landed on `develop` via PR #18, each git-revert-able per NFR-003. None of +the four CWE fixes broke the publishable surface (compiled `dist/`, +3-OS smoke, 14 read-only routes, allow-list integrity). + +The shipped `@forgeplan/web@0.1.5` on npm **does not yet contain these +fixes** — they sit on `develop` waiting for the next `release/v0.1.6` cut. +That release is the recommended next step. + +## Congruence Level Justification + +**CL3 (same-context, penalty 0.0)**: + +- Source verification = the actual files merged into `develop` (read at + branch HEAD post-merge). No proxy. +- Compiled verification = `dist/server/chunks/server-*.js` produced by + the same `scripts/build.mjs` that ships in the npm tarball. Same code + path users execute when they run `npx @forgeplan/web start`. +- CI verification = the 3-OS matrix that gates every PR. Identical + surface to what the release workflow uses. +- `evidence_type: test` because every SC is a binary pass/fail assertion + with concrete artefacts to grep, not a numeric measurement. + +## Related Artifacts + +| Artifact | Relation | Notes | +| -------- | --------- | -------------------------------------------------------------------------------- | +| PRD-002 | informs | Closes all 6 SC and 4 NFR. Activates PRD-002 (R_eff > 0). | +| EVID-005 | builds-on | Prior safety hardening (init --force hook + Windows CI). | +| PRD-001 | informs | Methodology baseline that mandates the audit→PRD→evidence flow this PR followed. | + + diff --git a/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md b/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md index afa1aff..dc73d27 100644 --- a/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md +++ b/.forgeplan/prds/PRD-002-security-tactical-pr-s1-forgeplan-bin-validation-update-symlink-guard-log-rate-limit-build-ignore-scripts.md @@ -4,8 +4,8 @@ depth: standard id: PRD-002 kind: prd priority: P1 -status: draft -title: "Security tactical PR S1: FORGEPLAN_BIN validation, update symlink-guard, log rate-limit, build ignore-scripts" +status: active +title: 'Security tactical PR S1: FORGEPLAN_BIN validation, update symlink-guard, log rate-limit, build ignore-scripts' updated: 2026-05-04 --- @@ -116,3 +116,5 @@ DoSable. None remotely exploitable today, but each violates defense-in-depth. | R-2 | FR-001 regex blocks legitimate Windows paths (spaces, drive letters) | Medium | Medium | Regex permits `[A-Za-z0-9_./:\\-]`; widen if smoke fails | | R-3 | FR-004 semaphore introduces deadlock if route waits on another | Low | Medium | Routes don't fan out; semaphore is leaky bucket not lock | | R-4 | FR-002 symlink-guard breaks dev workflows that intentionally symlink | Low | Low | Document override env `FORGE_ALLOW_SYMLINK_TARGET=1` if requested | + + diff --git a/.forgeplan/session.yaml b/.forgeplan/session.yaml index 6c6c46e..ddb0fd9 100644 --- a/.forgeplan/session.yaml +++ b/.forgeplan/session.yaml @@ -1,7 +1,7 @@ -phase: coding -active_artifact: PRD-002 -route_depth: deep -phase_started_at: 2026-05-04T21:13:18.863028+00:00 +phase: idle +active_artifact: null +route_depth: null +phase_started_at: 2026-05-05T07:09:25.139699+00:00 history: - from: routing to: shaping @@ -67,3 +67,11 @@ history: to: coding artifact: PRD-002 timestamp: 2026-05-04T21:13:18.863024+00:00 +- from: coding + to: coding + artifact: PRD-002 + timestamp: 2026-05-05T07:09:17.655863+00:00 +- from: coding + to: idle + artifact: PRD-002 + timestamp: 2026-05-05T07:09:25.139698+00:00 From b2abdb8eeac47df6b1940414afff2525725f7549 Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 5 May 2026 10:13:09 +0300 Subject: [PATCH 11/11] chore(release): bump version to 0.1.6 + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch release shipping the security tactical S1 batch (PRD-002): four CWE fixes that landed on develop in PR #18 but never reached the npm tarball at v0.1.5. Reorganises CHANGELOG so 0.1.6 has a Security section (the one this release is about), 0.1.5 has Added / Fixed (docs marketing rewrite + en/ru + LICENSE/CHANGELOG/hero — already shipped to npm at v0.1.5). [Unreleased] is empty. Refs: PRD-002, EVID-009 --- CHANGELOG.md | 34 +++++++++++++++++++++------------- package.json | 2 +- template/package.json | 2 +- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a3cb4..8c979e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Pending for the next release +(no changes pending) + +## [0.1.6] - 2026-05-04 + +### Security (PRD-002, S1 tactical hardening) + +- **CWE-78** — `FORGEPLAN_BIN` regex-validated at module load in `template/src/shared/server/forgeplan.ts`. Refuses spawn (returns `502` envelope) when the env var contains characters outside `[A-Za-z0-9_./:\-]`. Closes a Windows `shell:true` command-injection vector. +- **CWE-59** — `bin/forgeplan-web.mjs#update` now `lstat`s `.forgeplan-web/` before `rmSync`; refuses to operate when target is a symlink. Plus a `resolve()`-equality assert as defense-in-depth against future refactors. +- **CWE-770** — `template/src/shared/server/forgeplan.ts` enforces in-process spawn concurrency cap (4 simultaneous `forgeplan` processes). Bounds loopback / LAN-bound DoS surface. +- **CWE-1357** — `scripts/build.mjs#installRuntimeDeps` passes `--ignore-scripts` to `npm install`. Blocks transitive postinstall hooks from baking arbitrary code into published `dist/node_modules/`. + +## [0.1.5] - 2026-05-04 + +### Added - Marketing-style README rewrite + Russian translation (`README.ru.md`). - `docs/` split: `USAGE.md` (end-user reference) and `CONTRIBUTING.md` (dev/release flow). - `.github/assets/hero.png` — interactive map screenshot for the npm landing. - `LICENSE` (MIT) added at repo root and declared in `package.json#files`. -- `CHANGELOG.md` (this file) added at repo root and declared in `package.json#files`. -- Demo console block in both READMEs aligned to the actual `init` output. -- Cross-platform CI badge URL switched from relative to absolute (so it renders - on the npm registry landing page, not only on GitHub). +- `CHANGELOG.md` added at repo root and declared in `package.json#files`. - `.claude/rules/00-index.md` now lists rule 12 (`forgeplan-agent-dispatch`). -### Security - -Tactical hardening pass per PRD-002 (S1 batch). +### Fixed -- **CWE-78** — `FORGEPLAN_BIN` regex-validated at module load in `template/src/shared/server/forgeplan.ts`. Refuses spawn (returns `502` envelope) when the env var contains characters outside `[A-Za-z0-9_./:\-]`. Closes a Windows `shell:true` command-injection vector. -- **CWE-59** — `bin/forgeplan-web.mjs#update` now `lstat`s `.forgeplan-web/` before `rmSync`; refuses to operate when target is a symlink. Plus a `resolve()`-equality assert as defense-in-depth against future refactors. -- **CWE-770** — `template/src/shared/server/forgeplan.ts` enforces in-process spawn concurrency cap (4 simultaneous `forgeplan` processes). Bounds loopback / LAN-bound DoS surface. -- **CWE-1357** — `scripts/build.mjs#installRuntimeDeps` passes `--ignore-scripts` to `npm install`. Blocks transitive postinstall hooks from baking arbitrary code into published `dist/node_modules/`. +- Demo console block in both READMEs aligned to the actual `init` output. +- Cross-platform CI badge URL switched from relative to absolute (renders on the npm registry landing page, not only on GitHub). ## [0.1.4] - 2026-05-04 @@ -114,7 +120,9 @@ host project via `npx @forgeplan/web init -y`. No `npm install` at user side: `dist/` ships its own `node_modules/` populated with `--omit=dev --omit=peer`. -[Unreleased]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.4...HEAD +[Unreleased]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.6...HEAD +[0.1.6]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.5...v0.1.6 +[0.1.5]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/ForgePlan/forgeplan-web/compare/v0.1.1...v0.1.2 diff --git a/package.json b/package.json index af6716d..549ec48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@forgeplan/web", - "version": "0.1.5", + "version": "0.1.6", "description": "Interactive realtime web map for a Forgeplan workspace. Ships a pre-built SvelteKit app and a tiny init/start CLI — no npm install at user side.", "type": "module", "bin": { diff --git a/template/package.json b/template/package.json index 646d2bb..6e9951d 100644 --- a/template/package.json +++ b/template/package.json @@ -1,7 +1,7 @@ { "name": "forgeplan-web-app", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite dev --port 5174 --host 127.0.0.1",