Skip to content

(sessions): trust a transcript cwd only if it encodes back to its folder (#385) - #419

Merged
devsuitup merged 3 commits into
mainfrom
fix/385-open-terminal-registration
Oct 2, 2026
Merged

devsuitup merged 3 commits into
mainfrom
fix/385-open-terminal-registration

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #385

Threat

A sandboxed session in project P can write its own transcript folder ~/.claude/projects/<enc(P)>/ and P's subtree, and no other encoded folder. It forges a JSONL in enc(P) with cwd: P/evil and plants P/evil/.claude/commands/schedule-x.md. deriveProjectPath returned the first JSONL cwd without checking it, so P/evil became the sidebar project path. From there it became the resume/fork spawn directory (and the sandbox's SWITCHBOARD_SANDBOX_PROJECT_FOLDER, which mkdir -ps enc(P/evil)), the target of the schedule creator's mkdir, and a schedule-registry entry via the plain launch registration in open-terminal. With the sandbox chosen per launch and P itself unsandboxed, the planted schedule then ran outside the sandbox.

Rule

A transcript cwd is trusted for a filesystem decision only if it encodes back to the name of the folder holding the transcript. One helper, verifiedTranscriptCwd(cwd, folderName) in encode-project-path.js: absolute cwd, path.resolve, encodeProjectPath(resolved) === folderName, else null. The schedule seed now calls it too (same comparison as before; a relative recorded path is now refused instead of resolved against the process cwd).

Consumers of a transcript cwd

Consumer Status
deriveProjectPath (sidebar, session.projectPath, cache_meta, getKnownProjectPaths) covered: unverified JSONLs skipped, worktree collapse applied to the verified cwd, none verified gives null
workers/scan-projects.js (cold-start scan, writes cache_meta) covered (same function)
refreshFolder reuse of a stored cache_meta.projectPath covered: a stored value must verify (or be the repository of a worktree folder), so a forged value stored before the upgrade is re-derived
resolveSessionRealCwd: resume/fork spawn cwd, Changes panel, panel terminal, terminal path links, subagent worktree discovery, sandbox bind folder (follows the spawn cwd) covered: verified against the folder holding the JSONL, found by main on disk; a forged copy of a session id is skipped for the next folder; none left falls back to the requested project path as for a session with no recorded cwd
create-schedule-session mkdir enc(projectPath); open-terminal registration covered by the above: projectPath comes from the sidebar; the registration is unchanged from main (still plain, no folder precondition)
remap-project (main.js, now project-remap.js) the folder keeps its name while every cwd becomes the new path: main records folder -> newPath in the projectRemaps setting before rewriting, and the rule also accepts a cwd equal to that record. No existence check
Cached state from before the upgrade (cache_meta, session_cache rows) covered: refreshFolder, buildProjectsFromCache and reconcileCacheFromFilesystem verify a stored path and re-derive; a row with a different projectPath is rewritten even if its file is unchanged; a folder with nothing verifiable loses its rows
Schedule seed already verified; now shares the helper
Remote hosts (remote-index.js, remote folders) not verified, by design: a remote cwd is a path on the host and nothing local is opened from it; deriveProjectPath(..., { remote: true })
Renderer-supplied projectPath strings, a session-restore state saved before the upgrade not covered: out of scope (renderer), and a persisted forged value is only possible if the attack ran before

Residual (not fixed)

encodeProjectPath truncates at 200 characters and appends a 32-bit hash, so a path of 200+ characters can collide (enc(P/long) === enc(P)). The seed has the same property. A transcript the CLI wrote whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer yields a project path or a resume directory; not observed, not measured.

Tests

New test/transcript-cwd-trust.test.js (15 tests), all red before the change (10 of 13 at first run; the helper and storedProjectPathMatchesFolder tests were added with the code and checked by mutation): helper accept/refuse/../relative, forged+genuine and forged-only deriveProjectPath, forged subagent transcript, worktree collapse kept, remote kept, resolveSessionRealCwd forged/worktree/shadowed id, chain A (schedule creation from a forged folder: project is null, not P/evil), chain B (resume of a forged session does not spawn in P/evil, the sidebar project is P), stored-meta replacement in refreshFolder, first launch of a normal project (source check that the registration line in open-terminal is unconditional). test/scan-projects-worker.test.js gains a forged-folder case.
Existing fixtures that named a folder arbitrarily were changed to encodeProjectPath(cwd): derive-project-path, session-cache-refresh/bridge-dedup/cold-start-progress, build-projects-cold-scan-fallback, scan-projects-worker, remote-scan-file-granularity. They exercised the old unverified behaviour.

