-
-
Notifications
You must be signed in to change notification settings - Fork 25.4k
Fix: fix(hooks): GateGuard permanently blocks all operations on Windows — fs.renameSync EEXIST not handled #1444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,9 +27,9 @@ const fs = require('fs'); | |||||||||||||||||||||
| const path = require('path'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Session state — scoped per session to avoid cross-session races. | ||||||||||||||||||||||
| // Uses CLAUDE_SESSION_ID (set by Claude Code) or falls back to PID-based isolation. | ||||||||||||||||||||||
| // Uses CLAUDE_SESSION_ID (set by Claude Code) or falls back to stable CWD-based isolation. | ||||||||||||||||||||||
| const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); | ||||||||||||||||||||||
| const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.env.ECC_SESSION_ID || `pid-${process.ppid || process.pid}`; | ||||||||||||||||||||||
| const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.env.ECC_SESSION_ID || `cwd-${crypto.createHash('sha256').update(process.cwd()).digest('hex').slice(0, 16)}`; | ||||||||||||||||||||||
| const STATE_FILE = path.join(STATE_DIR, `state-${SESSION_ID.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // State expires after 30 minutes of inactivity | ||||||||||||||||||||||
|
|
@@ -82,6 +82,9 @@ function saveState(state) { | |||||||||||||||||||||
| // Atomic write: temp file + rename prevents partial reads | ||||||||||||||||||||||
| const tmpFile = STATE_FILE + '.tmp.' + process.pid; | ||||||||||||||||||||||
| fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8'); | ||||||||||||||||||||||
| // On Windows, fs.renameSync throws EEXIST if the destination exists. | ||||||||||||||||||||||
| // Unlink first to ensure cross-platform replacement. | ||||||||||||||||||||||
| try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore if not exists */ } | ||||||||||||||||||||||
| fs.renameSync(tmpFile, STATE_FILE); | ||||||||||||||||||||||
|
Comment on lines
+87
to
88
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Always unlinking first creates a window between line 87 and 88 where A safer pattern catches
Suggested change
The worst-case failure mode here is still a spurious re-gate (safe direction), but reducing the frequency matters for stability on async hook runs. |
||||||||||||||||||||||
| } catch (_) { /* ignore */ } | ||||||||||||||||||||||
|
Comment on lines
83
to
89
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If
// in pruneStaleFiles, add alongside the existing state-*.json cleanup:
if (f.includes('.tmp.') && now - stat.mtimeMs > SESSION_TIMEOUT_MS) {
try { fs.unlinkSync(path.join(STATE_DIR, f)); } catch (_) {}
}
Comment on lines
+85
to
89
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In current Node.js LTS versions (v24 Active LTS "Krypton" and v22/v20 Maintenance LTS as of 2026-04-15), fs.renameSync(oldPath, newPath) behavior when the destination already exists is platform-dependent, emulating POSIX rename(2) semantics via libuv. On POSIX (Linux/macOS): Overwrites the destination if it's a file (atomically replaces it). If destination is an existing non-empty directory, throws ENOTEMPTY. If empty directory, may succeed (replaces it). On Windows: Intended to overwrite existing files using MoveFileEx with MOVEFILE_REPLACE_EXISTING flag in libuv. However, frequently throws EPERM ("operation not permitted") or EBUSY, especially if files are locked by antivirus/indexer/Windows Search, or under concurrent access/high load. EEXIST may occur if replacement fails. Retries (as in graceful-fs) are a common workaround, but not built into core fs. Official Node.js docs (fs.html) do not explicitly document per-platform differences or error codes for existing destinations, referencing POSIX rename(2) generally. Issues like nodejs/node#29481 and libuv discussions confirm Windows unreliability for atomic replacement via rename. Cross-platform pattern for preserving atomic file replacement: Write new content to a temporary file (e.g., destination + unique suffix like '.' + hash/process.pid), then fs.renameSync(tempPath, destination). This leverages rename's atomicity within the same filesystem. If destination exists, rename will attempt replacement (POSIX: succeeds; Windows: may fail with EPERM but often succeeds if temp is new). Popular libraries like write-file-atomic implement this with retries, fsync, chown/mode preservation, handling Windows quirks for reliability. Example: const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); function atomicWriteSync(dest, data) { const tmp = dest + '.' + crypto.createHash('md5').update(dest + process.pid + Date.now).digest('hex'); fs.writeFileSync(tmp, data); fs.renameSync(tmp, dest); // Atomic replace } Use libraries like 'write-file-atomic' for production (handles edge cases, retries EPERM on Windows). Citations:
Remove unconditional unlink and handle state-write errors explicitly. Line 87 unconditionally deletes On POSIX systems (Linux, macOS), Suggested approachAttempt Per the coding guidelines: "Always handle errors explicitly at every level and never silently swallow errors." 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Unconditional pre-delete before rename creates a non-atomic write window and can silently lose persisted state if rename fails.
Prompt for AI agents