CLI hooks block each tool call for the full timeout; lowering it silently disables deny-hooks (Windows)

Where does the bug appear (feature/product)?

Cursor CLI

Describe the Bug

In headless mode on Windows (no POSIX shell detected), every hook invocation blocks its tool call for the FULL configured timeout, even though the hook process exits in ~0.1 s with a valid {"permission": "allow"} on stdout. The tool call duration tracks the configured timeout exactly, not the hook’s runtime: with "timeout": 10 a simple readToolCall takes 10.4 s, with "timeout": 3 it takes 3.4 s, with "timeout": 1 it takes 1.5 s, and 0.4 s with no hooks registered (control). With no timeout field, .cursor/hooks.json hooks make each edit wait ~30-40 s. On a workspace with a normal hook setup this makes headless runs 5-10x slower (our read+shell+2-edits benchmark: 93 s with hooks vs 51 s after workarounds vs ~20 s hook-free).

There is a security consequence: lowering the timeout to compensate silently disables deny-hooks. With "timeout" set to 3, 6 or 8 on a preToolUse hook that denies writes to protected files (and answers in ~0.1 s), the hook is killed before its verdict is collected and the agent proceeds fail-open: during our test the protected file was actually rewritten, with no warning or error anywhere in the stream-json output.

All timings come from the timestamp_ms fields of the stream-json output (tool_call started → completed), cross-checked with an instrumented hook that logs its own start/exit times.

Steps to Reproduce

  1. Create a minimal workspace containing:

target.md (any content)

.cursor/hooks.json:

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      { "command": "python hook.py", "timeout": 3,
        "matcher": "Write|StrReplace|Edit|ApplyPatch" }
    ]
  }
}

hook.py: reads stdin, appends a timestamped line to a log file, prints {"permission": "deny", "agent_message": "blocked"} and exits (runs in <0.2 s).

  1. From cmd.exe with MSYSTEM / SHELL / EXEPATH cleared (so no POSIX shell is detected), run:
cursor-agent.cmd -p --force --trust --output-format stream-json "Append a line to target.md"
  1. Observe via the stream-json timestamp_ms fields and the hook’s own log:
    • Full-timeout wait: change the hook to answer {"permission": "allow"} and set "timeout": 10 → the edit tool call takes ~10 s although the hook exited after ~0.1 s. Lower the timeout to 1 → ~1.5 s. The wait always equals the configured timeout, regardless of when the hook exits or what it prints.
    • Silent fail-open: with the deny hook and "timeout": 3 (or 6, or 8) → target.md IS modified and no error or warning is emitted. Remove the timeout field → the deny works (but each edit then waits ~30-40 s).

Expected Behavior

  • The hook wrapper should resolve as soon as the hook process exits and its stdout verdict is available; timeout should only be a cap for hung hooks, not a fixed wait applied to every invocation.
  • A preToolUse hook killed by timeout should not silently fail-open: either fail-closed by default for deny-capable hooks, or at least emit a visible warning in the output so guard authors can detect that their hook was killed.

Operating System

Windows 10/11

Version Information

CLI Version 2026.08.25-3e8eec8 (Latest, up to date)
OS: win32 (x64)

For AI issues: which model did you use?

Model name: composer-2.5-fast (headless -p runs; also reproduced with cursor-grok-4.6-high-fast)

For AI issues: add Request ID with privacy disabled

Request ID: 194bfa0d-f227-42ef-b429-d3fdffe1ef9d (full-timeout wait, hooks answering allow, timeout 10)
Also relevant: 05d7d1c9-38b6-48ca-92eb-5d006fa30832 (timeout 1 → waits shrink to ~1 s, confirming the wait tracks the configured timeout, not hook runtime)

Additional Information

  • Likely related to the known Windows shell issues: topic 151858 (“Agent CLI on Windows: no way to configure shell, hardcoded to PowerShell”, confirmed as a bug in February) and topic 167319 (“Windows agent shell: ~17-30s overhead”). We measure ~26 s per shell command with pwsh 7 on PATH and ~18 s with the Windows PowerShell 5.1 fallback, for commands that run in <1 s directly — the per-invocation hook wait may share the same root cause (PowerShell session handshake per invocation).
  • Separate but related data-loss finding: if a POSIX shell IS detected (Git Bash env, MSYSTEM=MINGW64), everything becomes fast but hook wrappers crash (shell commands reported as “blocked by a hook”) and, more importantly, editToolCall returns success while the target file is left unchanged — silent edit loss. This is why we strip the POSIX env in the first place. Happy to file it separately if you prefer.
  • We can provide the full stream-json captures and the instrumented hook logs on request. Thanks a lot for looking into this!

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey @JusteMow ,
I set up a matching Windows headless run on the same CLI version (2026.08.25-3e8eec8) and couldn’t get the same result yet - the wait didn’t track timeout, and a deny hook still applied. To pin down the difference, could you share the stream-json capture + your hook’s own start/exit log for the timeout: 10 allow run and the timeout: 3 deny run, plus which PowerShell is on PATH (pwsh 7 vs Windows PowerShell 5.1) and your .cursor/hooks.json? A short recording of the deny run writing the file would be perfect too.