Mutations (each makes the new tests red, then restored)

  • deriveProjectPath trusts any cwd: 5 red (chains A and B among them)
  • deriveProjectPath drops the remote bypass: 1 red (remote kept)
  • resolveSessionRealCwd returns the raw cwd: 3 red; returns null on first hit instead of continuing: 1 red
  • stored-meta check removed in refreshFolder: 1 red; storedProjectPathMatchesFolder always true: 2 red
  • helper skips the encode comparison: 10 red; drops isAbsolute: 1 red (needed an added relative-cwd case, since the first version survived); drops path.resolve: 1 red
  • worker remote: true always: red; worker without the remote option: remote-indexing-e2e red

Review round 2

  • Remap (blocking): remapProjectTranscripts in project-remap.js records the new path, then rewrites; verifiedTranscriptCwd accepts a cwd equal to the recorded value for that folder. Test drives the real rewrite, deriveProjectPath, refreshFolder and a cold buildProjectsFromCache; a second test refuses a cwd that differs from the recorded one.
  • Stale forged state: folder meta verified in buildProjectsFromCache and reconcileCacheFromFilesystem; rows fixed through the dirty rule in refreshFolder. Tested.
  • Rejection log: one [session-cache] warning per folder with the folder name and the first rejected cwd, in refreshFolder, buildProjectsFromCache and the cold scan. Tested for once-per-folder.
  • Long-path hash: not changed. The CLI hashes the raw path, so normalising before hashing would break folder-name parity; documented with the residual.
  • Mutations added: drop the recorded-value branch (red), drop the equality with the record (red), drop folder-meta verification in the sidebar build (red), in reconcile (red), drop the dirty rule (red), drop the once-per-folder guard (red), drop the row deletion (red).
  • Not changed: session rows are not individually re-verified in buildProjectsFromCache; they are fixed by the refresh above, so until reconcileCacheFromFilesystem has run a stale forged row can still be listed.

Review round 3

  • Cold or interrupted scan: the worker names a local folder whose result is null, and writeScannedFolder deletes that folder's cached rows and search entries, as refreshFolder does, so a forged pre-upgrade row is not served before the scan ends. Test runs the real worker; mutation (drop the delete) is red.
  • The rejection warning logs the cwd through JSON.stringify, so a newline cannot forge a log line. Test uses a cwd with a newline; mutation (raw cwd) is red.

Not verified

  • Not run against a real sandboxed session or with the Electron app; the spawn decision in main.js is exercised through resolveSessionRealCwd only.
  • task check: lint 0 errors; the test run shows one failure, viewer-file-watch (known Windows libuv assertion). A first run also failed two git-changes-runner-real-git tests, which pass alone (31/31) and on the primary checkout, and passed on the second run.
  • The husky hook does not run in a worktree; it was not what produced the result above.
  • Whether real CLI transcripts always round-trip on Windows and Linux (drive-letter case, symlinked cwds) is assumed from the encoding, not measured.

Changelog: none (no-changelog label). In normal use nothing a user sees changes; the only observable effect is on a transcript whose cwd does not match its folder.

@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing 9220cbe (adversarial review in progress).

@devsuitup

Copy link
Copy Markdown
Owner Author

