Skip to content

fix(security): harden context, dry-run, manifest, and glob handling#488

Open
toshtag wants to merge 145 commits into
mainfrom
fix/security-hardening-bd84281
Open

fix(security): harden context, dry-run, manifest, and glob handling#488
toshtag wants to merge 145 commits into
mainfrom
fix/security-hardening-bd84281

Conversation

@toshtag

@toshtag toshtag commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Addresses the Codex security review of bd84281 (/tmp/codex-security-scans/code-pact/bd84281_20260618T142512Z/report.md): 7 findings — 2 high, 5 medium. Every fix ships an attacker-scenario regression test; user-visible behavior changes are documented in CHANGELOG.md + docs/cli-contract.md.

Codex findings → resolution

# Sev Finding Resolution Source
1 High Context pack follows design/constitution.md symlink outside the project (CWE-59) loadConstitution reads via readWithinProject/resolveWithinProject — same project-contained path as rules/decisions; unsafe → null src/core/pack/loaders.ts
2 High task complete --dry-run executes verification shell commands (CWE-78) Propagate dryRun into runVerify; commands check is previewed not executed (shell:true never runs); read-only decision gate still runs src/commands/task-complete.ts
4 Med Manifest paths write/read through .code-pact/adapters symlink escape (CWE-59) New resolveManifestPathread/writeManifest resolve via resolveWithinProject; fail-closed on escape (throws, not treated as missing) src/core/adapters/manifest.ts
5 Med Predictable atomic-write temp paths can clobber symlink targets (CWE-59/377) Temp names are randomUUID + exclusive create flag:"wx" (O_CREAT|O_EXCL, refuses + never follows a symlink); EEXIST retry; test seam src/io/atomic-text.ts
3 Med Forged manifest preserves malicious generated instructions on install (CWE-345) decideAction: install managed-clean × staleupdate (re-render) instead of skip; install command handles update. managed-modified still untouched src/core/adapters/file-state.ts, src/commands/adapter-install.ts
6 Med Forged manifest orphan entries can delete arbitrary in-project files (CWE-73) Orphan auto-prune gated on descriptor.ownedPathGlobs; unowned orphan → warn (kept on disk, kept tracked, explained in CLI). See trade-off below src/commands/adapter-upgrade.ts, src/cli/commands/adapter.ts
7 Med Repeated ** globs cause regex ReDoS (CWE-1333) Linear two-pointer matchGlob replaces backtracking globToRegex on walk/audit/doctor hot paths; MAX_GLOB_LENGTH cap in validateGlobSyntax src/core/glob.ts, src/core/audit/write-audit.ts, src/commands/doctor.ts

Regression tests added

User-visible behavior changes (3)

  1. task complete --dry-run — a dry run whose only failing check is a command no longer exits 1; it returns a clean dry_run preview (exit 0). The decision gate still runs. Non-dry-run completion is unchanged.
  2. adapter install — a managed-clean file whose content is stale vs the generator is re-rendered (update) instead of skipped. Genuinely user-modified (managed-modified) files are still left untouched.
  3. adapter upgrade --write — orphans outside the adapter's owned path set are no longer auto-deleted; they are surfaced (action:"warn") and kept on disk with an explanatory CLI message.

Design decision on #6 (option A — adopted)

