feat: Workspace filesystem cleanup #391
5 issues
find-bugs: Found 5 issues (3 medium, 2 low)
Medium
forceStopDaemon may leave socket and registry in place when process hasn't exited within 500ms - `src/cli/daemon-control.ts:53-57`
forceStopDaemon sends SIGTERM, waits only 500ms, then calls cleanupWorkspaceDaemonFiles without allowLiveOwner: true. In daemon-registry.ts, canRemoveRegistryEntry requires !isPidAlive(entry.pid) when allowLiveOwner is not set, so if the daemon hasn't died yet the registry entry and socket file are not removed. Since forceStopDaemon's documented purpose is to remove the stale socket after a version-mismatch force-stop, callers (e.g. ensureDaemonRunning) may immediately try to start a new daemon while the old socket is still present, defeating the cleanup.
Also found at:
src/daemon.ts:334-351
ensureSocketDir TOCTOU lets a pre-existing directory bypass the 0o700 mode - `src/daemon/socket-path.ts:29-36`
ensureSocketDir() guards mkdirSync with existsSync(dir); if the directory already exists (e.g., created by another local user in the shared tmpdir before the daemon starts) the function returns without enforcing mode 0o700 or verifying ownership. Combined with the move of the socket directory under os.tmpdir() in daemonDirForWorkspaceKey(), a local attacker can pre-create the per-workspace directory world-readable/writable and the daemon will happily place its UNIX socket and registry inside it, exposing IPC traffic.
runningScheduledSweeps lock leaks when sweep promise is never created - `src/utils/workspace-filesystem-lifecycle.ts:471-488`
In scheduleWorkspaceFilesystemLifecycleSweep, runningScheduledSweeps.add(scheduleKey) is called synchronously, but the corresponding delete only fires inside the setTimeout's .finally(). If the timer is cancelled (e.g. process shutdown before delay elapses) or if runWorkspaceFilesystemLifecycleSweep throws synchronously before returning a promise, the scheduleKey is never removed and all future scheduling for that workspace/logDir is silently blocked for the lifetime of the process.
Also found at:
src/utils/fs-lock.ts:138-148
Low
Successful recovery on second attempt is discarded without retrying createLock - `src/utils/fs-lock.ts:209-228`
In tryAcquireFsLock the retry loop runs only attempts 0 and 1. If attempt=1 hits EEXIST and tryRecoverExpiredLockDir succeeds, the loop ends (attempt becomes 2) without performing the createLock that the recovery enabled. The function then falls through to return null, so a recoverable lock is reported as unacquirable and callers must retry, defeating the purpose of the second attempt.
Also found at:
src/utils/fs-lock.ts:76-95
buildSchedulePreKey cooldown can be bypassed by alternating workspaceKey and logDir - `src/utils/workspace-filesystem-lifecycle.ts:446-454`
buildSchedulePreKey returns either a workspace: or logDir: key based on which option is present. A caller that schedules first with {workspaceKey} and then with {logDir} (pointing to the same workspace's logs) will produce different preKeys, defeating the cooldown. Combined with the separate scheduleKey (workspaceKey:logDir), this allows scheduling far more sweeps than intended for the same underlying directory.
Also found at:
src/utils/workspace-filesystem-lifecycle.ts:492-521
Duration: 12m 40s · Tokens: 1.2M in / 20.0k out · Cost: $4.37 (+merge: $0.00)
Annotations
Check warning on line 57 in src/cli/daemon-control.ts
github-actions / warden: find-bugs
forceStopDaemon may leave socket and registry in place when process hasn't exited within 500ms
forceStopDaemon sends SIGTERM, waits only 500ms, then calls cleanupWorkspaceDaemonFiles without allowLiveOwner: true. In daemon-registry.ts, canRemoveRegistryEntry requires !isPidAlive(entry.pid) when allowLiveOwner is not set, so if the daemon hasn't died yet the registry entry and socket file are not removed. Since forceStopDaemon's documented purpose is to remove the stale socket after a version-mismatch force-stop, callers (e.g. ensureDaemonRunning) may immediately try to start a new daemon while the old socket is still present, defeating the cleanup.
Check warning on line 351 in src/daemon.ts
github-actions / warden: find-bugs
[JYJ-UYW] forceStopDaemon may leave socket and registry in place when process hasn't exited within 500ms (additional location)
forceStopDaemon sends SIGTERM, waits only 500ms, then calls cleanupWorkspaceDaemonFiles without allowLiveOwner: true. In daemon-registry.ts, canRemoveRegistryEntry requires !isPidAlive(entry.pid) when allowLiveOwner is not set, so if the daemon hasn't died yet the registry entry and socket file are not removed. Since forceStopDaemon's documented purpose is to remove the stale socket after a version-mismatch force-stop, callers (e.g. ensureDaemonRunning) may immediately try to start a new daemon while the old socket is still present, defeating the cleanup.
Check warning on line 36 in src/daemon/socket-path.ts
github-actions / warden: find-bugs
ensureSocketDir TOCTOU lets a pre-existing directory bypass the 0o700 mode
ensureSocketDir() guards mkdirSync with existsSync(dir); if the directory already exists (e.g., created by another local user in the shared tmpdir before the daemon starts) the function returns without enforcing mode 0o700 or verifying ownership. Combined with the move of the socket directory under os.tmpdir() in daemonDirForWorkspaceKey(), a local attacker can pre-create the per-workspace directory world-readable/writable and the daemon will happily place its UNIX socket and registry inside it, exposing IPC traffic.
Check warning on line 488 in src/utils/workspace-filesystem-lifecycle.ts
github-actions / warden: find-bugs
runningScheduledSweeps lock leaks when sweep promise is never created
In scheduleWorkspaceFilesystemLifecycleSweep, runningScheduledSweeps.add(scheduleKey) is called synchronously, but the corresponding delete only fires inside the setTimeout's .finally(). If the timer is cancelled (e.g. process shutdown before delay elapses) or if runWorkspaceFilesystemLifecycleSweep throws synchronously before returning a promise, the scheduleKey is never removed and all future scheduling for that workspace/logDir is silently blocked for the lifetime of the process.
Check warning on line 148 in src/utils/fs-lock.ts
github-actions / warden: find-bugs
[JPM-S8N] runningScheduledSweeps lock leaks when sweep promise is never created (additional location)
In scheduleWorkspaceFilesystemLifecycleSweep, runningScheduledSweeps.add(scheduleKey) is called synchronously, but the corresponding delete only fires inside the setTimeout's .finally(). If the timer is cancelled (e.g. process shutdown before delay elapses) or if runWorkspaceFilesystemLifecycleSweep throws synchronously before returning a promise, the scheduleKey is never removed and all future scheduling for that workspace/logDir is silently blocked for the lifetime of the process.