feat: Workspace filesystem cleanup #391
11 issues
High
Daemon socket directory in shared tmpdir is vulnerable to symlink pre-creation attack - `src/daemon/socket-path.ts:89-95`
daemonDirForWorkspaceKey places the socket directory under tmpdir() (typically /tmp), which is writable by any local user. The workspace hash is deterministic from a path that may be predictable (e.g., /Users/<name>/projects/<name>), so a local attacker can pre-create xcodebuildmcp-<hash> as a symlink targeting an attacker-controlled directory before the daemon starts. mkdirSync with recursive: true silently succeeds when the path already exists, including when it is a symlink to an existing directory. Although validateSocketDir rejects symlinks afterwards via lstatSync, the daemon will refuse to start while the attacker has effectively performed a denial-of-service; if symlink target is owned by the user and not a symlink itself (e.g., the user's ~/.ssh), the ownership check passes and chmodSync may then alter permissions on that target.
Medium
discovered.total bumped from 52 to 57 without matching items list expansion - `src/snapshot-tests/__fixtures__/json/device/test--failure.json:34`
The hunk increases tests.discovered.total from 52 to 57, but the visible items list in the surrounding context shows no corresponding additions of five new test entries. If the underlying test count did not actually grow by five, this fixture change would be a snapshot-only patch unrelated to the workspace filesystem cleanup behavior change, which violates the guardrail that fixture updates must map to intentional behavior changes and stay aligned with structured output. Reviewers should confirm the items array elsewhere in the fixture grew by exactly 5 entries; otherwise this is a fixture patched only to make tests pass.
chmodSync after statSync allows TOCTOU permission change on attacker-swapped path - `src/daemon/socket-path.ts:84-86`
validateSocketDir calls statSync(dir) then chmodSync(dir, 0o700) if permissions are too loose. Between these calls, a local attacker who can win the race could swap the path to a symlink (or replace the directory) so the chmod applies to a different filesystem object owned by the same user. Combined with the predictable path under tmpdir(), this enables tampering with the permissions of arbitrary user-owned directories. Using fchmod on an opened file descriptor (with O_NOFOLLOW) would close the window.
Also found at:
src/utils/fs-lock.ts:113-121
Pre-key cooldown skip can starve scheduling when sweeps never complete - `src/utils/workspace-filesystem-lifecycle.ts:401-412`
buildSchedulePreKey-based skip in scheduleWorkspaceFilesystemLifecycleSweep reads lastScheduledAtByPreKey but that map is only updated after a non-skipped sweep completes. Because the entry is set at completedAt (after the schedule delay plus sweep duration), and the same value is also used to short-circuit future schedule calls, this is benign — but the pre-key check uses options.now ?? Date.now() while the post-completion write uses Date.now(); if a caller passes a fixed options.now in the past for tests, the cooldown check can incorrectly skip indefinitely. This can cause scheduled sweeps to be silently dropped in test or time-controlled environments.
Also found at:
src/utils/workspace-filesystem-lifecycle.ts:426-444
Stale socket no longer removed when registry metadata is missing - `src/cli/daemon-control.ts:90-95`
The previous implementation always called removeStaleSocket(socketPath) at the end of forceStopDaemon, ensuring an orphaned socket file would be cleaned up even when no registry entry existed. The new implementation throws early when findDaemonRegistryEntryBySocketPath returns null, so stale sockets without a matching registry entry are never cleaned up by this path. This can leave orphaned socket files on disk that prevent subsequent daemon startup from binding to the expected path.
Also found at:
src/cli/daemon-control.ts:91-95
Daemon socket directory placed in shared tmpdir with predictable name enables pre-creation DoS - `src/daemon/socket-path.ts:21-36`
daemonRunDir() defaults to os.tmpdir() (typically /tmp, shared/world-writable with sticky bit), and daemonDirForWorkspaceKey produces a deterministic name xcodebuildmcp-<12-hex> derived from the workspace path hash. A local unprivileged attacker who can predict or enumerate workspace hashes can pre-create that directory under their own UID before the daemon starts. ensureSocketDir will then skip mkdirSync (because existsSync is true), and validateSocketDir will throw on the uid mismatch, preventing the daemon from ever starting for that workspace. The validation correctly defends against symlink/uid hijacking of an existing dir, but the predictable name in a shared directory still allows trivial denial-of-service against any user on a multi-tenant host.
Also found at:
src/daemon/socket-path.ts:89-95
Low
Quarantined lock directory is leaked when restore rename fails - `src/utils/fs-lock.ts:60-67`
restoreQuarantinedLockDir swallows rename errors and intentionally leaves the quarantined directory in place when restoration fails. Over time, repeated contention failures (e.g., another contender created a new lockDir before the validation rejected our recovery) accumulate '.{name}.stale.{pid}.{uuid}' directories under the lock parent with no cleanup path, causing unbounded disk usage in long-lived workspaces.
Also found at:
src/utils/fs-lock.ts:184-191
normalizeWorkspaceKey allows '..' and other traversal-adjacent values - `src/utils/log-paths.ts:38-46`
normalizeWorkspaceKey only rejects empty strings and forward/back slashes, but does not reject '..', '.', null bytes, or other characters that can affect path resolution on some platforms. If a workspace key of '..' is ever passed in, path.join will resolve the workspace root to the parent of the workspaces directory, allowing cleanup logic to operate outside the intended workspace tree. The user-visible consequence is potential deletion or lock contention on unintended directories if an attacker or buggy caller controls the workspace key.
isPidAlive treats EPERM as alive without verifying PID ownership - `src/utils/process-liveness.ts:7-12`
process.kill(pid, 0) returns EPERM when the PID exists but belongs to another user/process the caller cannot signal. The function correctly treats this as 'alive' (only ESRCH returns false), but on systems where PID reuse occurs across users, an unrelated process owned by another user could be considered a live workspace-owned helper. Given the skill's emphasis on 'ownership checks' for multi-process cleanup, this liveness primitive alone cannot establish ownership — callers must combine it with workspace ownership verification, otherwise stale helpers owned by other users may be treated as alive and never reconciled.
chmod-on-detect repairs perms after potential exposure window - `src/daemon/socket-path.ts:84-86`
validateSocketDir lazily chmods the directory to 0o700 only when it detects loose perms (stat.mode & 0o077 !== 0). If a previous run (or external actor with same UID) left the directory world/group-readable, any socket, log, or daemon.json that was created during that window may already have been observable. The fix corrects future state but does not audit or roll prior contents. Consider failing closed (refusing to start) when perms are unexpectedly loose, instead of silently repairing.
...and 1 more
15 skills analyzed
| Skill | Findings | Duration | Cost |
|---|---|---|---|
| xcodebuildmcp-docs-release-review | 0 | 7.9s | $0.08 |
| xcodebuildmcp-docs-command-review | 0 | 6.0s | $0.08 |
| xcodebuildmcp-rendering-streaming-review | 0 | 24.2s | $0.40 |
| xcodebuildmcp-runtime-boundary-review | 0 | 6m 39s | $0.34 |
| xcodebuildmcp-snapshot-fixture-review | 1 | 6m 28s | $10.94 |
| xcodebuildmcp-structured-output-review | 0 | 4m 14s | $0.96 |
| xcodebuildmcp-test-boundary-review | 0 | 3m 18s | $5.37 |
| xcodebuildmcp-tool-contract-review | 0 | 17.3s | $0.17 |
| wrdn-pii | 0 | 11m 33s | $20.68 |
| wrdn-authz | 0 | 7m 34s | $5.45 |
| wrdn-code-execution | 0 | 12m 30s | $1.69 |
| wrdn-data-exfil | 0 | 13m 30s | $1.72 |
| find-bugs | 6 | 17m 6s | $7.56 |
| code-review | 3 | 15m 8s | $7.21 |
| code-simplifier | 1 | 17m 46s | $1.41 |
Duration: 116m 41s · Tokens: 16.0M in / 47.1k out · Cost: $64.18 (+dedup: $0.10, +extraction: $0.00, +merge: $0.01)