Security trade-off: adapter upgrade no longer auto-prunes orphaned Claude skill files unless strong ownership can be proven. This intentionally favors preserving user files over automatic cleanup. Safe auto-prune will be revisited in a follow-up design using reserved generated-file namespaces or stronger ownership markers.

  • Option B (widen ownedPathGlobs to .claude/skills/*.md) — rejected. The manifest is project-controlled; a namespace glob over a shared directory would let a forged manifest delete user-authored skills — the same vulnerability class in a different directory, which the review explicitly warned against.
  • Option C (strong ownership → restore safe auto-cleanup) — deferred to a separate, non-hotfix change (needs a filename migration): Safe orphan auto-cleanup via strong ownership (reserved namespace / generated-file markers) #487.

Verification

The initial-review gate passed (typecheck / unit / integration / check:docs all green). Current
counts after all hardening rounds are in Verification (latest) at the
bottom — that is the single source of truth; the per-round numbers above are historical.

Independent adversarial review (internal code-reviewer pass) confirmed matchGlobglobToRegex parity, atomic-text exclusive-create correctness, and no other missed manifest-trust delete paths; its one diagnostic note (adapter list classifying a symlink-escape throw as manifestInvalid) was addressed by documenting the intentional tolerant degradation (the lister must not throw and surfacing-as-unusable beats masking-as-absent).

Follow-up: #487 — safe orphan auto-cleanup via strong ownership.


Additional hardening (post-initial-review adversarial rounds)

Several further adversarial review passes on this branch extended the same fix classes — out-of-project read/write, partial side effects before a fail-closed check, and glob-matcher consistency. Each fix ships regression tests and the full gate stays green.

Area Fix Source
Malformed manifest YAML-parse / schema-invalid manifests now map to a structured ADAPTER_MANIFEST_INVALID (exit 2) in adapter install/upgrade, not an uncoded internal error (exit 3) src/core/adapters/manifest.ts, src/cli/commands/adapter.ts
Planning prompt leak plan prompt reads design/brief.md / design/constitution.md through a shared readProjectTextOrNull (symlink-escape → null), closing the same agent-facing leak class as finding #1 for another command src/core/project-read.ts, src/commands/plan-prompt.ts, src/core/pack/loaders.ts
Agent-profile containment resolveAgentProfilePath resolves through resolveWithinProject (escape → CONFIG_ERROR), so a symlinked .code-pact/agent-profiles cannot redirect a profile read — or the --model pin's write — outside the project src/core/agent-profile-path.ts
No pre-failure side effect adapter install/upgrade read the manifest AND preflight every write path (placeholder dirs + generated files + orphan candidates via resolveWithinProject) BEFORE persisting the --model pin, so a doomed run leaves no pin/write/unlink src/commands/adapter-install.ts, src/commands/adapter-upgrade.ts, src/core/adapters/file-state.ts
Path-safety code resolveWithinProject tags an escape PATH_OUTSIDE_PROJECT; decision prune/retire keep classifying it as target_invalid; code registered in the error-code surface contract src/core/path-safety.ts, src/core/decisions/prune.ts, src/core/decisions/retire.ts
Dangling symlinks resolveWithinProject is now a write-safe containment preflight: it walks per-component and refuses ALL dangling symlinks (in- or out-of-project) and cycles via realpath, instead of mistaking a dangling link for a safe not-yet-created path (which let a later mkdir strand a partial side effect) src/core/path-safety.ts
Glob matcher parity findProtectedPathOverlaps uses the runtime matchGlob, and globToRegex collapses adjacent ** so the two agree — a repeated-** write (e.g. design/**/**/roadmap.yaml) can no longer match a protected file at runtime while evading the protected-path advisory. Also: globToRegex escapes ? (a literal in this subset) and expands ** as [\s\S]* so it truly matches matchGlob (enforced by a generative parity test) src/core/glob.ts
Atomic temp cleanup createExclusiveTemp claims the temp via open(..,"wx") then writes through the handle; a mid-write failure (EFBIG/ENOSPC/EIO) closes + unlinks the partial temp instead of leaking a .tmp-<uuid> (the Round-1 symlink-clobber fix had regressed failure-path cleanup) src/io/atomic-text.ts
Typed write preflight the adapter write preflight checks on-disk TYPE as well as containment: a directory spec that is a regular file (mkdir EEXIST), a file spec that is a directory (write EISDIR), or a non-directory intermediate (ENOTDIR) → CONFIG_ERROR BEFORE the --model pin — so a forged profile/manifest of the wrong type can no longer strand a pin or crash with exit 3 src/core/adapters/file-state.ts, install/upgrade
Attacker-input read errors non-ENOENT read failures on project-controlled paths map to structured errors, not an uncoded exit 3: readManifest (a directory manifest path → EISDIR) → ADAPTER_MANIFEST_INVALID; adapter doctor's diagnostic read degrades all errors to null; classifyDecisionAdrs / plan lint's ADR reads route through the contained readLiveDecisionFile seam + degrade (a *.md directory or symlink-escaping ADR no longer crashes plan lint); loadModelProfiles skips a *.yaml directory src/core/adapters/manifest.ts, src/commands/adapter-doctor.ts, src/core/decisions/adr.ts, src/core/plan/lint.ts, install/upgrade
Arbitrary file overwrite (HIGH) AgentProfile.instruction_filename and manifest files[].path are both attacker-controlled, so a forged manifest (hash == a victim file's real hash) + a profile pointing the instruction/skill path at e.g. package.json made it managed-clean × stale → auto-update (overwrite) on a plain adapter install. A content overwrite (update/replace_unmanaged) is now gated on a NEW trusted-static overwriteOwnedPathGlobs namespace (separate from, and broader than, the narrow delete gate) — a generated path outside it is refused, never written on manifest trust. This was the blind spot in the original managed-clean × stale → update self-heal src/core/adapters/types.ts, src/core/adapters/claude.ts, src/commands/adapter-install.ts, src/commands/adapter-upgrade.ts
Control-plane read containment loadPhase / loadRoadmap (mandatory inputs rendered into the context pack AND generated Claude skills) route through resolveWithinProjectCONFIG_ERROR on escape, so a .. phase ref or a symlinked design/ no longer leaks an out-of-project phase's objective/tasks/verification.commands src/core/plan/load-phase.ts, src/core/plan/roadmap.ts, src/core/adapters/claude.ts
Malformed profile + special files adapter install/upgrade map a malformed/schema-invalid agent profile to CONFIG_ERROR (was uncoded exit 3); the typed write preflight requires a regular file (st.isFile()), so a FIFO/socket/device at a generated path is refused before the pin instead of blocking a later readFile forever src/commands/adapter-install.ts, src/commands/adapter-upgrade.ts, src/core/adapters/file-state.ts

These rounds were increasingly proactive — an internal adversarial code-reviewer sweep found the typed-preflight gap, the temp-leak, and several uncoded-errno readers before they shipped, and a new fast static fs-containment tripwire (scripts/check-fs-containment.mjs, wired as a local PostToolUse edit-time hook — NOT added to CI, which is already ~3.5 min) catches the lexical-join containment class at authoring time. pnpm check:fs-containment reports the remaining ~27-line migration backlog (the open follow-up to contain .code-pact/project.yaml / model-profiles reads).

Ownership vs containment (combination-attack round)

A further adversarial round found that the overwrite/prune authorization conflated containment (inside the project) with ownership (a path the generator actually owns):

Area Fix Source
Symlinked owned namespace (HIGH) the overwrite/prune gate matched the LEXICAL path against owned globs, but resolveWithinProject allows an in-project symlink and returns the lexical path — so .claude/skills -> ../src let an owned-looking .claude/skills/context.md resolve to src/context.md and a forged manifest overwrite/prune the real file. A destructive AUTO action (update/replace_unmanaged/prune) on a path that traverses ANY symlink component is now refused (new pathTraversesSymlink) — lexical path must equal the real destination src/core/path-safety.ts, src/commands/adapter-install.ts, src/commands/adapter-upgrade.ts
Shared skills namespace (HIGH) overwriteOwnedPathGlobs had widened the auto-overwrite set to .claude/skills/*.md — a dir SHARED with hand-authored user skills, so (no symlink, no --force) a verification command whose derived name collides with a user skill + a forged manifest hash overwrote that user skill with generator content carrying the attacker's command. Reverted: the overwrite gate now uses the EXACT static ownedPathGlobs (built-ins only); dynamic command-skills are refused when stale (a reserved generated-skill namespace is the follow-up) src/core/adapters/types.ts, src/core/adapters/claude.ts, install/upgrade
pack error mapping loadPhase/loadRoadmap now map non-ENOENT read + YAML/schema failures to CONFIG_ERROR (ENOENT stays raw for the archived-fallback), and cmdPack maps CONFIG_ERROR to exit 2 — so a symlinked / malformed / directory phase ref no longer exit-3's from pack src/core/plan/load-phase.ts, src/core/plan/roadmap.ts, src/cli.ts

Also tracked, NOT in this PR: #489adapter doctor / conformance read manifest-declared entry paths via lexical join (final-component / parent symlink not contained). These are advisory surfaces only (checksums / check results, never a write); tracked as security debt. The model-profiles reads in install/upgrade are now contained; pnpm check:fs-containment reports the remaining backlog (.code-pact/project.yaml and a few others) for the "unify all project-metadata reads through resolveWithinProject" follow-up.

Verification (latest)

Command Result
pnpm typecheck clean
pnpm test:unit 3467 passed, 12 skipped
pnpm test:integration 702 passed
pnpm check:docs all checks OK
GitHub Actions full (Node 22) / smoke (Node 24) green on HEAD

Roadmap-read containment completeness (cross-command round)

A cross-command audit found that the contained loadRoadmap was bypassed by several direct reads, and that the resulting CONFIG_ERROR was not mapped by every command:

Area Fix Source
Roadmap read bypass resolveTaskInRoadmap (shared by all task * commands), phase archive, phase reconcile, and plan adopt did their own readFile(join(cwd,'design/roadmap.yaml')) — bypassing containment. All now use loadRoadmap(cwd) src/core/plan/resolve-task.ts, src/commands/phase-archive.ts, src/commands/phase-reconcile.ts, src/commands/plan-adopt.ts
CONFIG_ERROR mapping every consumer (task complete/record-done/finalize/status/start/block/resume/add, verify, phase archive/reconcile) maps a contained-loader CONFIG_ERROR to exit 2, plus a top-level safety net in main() so any unmapped one is still a clean envelope, never exit 3 src/cli.ts, src/cli/commands/task.ts, src/cli/commands/phase.ts
model-profiles dir loadModelProfiles now resolveWithinProjects the directory before readdir (no out-of-project enumeration of a symlinked dir) install/upgrade
refusal guidance adapter refusals carry a machine-readable reason (managed_modified / unowned_generated_path / symlink_traversal); --accept-modified is only suggested for managed_modified (the security refusals are not overridable by it) src/cli/commands/adapter.ts, install/upgrade
tripwire check-fs-containment now also catches the multiline fsfn(⏎ join(…)) form (the variable-indirection form remains the AST-lint / projectFs follow-up) scripts/check-fs-containment.mjs
PlanState family (last roadmap consumers) loadPlanState (strict — task/phase runbook, status, plan analyze) + collectPlanArtifacts / scanPhasesDirBestEffort (lenient — decision prune/retire, plan lint) read via resolveWithinProject: strict → CONFIG_ERROR, lenient → a graph-file FileIssue so planArtifactsUnreadable() fail-closes. This closes a real destructive mis-authorization: a roadmap symlinked to an external EMPTY roadmap could hide the current project's referencing not-done task and let decision prune/retire --write delete a still-referenced decision src/core/plan/state.ts
Backwards CLI help adapter --force / --accept-modified help described the OPPOSITE of the implemented behavior (a footgun on destructive flags) — corrected src/cli/usage.ts

Scope boundary — deferred to a dedicated follow-up PR

The design/ writer commands (plan normalize --write, plan sync-paths --write, phase add/new, spec import --write, plan brief --force, plan constitution --force, createPhase) can still write through a symlinked design/ to outside the project. This is a real, distinct class — but containing every project WRITE (plus a projectFs chokepoint + a no-direct-node:fs lint) is its own large change, and piling it onto this already-large PR would itself be a review/regression risk. It is intentionally out of scope for #488 and tracked as the next security follow-up; pnpm check:fs-containment already surfaces the in-repo candidates for it.

Address the Codex security review (scan bd84281). Each fix adds attacker-
scenario regression tests; behavior changes are documented in CHANGELOG +
cli-contract.

- Context pack: loadConstitution reads via resolveWithinProject (no symlink
  escape into the agent-facing pack). [CWE-59]
- task complete --dry-run: propagate dryRun into verify so project-controlled
  verification.commands (shell:true) are previewed, not executed. [CWE-78]
- Adapter manifest I/O: read/writeManifest resolve through resolveWithinProject,
  fail-closed on a .code-pact/adapters symlink escape. [CWE-59]
- Atomic writes: crypto-random temp names + exclusive create (wx/O_EXCL) so a
  pre-planted temp symlink is refused, never followed. [CWE-59/377]
- adapter install: managed-clean × stale re-renders (update) instead of trusting
  a project-shipped manifest hash to keep stale/forged content; managed-modified
  still untouched. [CWE-345]
- adapter upgrade: orphan auto-prune gated on descriptor.ownedPathGlobs; an
  unowned orphan is surfaced (warn) and kept, with a CLI message naming the file,
  the reason, and the manual-removal step. A forged manifest can no longer turn
  upgrade --write into an arbitrary in-project delete. [CWE-73]
- Glob matching: linear two-pointer matchGlob replaces the backtracking
  globToRegex on the walk/audit/doctor hot paths; pattern-length cap added. [CWE-1333]

Security trade-off (#6): upgrade no longer auto-prunes orphaned generated files
unless strong ownership can be proven. This intentionally favors preserving user
files over automatic cleanup; safe auto-prune is deferred to a follow-up design
using reserved generated-file namespaces / stronger ownership markers.
@toshtag toshtag self-assigned this Jun 19, 2026
…divergent install files

Round 2 — address the two pre-merge blockers from the follow-up review.

Blocker 1 — `.code-pact/adapters` symlink escape no longer surfaces as an
internal error / exit 3. `resolveManifestPath` re-throws the path-containment
refusal with code `ADAPTER_MANIFEST_INVALID`; `adapter install` and
`adapter upgrade` (--check / --write) map it to a structured envelope (exit 2)
in both --json and human modes. Doctor already degrades it to an issue.

Blocker 2 — `adapter install` no longer SILENTLY skips a managed file that
matches neither the manifest hash nor the generator output (managed-modified ×
stale — the shape a hostile repo ships). decideAction returns `refuse` for that
cell; install keeps the file (could be a real edit) but reports it via
`result.refused[]` / `files[].action:"refuse"`, prints the file + the
`--accept-modified` regenerate step, and exits 1.

Also (additive): unowned-orphan `warn` plan entries now carry a machine-readable
`reason: "unowned_orphan_not_pruned"` for JSON consumers.

Docs (cli-contract + CHANGELOG) updated for all three. Verification: typecheck
clean, unit 3438 passed, integration 664 passed, check:docs OK.
@toshtag

toshtag commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Round 2 — pre-merge blockers addressed (commit 5292283)

Both conditional-approval blockers from the follow-up review are fixed; follow-ups filed.

Blocker 1 — manifest symlink escape now a clean CLI error (not internal error / exit 3)

resolveManifestPath re-throws the path-containment refusal with code ADAPTER_MANIFEST_INVALID; adapter install and adapter upgrade (--check / --write) map it to a structured envelope exit 2 in both --json and human modes. Fail-closed is preserved (no bytes read/written outside the project); doctor already degrades it to an issue.

  • New integration tests (adapter-cli.test.ts): install --json, install human, upgrade --check --json, upgrade --write --json — each with .code-pact/adapters symlinked outside → exit 2 + { ok:false, error:{ code:"ADAPTER_MANIFEST_INVALID" } }, no internal error, nothing written to the outside target.

Blocker 2 — adapter install no longer silently skips a divergent managed file

decideAction returns refuse for install managed-modified × stale (disk matches neither the manifest hash nor the generator output — the shape a hostile repo ships). Install keeps the file (could be a real edit) but surfaces it: result.refused[] + files[].action:"refuse", a human warning naming the file and the adapter upgrade --write --accept-modified regenerate step, and exit 1 (mirrors upgrade's refuse→exit 1). It is never passed over in silence.

  • Tests: unit (adapter-upgrade.test.ts "manifest trust" — refuse + in refused[], not in skipped[], not overwritten; adapter-file-state.test.ts matrix → refuse) + integration (human exit 1 + warning, --json action refuse + refused[]).

Additive (follow-up #2 from the review, done now)

Unowned-orphan warn plan entries now carry a machine-readable reason: "unowned_orphan_not_pruned" so JSON consumers don't have to parse prose.

Follow-ups filed (not blocking)

Verification (re-run)

Command Result
pnpm typecheck clean
pnpm test:unit 3438 passed
pnpm test:integration 664 passed (+6)
pnpm check:docs all OK

Docs updated: cli-contract.md (top-level ADAPTER_MANIFEST_INVALID row, install refuse/exit-1 matrix row, warn reason) + CHANGELOG.md.

toshtag added 25 commits June 19, 2026 11:58
… root

`plan prompt` read design/brief.md and design/constitution.md via join(cwd, ...) + readFileOrNull, so a repo that symlinks either file out of the project leaked the target's contents into the agent-facing prompt (and the --clipboard payload) — the same out-of-project-read → agent-facing leak class as the context pack (CWE-59).

Route both reads through a shared readProjectTextOrNull (resolveWithinProject: .. / absolute / symlink-escape → null) and reuse it from the context pack's loadConstitution so every agent-facing grounding read shares one guard.
…losed

resolveWithinProject now tags a symlink/unsafe-path escape with a stable PATH_OUTSIDE_PROJECT errno code so command layers can map it to a structured envelope instead of an internal error.

That additive code silently broke the `decision prune` / `decision retire` target classification, which keyed the escape branch off `code === undefined` (the old code-less throw) and so demoted an escaping target from target_invalid to target_unreadable. Detect the escape via the explicit code (keeping the code-less ZodError path for structural rejections), and register PATH_OUTSIDE_PROJECT in the error-code surface contract + cli-contract.md.
…clean error

readManifest let a YAML parse error or a Zod schema violation throw uncoded, so adapter install / upgrade surfaced a project-controlled (adversarial) manifest as an internal error / exit 3. Wrap the parse+validate step and tag it ADAPTER_MANIFEST_INVALID; ENOENT still degrades to null and the tolerantDuplicatePaths repair path is unchanged.
…lder mkdir, CLI mapping)

- install reads the manifest AND resolves the placeholder dirs (.context / hook_dir, via resolveWithinProject) BEFORE persisting the --model pin, so a doomed install (manifest escape/invalid, or a symlinked placeholder dir) leaves no partial side effect — it never rewrites the profile's model_version.

- install/upgrade route the placeholder mkdir through resolveWithinProject so a symlinked .context / .claude cannot create a directory outside the project.

- the CLI maps ADAPTER_MANIFEST_INVALID (now also malformed/schema-invalid) and PATH_OUTSIDE_PROJECT (→ CONFIG_ERROR) to structured envelopes (exit 2).

Adds integration coverage for all of the above: malformed/schema-invalid manifest on install + upgrade --check/--write, no model pin on a failed --model install, and .context/.claude placeholder symlink escape.
resolveAgentProfilePath returned join(cwd, ".code-pact", rel) — lexical only. A symlinked .code-pact/agent-profiles (or a symlinked profile file) let a profile READ, and crucially the `--model` pin's atomicWriteText, escape the project root (CWE-59). manifest I/O was contained but the profile path was not.

Route the resolved path through resolveWithinProject (the single resolver every read + the pin flow shares) and map a symlink escape to CONFIG_ERROR — consistent with this resolver's existing throws, so every caller's CONFIG_ERROR handling applies unchanged with no new code to map at each call site.
adapter install moved the placeholder mkdir before the pin, but the generated-file write loop (and upgrade's pin → mkdir → write/prune order) still ran the path-safety checks AFTER persisting the --model pin. So an install/upgrade --model that ultimately fails closed on a symlink escape (.context/.claude, a forged manifest path, or a final-component symlink like CLAUDE.md) could strand a pinned model_version.

Add a shared assertAdapterWritePathsContained preflight (resolveWithinProject over placeholder dirs + every generated file + manifest-tracked orphan candidates) and run it BEFORE the deferred pin in both install and upgrade --write. A doomed run now leaves no pin, no write, no unlink. Escapes surface as PATH_OUTSIDE_PROJECT → CONFIG_ERROR (exit 2).

Adds integration coverage: upgrade --write --model .context no-pin, .code-pact/agent-profiles symlink escape (install + upgrade), and CLAUDE.md final-symlink no-pin / out-of-project target unwritten (install + upgrade).
realpath() reports a DANGLING symlink (target absent) as a bare ENOENT, indistinguishable from a not-yet-created path. The walk-up containment check therefore mistook .context -> /outside/does-not-exist for a safe missing path and returned ACCEPTED, so the Round-4 preflight could pass and then pin the profile / mkdir outside the project. The same gap let a dangling .code-pact/adapters read as null (no manifest), risking a partial generated-files-but-no-manifest state at the later writeManifest.

Rewrite resolveWithinProject to canonicalize the path one component at a time from the real project root via lstat/readlink, following a symlink to where it POINTS regardless of target existence; the final canonical location must stay in the project. Contract: non-existent in-project path and in-project (incl. dangling) symlinks are allowed; any symlink (existing or dangling) pointing outside maps to PATH_OUTSIDE_PROJECT; a symlink cycle (> 40 hops) maps to PATH_OUTSIDE_PROJECT instead of a raw error.

Tests: resolveWithinProject dangling-ancestor / dangling-final / in-project-dangling / symlink-cycle / ordinary-deep-nonexistent unit cases; adapter integration cases (dangling .context -> no model pin; dangling .code-pact/adapters -> ADAPTER_MANIFEST_INVALID, no pin, no partial state).
…blestar

matchGlob (the runtime walk / write-audit matcher) let adjacent ** segments each match zero, but globToRegex compiled **+** into a form that forced an intermediate segment. So design/**/**/roadmap.yaml matched design/roadmap.yaml at runtime yet evaded findProtectedPathOverlaps (which used globToRegex) — a declared write could touch a protected path while dodging the advisory warning.

Switch findProtectedPathOverlaps to matchGlob (the same matcher as the runtime walk, parity by construction) AND collapse adjacent ** runs in globToRegex so the two agree. Correct the doc comments that claimed exact parity.

Tests: matchGlob/globToRegex parity over adjacent-doublestar patterns, and a findProtectedPathOverlaps case proving design/**/**/roadmap.yaml is flagged for the protected design/roadmap.yaml.
…e-safe preflight)