Adversarial review at 9220cbe: changes requested. The new-session branch accepts "the project's own folder exists" as evidence. That check is circular: the launch itself (create-schedule-session mkdir, the sandbox mkdir of SWITCHBOARD_SANDBOX_PROJECT_FOLDER, the CLI's first write) creates that folder for a forged P/evil. The schedule creator on an unindexed session also stops registering. Since the sandbox can be chosen per launch, a planted schedule can run unsandboxed. Decision: fix the source instead. A transcript cwd is trusted only if it encodes back to its folder, both in deriveProjectPath and for the resume spawn cwd. This PR will be force-pushed with that approach.

A sandboxed session can forge a transcript in its own project folder whose
cwd points below the project. The cwd fed the sidebar project path, the
resume and fork spawn directory, the sandbox project folder and the
schedule registration, so a schedule planted there could run outside the
sandbox.

verifiedTranscriptCwd accepts a cwd only when it encodes back to the folder
holding the transcript. deriveProjectPath, the cold-start scan worker,
resolveSessionRealCwd and the reuse of a stored cache_meta project path
go through it; remote hosts keep their recorded cwd. The schedule seed
uses the same function.

Closes #385
@devsuitup
devsuitup force-pushed the fix/385-open-terminal-registration branch from 9220cbe to 422deea Compare October 2, 2026 17:17
@devsuitup devsuitup changed the title (schedules): register a launch only for a folder Switchboard knows the session by (#385) (sessions): trust a transcript cwd only if it encodes back to its folder (#385) Oct 2, 2026
@devsuitup devsuitup added the no-changelog The PR changes nothing a user sees; the CHANGELOG.md check is waived label Oct 2, 2026
@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing 422deea (adversarial review in progress).

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review at 422deea: changes requested. (B1, reproduced) remap-project rewrites cwds to newPath inside folder enc(oldPath), so nothing verifies and the remapped project vanishes or stays missing. The fix is a folder-to-path record written only by main. (M1) buildProjectsFromCache trusts a stored cache_meta.projectPath unverified, so a value forged before the upgrade survives until the folder changes. Minor: no warning when a folder has cwds but none verifies, so a CLI naming change would hide every project silently; long paths with mixed separators. Checked: no worktree regression, since resume only reads the first cwd, as on main. Chains A and B are closed, and no other raw-cwd consumer was found.

The remap dialog rewrites every transcript cwd of enc(oldPath) to the new
path while the folder keeps its name, so the verified-cwd rule rejected
every line and the project vanished. Main now records folder -> newPath in
the projectRemaps setting before rewriting, and a cwd equal to that record
verifies.

Stored state from before the upgrade is verified too: cache_meta in the
sidebar build and in reconcile, and cached rows whose projectPath differs
from the folder's are rewritten. A folder with no verifiable transcript
loses its cached rows and logs one warning naming the folder and the
first rejected cwd.

Closes #385
@devsuitup

Copy link
Copy Markdown
Owner Author

Reviewing ccc03d6 (adversarial review in progress).

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at ccc03d6 (delta from 422deea): 0 blocking. Remap works again. Only main writes projectRemaps, and the sandbox has no bind for the settings DB. The newPath comes from the native picker. A cwd is accepted only if it equals the record exactly. The new row deletion is cache-only and local-only. A file with no cwd yet does not trigger it, since one verified transcript keeps the folder. Reconcile converges after one refresh (no re-index every launch), and the warning fires once per folder per process. Each of 5 mutations turns exactly its target test red. Being fixed: forged rows in the cold or interrupted-scan path are not purged by writeScannedFolder(null), and the logged cwd is not escaped.

A cold or interrupted scan wrote nothing for a local folder with no
verified project path, so a cached row from before the upgrade could still
be served and opened by session restore. The scan now deletes that
folder's cached rows and search entries, as refreshFolder does.

The rejected cwd in the warning is logged through JSON.stringify so a
newline in it cannot forge a log line.

Closes #385

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 52486c9 (delta from ccc03d6): 0 blocking. The cold or interrupted scan now purges the cached rows and search entries of a local folder whose result is null. Remote mirrors are excluded (FOLDER_PREFIX). The test drives the real worker and goes red without the delete. The rejected cwd is logged JSON-quoted, and a test checks that a newline inside it no longer forges a line.

@devsuitup
devsuitup merged commit 2913237 into main Oct 2, 2026
11 checks passed
@devsuitup
devsuitup deleted the fix/385-open-terminal-registration branch October 2, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog The PR changes nothing a user sees; the CHANGELOG.md check is waived

Projects

None yet

Development

Successfully merging this pull request may close these issues.

(sandbox): refuse extra binds under .claude/.git, and tighten the schedule registry seed

1 participant