Skip to content

Fix: fix(hooks): GateGuard permanently blocks all operations on Windows — fs.renameSync EEXIST not handled#1444

Open
shahyashish wants to merge 1 commit intoaffaan-m:mainfrom
shahyashish:gitfix-patch-1435-1776222610654
Open

Fix: fix(hooks): GateGuard permanently blocks all operations on Windows — fs.renameSync EEXIST not handled#1444
shahyashish wants to merge 1 commit intoaffaan-m:mainfrom
shahyashish:gitfix-patch-1435-1776222610654

Conversation

@shahyashish
Copy link
Copy Markdown

@shahyashish shahyashish commented Apr 15, 2026

Fixed two critical issues on Windows: 1) Handled EEXIST error in fs.renameSync by unlinking the destination file before renaming, ensuring state is correctly persisted across atomic writes. 2) Replaced the unstable pid/ppid fallback for SESSION_ID with a stable hash of the current working directory. This ensures that hook invocations within the same project context share a consistent state file even when spawned through process wrappers that change parent PIDs.

Test: On a Windows environment without CLAUDE_SESSION_ID set, trigger a GateGuard fact-forcing check (e.g., by running a Bash command). Verify that the command is blocked initially. Provide the requested facts/instruction quote and retry the same command. Verify that the command is now permitted and that only a single state file (matching the stable CWD hash) is created in the .gateguard directory.


Summary by cubic

Fixes GateGuard blocking on Windows by safely replacing the state file and using a stable session key. This prevents EEXIST errors during atomic writes and keeps session state consistent across process wrappers.

  • Bug Fixes
    • Unlink destination before fs.renameSync to handle Windows EEXIST and ensure atomic state replacement.
    • Replace PID-based fallback with a stable CWD hash for SESSION_ID when CLAUDE_SESSION_ID is unset, so hooks in the same project share one state file.

Written for commit 9a52399. Summary will update on new commits.

… operations on Windows — fs.renameSync EEXIST not handled
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

This PR updates the session isolation fallback mechanism to use a deterministic cwd-derived SHA256 hash instead of PID-based identification, and adds file deletion before renaming the state file to prevent Windows EEXIST errors during atomic operations.

Changes

Cohort / File(s) Summary
Session and State Management
scripts/hooks/gateguard-fact-force.js
Modified session ID fallback from PID-based to deterministic cwd-derived SHA256 hash; added fs.unlinkSync(STATE_FILE) before fs.renameSync to resolve Windows file rename conflicts when destination exists.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

  • Issue #1435: Directly addresses the same two code-level fixes—cwd-derived session ID fallback and STATE_FILE unlinking before rename to avoid Windows EEXIST.

Possibly related PRs

  • PR #1367: Makes identical modifications to scripts/hooks/gateguard-fact-force.js with the same session ID and state file handling changes.

Suggested reviewers

  • affaan-m

Poem

🐰 A rabbit hops through Windows gates,
Where files renamed meet their fates,
No more PIDs, just hashes true,
State files deleted, fresh and new! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main Windows-specific issue being fixed: fs.renameSync EEXIST handling that permanently blocks operations, which is the primary motivation for the changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 15, 2026

Greptile Summary

This PR fixes two Windows-specific regressions in the GateGuard hook: fs.renameSync failing with EEXIST when the destination state file already exists, and an unstable ppid-based SESSION_ID that created a new state file on every hook invocation (since process wrappers change parent PIDs). The CWD-hash fallback is a sound replacement for the PID fallback and the EEXIST handling unblocks the hook on Windows.

  • The unlinkSync-before-renameSync pattern breaks the existing "atomic write" guarantee — a gap where STATE_FILE is absent means a concurrent loadState() returns empty state; catching EEXIST specifically on renameSync would preserve atomicity on the happy path.
  • Orphaned .tmp.<PID> files are not cleaned by pruneStaleFiles (which filters for state-*.json only), so a rare rename failure after a successful unlink leaks temp files indefinitely.

Confidence Score: 4/5

Safe to merge with minor improvements — failure modes are in the safe direction (spurious re-gate, not gate bypass).

Both findings are P2: the atomicity gap causes spurious blocking (not gate bypass), and orphaned temp files are an edge-case disk-leak. The core fixes (EEXIST handling, stable CWD-hash session ID) are correct and address real Windows regressions. Score is 4 rather than 5 because the two P2 findings touch the correctness of the write path and temp-file hygiene.

scripts/hooks/gateguard-fact-force.js — saveState atomicity and pruneStaleFiles temp-file coverage.

Important Files Changed

Filename Overview
scripts/hooks/gateguard-fact-force.js Fixes Windows EEXIST crash and unstable PID-based SESSION_ID; introduces a narrow atomicity window in saveState and orphaned temp-file leak on rename failure.

Sequence Diagram

sequenceDiagram
    participant Hook as GateGuard Hook
    participant FS as File System

    Note over Hook: saveState() called
    Hook->>FS: writeFileSync(tmpFile)
    FS-->>Hook: tmpFile written

    alt Before this PR (Windows EEXIST bug)
        Hook->>FS: renameSync(tmpFile → STATE_FILE)
        FS-->>Hook: ❌ EEXIST (destination exists)
        Note over Hook: outer catch ignores — state lost permanently
    else After this PR
        Hook->>FS: unlinkSync(STATE_FILE)
        FS-->>Hook: STATE_FILE deleted
        Note over FS: ⚠️ window: STATE_FILE absent
        Hook->>FS: renameSync(tmpFile → STATE_FILE)
        FS-->>Hook: ✅ success
    end

    Note over Hook: SESSION_ID fallback
    alt CLAUDE_SESSION_ID set
        Hook->>Hook: use env var (stable)
    else Fallback before PR
        Hook->>Hook: pid-{ppid||pid} — changes each invocation on Windows
    else Fallback after PR
        Hook->>Hook: cwd-sha256(cwd)[0:16] — stable per project
    end