The Round-5 walk ALLOWED an in-project symlink whose target was absent (dangling-but-contained). For a READ that is fine, but as a WRITE preflight it is wrong: .context -> <cwd>/missing passed the preflight, install then persisted the --model pin, and the subsequent mkdir(.context/claude) failed with ENOENT — leaving the pin as a partial side effect (and an uncoded error / exit 3). A dangling .code-pact/adapters -> <cwd>/.missing similarly read as 'no manifest' and risked a generated-files-but-no-manifest partial state.

Switch resolveWithinProject to a simpler, more OS-portable shape: walk each component; on a symlink call realpath(candidate) — success continues from the canonical target (chains / macOS case-insensitive / Windows handled by the OS), ENOENT means dangling and is refused, ELOOP is refused. A PLAIN (non-symlink) missing component still ends the walk and is allowed, so creating new files/dirs works. Net contract: only plain not-yet-created paths and existing in-project paths/symlinks are allowed; ALL dangling symlinks (in- or out-of-project) and cycles map to PATH_OUTSIDE_PROJECT.

Tests: flip the in-project-dangling unit case to expect refusal; add adapter integration cases for internal-dangling .context (install + upgrade -> CONFIG_ERROR, no pin) and internal-dangling .code-pact/adapters (install -> ADAPTER_MANIFEST_INVALID, no pin, no partial state).
createExclusiveTemp wrote via writeFile(flag:"wx"); if the file was created but the write then failed (EFBIG/ENOSPC/EIO), the catch only handled EEXIST and rethrew everything else — leaving a partial .tmp-<uuid> behind, because writeThenRename's unlink cleanup only runs AFTER createExclusiveTemp returns the path. The Round-1 #5 symlink-clobber fix had regressed the failure-path cleanup.