In the meantime, "failClosed": true on security-critical hooks blocks the action if a hook ever fails or times out (docs).

Hi @mohitjain , thanks a lot for taking the time to set up a matching run!

Your non-repro actually lines up with what I now think is the real mechanism, and I could reproduce it cleanly again today. The short version: on my machine cursor-agent resolves a hook at the timeout deadline, not when the hook process exits — so if the wrapper takes a while to spin up (PowerShell session per invocation, see below), the deadline is what matters, and a deny verdict that the hook already printed gets discarded as “timed out”. On a machine where the wrapper resolves quickly (like yours), the verdict comes back before the deadline and everything behaves — which is exactly what you saw.

The smoking gun (timeout: 10, deny hook)

Single preToolUse deny hook, its own log line proves when it ran:

edit tool call  started    18:09:12.123
hook process    executed    18:09:15.351   -> prints {"permission":"deny"}, exits ~0.1s later (18:09:15.45)
edit tool call  completed   18:09:25.347   -> FILE WAS WRITTEN (deny ignored)

The hook ran and returned deny at 18:09:15.45, but the tool call did not resolve until 18:09:25.35 — almost exactly hook-invocation (15.35) + timeout (10s). So cursor-agent waited the full timeout after the hook had already exited with a verdict, then treated it as a timeout → fail-open, discarding the deny.

Timeout-value sweep (same deny hook, same workspace)

| `.cursor/hooks.json` timeout | hook actually ran? | edit duration | file written (fail-open)? |
|---|---|---|---|
| none | yes | ~30–40 s | **no — deny honored** |
| 15 | yes (logged) | 18.3 s | yes |
| 10 | yes (logged) | 13.2 s | yes |
| 3 | **no** (never logged) | 6.3 s | yes |
| 10 + `failClosed: true` | yes (logged) | 10.1 s | no — blocked |
| 10 + `failClosed: true`, hook answering **allow** | yes (logged) | 10.1 s | **no — blocked too** |

Two regimes:

  • timeout set → the tool call resolves at invocation + timeout, and the deny is ignored even when the hook ran and printed it (10 and 15) or was killed before it could even start (3).

  • no timeout → it resolves when the hook exits, deny is honored, but every guarded call eats ~30–40 s.

  • timeout + failClosed: true → the verdict is still discarded at the deadline, so failClosed decides alone: a hook that answers allow gets blocked exactly like one that answers deny. On this machine that means no guarded edit can succeed at all.

The edit duration tracking timeout + a few seconds (rather than the hook’s real ~0.1 s runtime) is the same “resolve at deadline, not on exit” behavior from the original report.

Environment (the likely differentiator vs your run)

  • Original report was on CLI 2026.08.25-3e8eec8; all the runs below were re-done today on 2026.09.02-c22c1a3 (current, agent about says up to date). Windows 11 Pro (10.0.26200), headless -p --force --trust --output-format stream-json.

  • Launched from cmd.exe with MSYSTEM/SHELL/EXEPATH cleared (our workaround for the POSIX-shell edit-loss issue), so the shell falls back to PowerShell.

  • pwsh 7.6.5 is on PATH (C:\Program Files\PowerShell\7\pwsh.exe). A bare Get-Date shell command in this same setup takes ~26 s with pwsh 7 on PATH and ~18 s with Windows PowerShell 5.1 as fallback, for a command that runs in <1 s when I invoke pwsh directly. That per-invocation PowerShell spin-up is my prime suspect for why the wrapper doesn’t resolve before the deadline here — and it would explain why you can’t reproduce if your machine spins the wrapper up quickly. This may be the same root cause as forum topics 151858 and 167319.

  • The hooks themselves are trivial Python scripts that read stdin and exit in ~0.1 s (I log their start/exit from inside, which is how I know they ran at 18:09:15.35 above).

Attachments

I’ve kept the full stream-json captures + the hook’s own start/exit logs for every row of the table: the timeout: 10 allow run, the timeout: 10 and 15 deny runs (hook ran, verdict discarded), the timeout: 3 deny run (note: there is deliberately no hook log for that one — the hook never got to start, which is the point), and the two failClosed runs. Happy to attach them here or send however is easiest for you — plus my .cursor/hooks.json and the ~15-line instrumented hook so you can replay it as-is.

cursor_bug_evidence.zip (15,5 Ko)

Thanks also for the "failClosed": true tip — I tried it (last two rows of the table). It does make the guard safe, but because the verdict is discarded at the deadline here, it blocks the allow case just the same: with failClosed every guarded edit fails on this machine, whatever the hook answers. So it’s a good default in general, but it can’t be a workaround for the resolve-at-deadline behavior. Really appreciate you digging into this!