Loading

Reviews (1): Last reviewed commit: "Fix issue #1435: fix(hooks): GateGuard p..." | Re-trigger Greptile

Comment on lines +87 to 88
try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore if not exists */ }
fs.renameSync(tmpFile, STATE_FILE);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Unlink-before-rename breaks atomic write semantics

Always unlinking first creates a window between line 87 and 88 where STATE_FILE doesn't exist. Any concurrent loadState() call in that window returns empty state (checked: []), causing isChecked to return false and the gate to fire spuriously. The comment above still says "Atomic write" but the write is no longer atomic.

A safer pattern catches EEXIST specifically, so the unlink only happens when the destination actually exists — preserving the atomic case on the happy path:

Suggested change
try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore if not exists */ }
fs.renameSync(tmpFile, STATE_FILE);
try {
fs.renameSync(tmpFile, STATE_FILE);
} catch (err) {
if (err.code === 'EEXIST') {
try { fs.unlinkSync(STATE_FILE); } catch (_) { /* ignore */ }
fs.renameSync(tmpFile, STATE_FILE);
}
}

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.

Comment on lines 83 to 89
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);
} catch (_) { /* ignore */ }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Orphaned temp files not pruned

If renameSync throws after unlinkSync succeeds (e.g., an antivirus tool holds a lock on tmpFile), the outer catch (_) { /* ignore */ } silently swallows the error and tmpFile (state-...json.tmp.<PID>) is left on disk permanently.

pruneStaleFiles skips it because the filter requires f.endsWith('.json') — files ending in .json.tmp.<PID> don't match. Consider extending the prune loop to also remove stale .tmp.* files:

// 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 (_) {}
}

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/hooks/gateguard-fact-force.js`:
- Around line 85-89: Remove the unconditional fs.unlinkSync(STATE_FILE) and
change the tmpFile -> STATE_FILE replace logic to try fs.renameSync(tmpFile,
STATE_FILE) first; on failure only handle Windows-specific errors (check
error.code === 'EEXIST' || error.code === 'EPERM') by calling
fs.unlinkSync(STATE_FILE) and retrying the rename, and for any other error or a
second failure, surface the error (throw or log and exit) instead of swallowing
it; update the code paths that reference STATE_FILE, tmpFile, fs.renameSync and
fs.unlinkSync so failures are handled explicitly and fail fast.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3bb049b4-34d6-42a0-a1d3-fa29657f48bf

📥 Commits

Reviewing files that changed from the base of the PR and between 48a30b5 and 9a52399.

📒 Files selected for processing (1)
  • scripts/hooks/gateguard-fact-force.js

Comment on lines +85 to 89
// 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);
} catch (_) { /* ignore */ }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In current Node.js LTS on Windows, what are the documented fs.renameSync behaviors when the destination already exists (including EEXIST/EPERM), and what cross-platform pattern is recommended to preserve atomic file replacement semantics?

💡 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 STATE_FILE before rename, creating a no-file gap where concurrent reads may find no file during the replacement. Additionally, line 89 silently swallows all errors from the rename, so if the operation fails, state may never persist and the gate can continue re-blocking incorrectly.

On POSIX systems (Linux, macOS), fs.renameSync() atomically replaces an existing file. On Windows, it may fail with EPERM due to file locks; conditional handling is needed only in that case.

Suggested approach

Attempt fs.renameSync() first on all platforms. Only on Windows, if it fails with EEXIST or EPERM, unlink the destination and retry. Wrap the entire operation in explicit error handling to fail fast and prevent silent state corruption.

Per the coding guidelines: "Always handle errors explicitly at every level and never silently swallow errors."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hooks/gateguard-fact-force.js` around lines 85 - 89, Remove the
unconditional fs.unlinkSync(STATE_FILE) and change the tmpFile -> STATE_FILE
replace logic to try fs.renameSync(tmpFile, STATE_FILE) first; on failure only
handle Windows-specific errors (check error.code === 'EEXIST' || error.code ===
'EPERM') by calling fs.unlinkSync(STATE_FILE) and retrying the rename, and for
any other error or a second failure, surface the error (throw or log and exit)
instead of swallowing it; update the code paths that reference STATE_FILE,
tmpFile, fs.renameSync and fs.unlinkSync so failures are handled explicitly and
fail fast.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/hooks/gateguard-fact-force.js">

<violation number="1" location="scripts/hooks/gateguard-fact-force.js:87">
P1: Unconditional pre-delete before rename creates a non-atomic write window and can silently lose persisted state if rename fails.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

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 */ }
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

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
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/gateguard-fact-force.js, line 87:

<comment>Unconditional pre-delete before rename creates a non-atomic write window and can silently lose persisted state if rename fails.</comment>

<file context>
@@ -82,6 +82,9 @@ function saveState(state) {
     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);
   } catch (_) { /* ignore */ }
</file context>
Fix with Cubic

@eljosuesilva
Copy link
Copy Markdown

Me too

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.

2 participants