Claim ownership with open(tmp,"wx") (O_CREAT|O_EXCL — still refuses + never follows a symlink), then write via the handle; on ANY post-open failure close the handle and unlink the partial temp before rethrowing. An EEXIST from open is NOT ours, so it is retried (fresh token) and never unlinked. Adds a test seam (__setAtomicWriteFailAfterOpenForTests) and tests proving no temp leak + untouched destination on a mid-write failure (both write and replace paths).
…nes)

validateGlobSyntax accepts '?' as a literal and matchGlob treats it as one, but globToRegex did NOT escape it: globToRegex("a?") became a quantifier (disagreeing with matchGlob) and globToRegex("?") threw a SyntaxError. Also '**' compiled to '.*', which (unlike matchGlob's '**') does not match a newline-containing segment.

Add '?' to the regex-escape set and expand '**' with [\s\S]* instead of '.*'. globToRegex is no longer used on a prod hot path (findProtectedPathOverlaps moved to matchGlob), but it is exported + the 'parity' claim must actually hold. Adds '?' and newline parity cases plus a deterministic generative parity test over the full validated subset (literals incl. regex metachars, '?', '*', '**').
… not exit 3

A project-controlled path that exists as the WRONG type (a directory where a file is read) makes readFile throw EISDIR — an UNCODED errno that surfaced as an internal error / exit 3, the same contract-break class as malformed manifests. Closed across the readers that take attacker-controlled paths:

- readManifest: non-ENOENT read failures (EISDIR when the manifest path is a directory, ENOTDIR, EACCES, a symlink that breaks on read) now map to ADAPTER_MANIFEST_INVALID; ENOENT still degrades to null. - adapter doctor readFileMaybe: a diagnostic read degrades ALL errors to null (a directory at a managed path reads as missing/drift, never a crash). - classifyDecisionAdrs (adr.ts) + detectAdrAcceptedBodyThin (plan lint): route through the project-contained readLiveDecisionFile seam + degrade on error, so a directory named *.md (EISDIR) or a design/decisions symlinked outside no longer crashes plan lint and is contained.

Tests: classifyDecisionAdrs skips a *.md directory (EISDIR) and a symlink-escaping ADR; adapter doctor reports (does not throw on) a managed path that is a directory.
…pe before the pin)

