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" } } 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/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 new file mode 100644 index 0000000..dc73d27 --- /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,120 @@ +--- +created: 2026-05-04 +depth: standard +id: PRD-002 +kind: prd +priority: P1 +status: active +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..ddb0fd9 100644 --- a/.forgeplan/session.yaml +++ b/.forgeplan/session.yaml @@ -1,7 +1,7 @@ -phase: routing +phase: idle active_artifact: null -route_depth: tactical -phase_started_at: 2026-05-04T15:37:06.933414+00:00 +route_depth: null +phase_started_at: 2026-05-05T07:09:25.139699+00:00 history: - from: routing to: shaping @@ -59,3 +59,19 @@ 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: 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 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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ad0d9..8c979e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,33 @@ 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`). +### Fixed + +- 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 ### Added @@ -105,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/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() { 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/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, ); } 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", 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", 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 {