The write preflight checked CONTAINMENT but not path TYPE, so a forged profile/manifest pointing a write at an existing entry of the wrong type passed it, then failed the real op AFTER the --model pin — stranding a pinned model_version and surfacing an uncoded errno (exit 3). E.g. context_dir occupied by a regular file (mkdir EEXIST), or CLAUDE.md occupied by a directory (write EISDIR).

assertAdapterWritePathsContained now takes typed specs ({path, kind:'directory'|'file'}): after the containment check it stats the entry and rejects a directory-spec that is a file / a file-spec that is a directory / a non-directory intermediate component as CONFIG_ERROR — all BEFORE the pin (containment+type failures occur before any mutation; the comment no longer overclaims crash-atomicity). install/upgrade pass typed specs for placeholder dirs (directory) + generated files + orphan candidates (file). Also harden loadModelProfiles: a directory named *.yaml is skipped (readFile moved inside the try) rather than crashing the command.

Tests: typed-preflight unit cases (file-as-dir, dir-as-file, intermediate-file, symlink escape still PATH_OUTSIDE_PROJECT); integration cases for context_dir-as-file (install + upgrade --model: CONFIG_ERROR, no pin), CLAUDE.md-as-dir (install: no pin, no internal error), and manifest-path-as-directory (install human/json + upgrade check/write: ADAPTER_MANIFEST_INVALID).
The mandatory control-plane loaders read via lexical join(cwd, path), so a roadmap phase ref with .. or a symlinked design/ or design/phases/* pulled an out-of-project file into the agent-facing context pack AND into generated Claude skills (verification.commands) — the same out-of-project-read class as the original constitution-symlink finding, on the must-read path.

Route both through resolveWithinProject; a path-safety refusal maps to CONFIG_ERROR (fail-closed + structured — these are control-plane inputs, never degraded to null). A missing/invalid roadmap or phase still throws ENOENT/ZodError as before.

Tests: loadRoadmap refuses a symlinked design/roadmap.yaml; loadPhase refuses a symlinked phase ref and a .. ref — all CONFIG_ERROR.
…y project files

Blocker 1 (HIGH): AgentProfile.instruction_filename and manifest files[].path are BOTH attacker-controlled. A forged manifest hash == a victim file's real hash made it managed-clean; since it differs from generated content it was stale → auto-update (overwrite) on a plain 'adapter install'. So a profile pointing instruction_filename at e.g. package.json + a matching forged manifest destroyed that file. A content OVERWRITE (update / replace_unmanaged) is now gated on a NEW trusted-static overwriteOwnedPathGlobs namespace (CLAUDE.md + .claude/skills/*.md for claude — separate from, and broader than, the narrow delete gate); a path outside it is refused, never written on manifest trust. This was the blind spot in the original managed-clean x stale -> update self-heal.

Blocker 3: install/upgrade loadAgentProfile parsed YAML + Zod OUTSIDE the read try, so a malformed/schema-invalid project profile threw uncoded (exit 3). Now: ENOENT->AGENT_NOT_FOUND, other read error / parse / schema -> CONFIG_ERROR.

Blocker 4: the typed write preflight allowed any non-directory as a 'file', so a FIFO/socket/device passed and a later readFile BLOCKED after the --model pin (hang + stranded pin). The file kind now requires a regular file (st.isFile()), refused before the pin.

Also routes claude's readVerificationCommands through the contained loadRoadmap. Tests: forged-manifest+profile overwrite is refused (exit 1, --force too); malformed/schema-invalid profile -> CONFIG_ERROR on install + upgrade check/write; FIFO file spec -> CONFIG_ERROR.
…k engine)

Answers enforce-this-without-bloating-the-3.5min-CI: a sub-second static check for the path-CONTAINMENT bug class the adversarial review kept finding — a project path read/written via lexical join() instead of resolveWithinProject(), which follows .. / symlinks out of the project. It is the engine behind a LOCAL PostToolUse(Edit|Write) hook (.claude/ is gitignored by repo policy, like the existing guard-push / guard-managed-skills hooks) that surfaces the smell at EDIT time, before any review round-trip or CI run.

scripts/check-fs-containment.mjs: with file args it checks just those (the hook mode); with none, pnpm check:fs-containment sweeps src/commands,core,cli as a migration report (currently ~27 known lexical reads — the backlog to contain .code-pact/project.yaml / model-profiles / a few ADR reads; some are the open follow-ups). Deliberately NOT wired into the gate yet: a blocking check needs that baseline migrated or marked with an fs-safe reason first. It catches only the MECHANICAL containment class — not design bugs like trusting a manifest hash as path ownership, which still need review.
… pack

Round-8 made loadPhase/loadRoadmap throw CONFIG_ERROR on a path-safety escape, but cmdPack's catch only handled PHASE_NOT_FOUND / AMBIGUOUS_PHASE_ID / TASK_NOT_FOUND, so an out-of-project-symlinked phase made 'pack --json' fall through to the top-level internal error / exit 3 instead of a structured CONFIG_ERROR / exit 2 (task context already mapped it). Also the loaders only coded the resolveWithinProject refusal — a phase path that is a directory (EISDIR), an intermediate file (ENOTDIR), unreadable (EACCES), or malformed YAML/schema stayed uncoded and likewise exit-3'd from pack.

cmdPack now maps CONFIG_ERROR to an exit-2 envelope. loadPhase/loadRoadmap now map NON-ENOENT read failures AND YAML/schema-parse failures to CONFIG_ERROR while keeping ENOENT RAW (resolve-task's archived-fallback keys on code === ENOENT, and no caller inspects the ZodError) — so attacker-controlled phase/roadmap input is always structured, never exit 3, with the legitimate missing-phase path unchanged.

Test: pack with a phase file symlinked outside → exit 2 / CONFIG_ERROR, no internal error, and the foreign phase's marker never reaches the pack.
…y, not lexical path

Blocker 1 (HIGH): the overwrite/prune ownership gate matched the LEXICAL path against owned globs, but resolveWithinProject ALLOWS in-project symlinks and returns the lexical path — so .claude/skills -> ../src makes the owned-looking .claude/skills/context.md resolve to src/context.md, and a forged manifest then overwrote (or pruned) the real src file via the symlink. PoC-confirmed. New pathTraversesSymlink(cwd, relPath) check: a destructive AUTO action (update / replace_unmanaged / prune) on a path that traverses ANY symlink component is refused, so lexical path == real destination (CWE-59/61).

Blocker 2 (HIGH): overwriteOwnedPathGlobs included '.claude/skills/*.md' — but that dir is SHARED with hand-authored user skills, so (no symlink, no --force needed) a verification command whose deriveSkillName collides with a user skill (e.g. 'deploy' → deploy.md) + a forged manifest hash made it managed-clean × stale → update → overwrote the user's skill with generator content embedding the attacker's command (agent-instruction poisoning). Removed overwriteOwnedPathGlobs entirely; the overwrite gate now uses the EXACT static ownedPathGlobs (CLAUDE.md + the 3 built-in skills). Dynamic command-skills are no longer auto-overwritten when stale — they are refused; a reserved generated-skill namespace that restores safe dynamic re-render is the planned follow-up.

Also contains loadModelProfiles' read via resolveWithinProject (a symlinked model-profiles dir/file no longer reads out of project), surfaced by the new fs-containment hook.

Tests: symlinked owned-skills-dir overwrite is refused (victim untouched); hand-authored deploy.md is not overwritten by a colliding command + forged manifest; --regen-skills no longer overwrites a divergent dynamic skill.
…dmap + map CONFIG_ERROR

Round 8 added a contained loadRoadmap, but resolveTaskInRoadmap (shared by task status/context/prepare/complete/record-done/finalize/start/block/resume), phase-archive's loadRef, phase-reconcile's resolvePhase, and plan-adopt's nextPhaseSeed STILL did their own readFile(join(cwd,'design/roadmap.yaml')) + Roadmap.parse — bypassing symlink containment AND the EISDIR/ENOTDIR/EACCES/malformed → CONFIG_ERROR mapping. So a symlinked design/roadmap.yaml made those commands read an out-of-project roadmap as the control plane. (My fs-containment tripwire missed resolve-task because its read was MULTILINE — see the tripwire fix.)

All four now use loadRoadmap(cwd). And every consumer's CLI maps the resulting CONFIG_ERROR to exit 2: task complete / record-done / finalize / status (switch cases), task start/block/resume (emitTaskCommonError), task add, verify, phase archive, phase reconcile — plus a top-level safety net in main()'s rejection handler so ANY unmapped CONFIG_ERROR is a clean exit-2 envelope, never an internal error / exit 3.

Test: design/roadmap.yaml symlinked outside → task complete --dry-run / task status / phase archive / phase reconcile --write all exit 2 with error.code CONFIG_ERROR, no internal error, and the foreign roadmap's marker never leaks.
…t, machine-readable reason

Must-fix 1: loadModelProfiles contained the per-file READ but still readdir'd a LEXICAL .code-pact/model-profiles, so a symlinked-outside dir was still enumerated (out-of-project listing / large-dir DoS). Now resolveWithinProject the directory BEFORE readdir (optional source → unsafe/missing dir is []).

Must-fix 2: every refusal printed 're-run with --accept-modified', but a SECURITY refusal (a generated path outside the trusted owned set, or one that reaches its real target through a symlink) is NOT resolvable that way — re-running refuses again. Refusals now carry a machine-readable reason (managed_modified | unowned_generated_path | symlink_traversal) in files[]/plan[] (JSON), and the human guidance branches per reason: --accept-modified ONLY for managed_modified; the security reasons get inspect/remove or fix-the-symlink guidance. --regen-skills help + cli-contract updated to state it does NOT overwrite a divergent dynamic skill (reserved-namespace follow-up).

Tests: the symlinked-owned-dir refusal carries reason symlink_traversal; the deploy.md collision carries reason unowned_generated_path.
…in(...))

The single-line regex missed a MULTILINE readFile(\n join(...)) — exactly the resolve-task read that bypassed loadRoadmap. The scan now runs over full text with \s* spanning newlines, reporting the fs-call's line number. A path stashed in a variable first (const d = join(...); readFile(d)) is still not caught — that needs dataflow (the AST-lint / projectFs chokepoint follow-up), so this stays an edit-time advisory, not a complete guarantee.
The last roadmap/phase consumers bypassing the contained seam were in plan/state.ts: loadPlanState (strict — behind task runbook, phase runbook, status, plan analyze) and collectPlanArtifacts + scanPhasesDirBestEffort (lenient — behind decision prune/retire and plan lint) read via roadmapPath(cwd)/join(cwd, ref.path) and a lexical design/phases readdir. A symlinked design/roadmap.yaml or design/phases therefore let those commands read an out-of-project control plane — and the top-level CONFIG_ERROR safety net does NOT help, because a VALID external YAML throws nothing and flows through as normal data.

Blocker 2 (the dangerous one): collectPlanArtifacts feeds decision prune/retire's referencing-task gate, so a roadmap symlinked to an external EMPTY roadmap hid the current project's referencing not-done task → prune/retire could be wrongly authorized to DELETE a still-referenced decision.

Fix: every read in the family now goes through resolveWithinProject. STRICT (loadPlanState) maps a containment escape to CONFIG_ERROR (propagates → consumer/safety-net → exit 2); LENIENT (collectPlanArtifacts / scanPhasesDirBestEffort) turns a containment escape into a graph-file FileIssue so planArtifactsUnreadable() fail-closes (prune/retire refuse). loadPlanStatePhase's ParseError-on-malformed contract is unchanged — only the PATH is contained. The model-profiles-style directory is also resolved before readdir.

Tests: symlinked roadmap → task runbook (loadPlanState) exits 2 CONFIG_ERROR with no leak; a roadmap symlinked to an external empty roadmap can no longer hide a referencing not-done task → decision prune --write fails closed and the decision is byte-identical.
The adapter install/upgrade help described these security-relevant flags as the OPPOSITE of what they do: --force was 'Overwrite existing managed files' (it is unmanaged-adoption only and NEVER overwrites a modified managed file), and --accept-modified was 'Preserve manually-edited managed files' (it ALLOWS overwriting them with generator output — the destructive flag). Backwards help on destructive flags makes a user misjudge the blast radius. Now: --force = adopt/replace UNMANAGED only, does not overwrite a modified managed file; --accept-modified = ALLOW overwriting a locally-modified managed file.
toshtag added 30 commits June 29, 2026 23:28
addWrite() now registers content in memory without touching the
filesystem. Temp files are created by createPreparedTemps() after
the prepared journal is durably written, ensuring recovery can
always detect and clean orphaned temps.

The dynamic-create existence check moves to commit time since
addWrite no longer probes the filesystem. verifyPreState() now
computes postState from staged content instead of reading a temp
file, and rejects pre-existing temps rather than requiring them.
Private state root now requires absolute paths for CODE_PACT_STATE_HOME,
XDG_STATE_HOME, and LOCALAPPDATA. Each directory component is lstat'd
to reject symlinks, verify current-user ownership, and reject
group/other-writable permissions on POSIX.

Journal loading is hardened: IDs must be valid UUIDv4, filename must
match body ID, entries are checked for duplicate target paths, and
operation/target_kind/post_state combinations are validated. Artifact
paths (temp/backup) are verified to stay within the target directory
and project root. SHA-256 hashes are validated against a strict regex.
Directory scan only processes UUIDv4-named JSON files.
…anup

When artifact cleanup succeeds but the final journal unlink or
directory sync fails, the error is now classified as
TRANSACTION_CLEANUP_PENDING instead of falling through to the
generic catch path. The journal is re-saved with status
cleanup_pending before throwing, so the next recovery can detect
the state. Final files are not rolled back.
…targets

AdapterDeleteTarget.absPath changes from OwnedWritePath to
OwnedDeletePath, and adapterStaticDeleteTarget re-brands the
authority path accordingly. This makes delete capability distinct
from write capability at the type level.
Update CLI contract to describe private user state directory for
transaction journals instead of legacy project-local path. Document
that prepared journals are written before temp files, legacy
project journals are rejected, and CODE_PACT_STATE_HOME must be
absolute. Correct project-fs comment to reflect that a small set
of primitive modules may use raw fs directly.
Reduce TRUSTED_FS_MODULES from 47 to 15 modules by keeping only core
primitives (raw fs I/O, path resolution, atomic write, transaction
state) and authority boundary modules (config, profile, manifest,
archive paths). Domain modules (archive, decisions, plan, progress,
pack, services) are now checked by the fs-authority gate.

Add 58 structured allowlist entries for legitimate fs operations in
domain modules that use symlink-free contained paths. Each entry
specifies file, function, operation, authority, and reason.

Add atomicReplaceExistingText to FS_SINK_FUNCTIONS and
WRITELIKE_FS_FUNCTIONS so the checker tracks it as a write-like sink.
…d-read

Move brand constructor functions from branded-paths.ts to branded-paths-internal.ts. Rename resolveOwnedReadPath to resolveSymlinkFreeReadCandidate with return type downgraded from OwnedReadPath to SymlinkFreeContainedPath. Update all call sites and checker configuration.
Add missing crash matrix cases required by the security hardening plan:

- crash before journal: no temp or journal artifacts exist (no-op recovery)
- crash after journal but before first temp: journal only, temps absent
  (rollback removes journal)
- crash after first temp: journal and partial temps exist (rollback
  removes temps + journal)
- journal cleanup failure after successful commit: surfaces
  TRANSACTION_CLEANUP_PENDING, final files preserved, recovery cleans
  on next run

Also adds a vi.mock for node:fs/promises to inject journal cleanup
failures via failJournalCleanup.
Replace stale "top-level design/decisions/*.md" phrasing with
"design/decisions/**/*.md" across PRUNED.md, README.md, the
lifecycle RFC, cli-contract, design-doc-lifecycle, and
troubleshooting. The implementation already accepts nested ADRs;
these docs now reflect that.
…orcement

- project-fs/index.ts: replace "Can later enforce" with present-tense
  description of check:fs-authority CI-time enforcement
- decision-gate-archive.ts: remove "nested" from the
  normalizeDecisionRef rejection examples (nested paths are now
  accepted)
Move node:fs and node:fs/promises re-exports from project-fs/index.ts
into a dedicated raw-internal.ts module. The barrel re-exports them
for backward compatibility, but the canonical raw-fs import site is
now isolated and auditable. check-fs-authority.mjs updated to
recognise raw-internal.ts as a trusted core primitive.
…edicated resolvers

safeReadProjectYaml now uses readOwnedText from owned-read.ts instead
of raw readFile + resolveSymlinkFreeReadCandidate. projectFileExists
now uses ownedPathPresence from control-plane.ts instead of raw
access + resolveSymlinkFreeReadCandidate. The corresponding
function-level allowlist entries are removed.
Verify at compile time that raw string is rejected by OwnedReadPath,
OwnedWritePath, OwnedDeletePath, and the branded-path API functions
(readOwnedText, writeOwnedText, removeOwned, listOwned). Uses
@ts-expect-error directives — if the brand types are ever weakened,
tsc will fail on unused directives.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant