// post

Two days tracing a silent deny: cursor-agent runs PowerShell hooks with bash on Windows

// A debugging field report: when MSYSTEM is set, cursor-agent on Windows composes its imported Claude Code hooks as PowerShell and evaluates them with bash, so every tool call is silently blocked while the CLI still exits 0. Two days to trace, the raw stream-json output gave it away, Cursor confirmed and reproduced it the same day, and an A/B run narrowed the trigger to that one Git Bash variable.

My harness runs cursor-agent in print mode, and the step after it started failing with nothing to commit, working tree clean. The agent step ahead of it exited 0, narrated the work it had done, and wrote nothing to stderr. It took two days to trace, and the symptom points away from the cause at every step.

Here is the finding, before the story. On Windows, cursor-agent imports Claude Code’s hooks when no Cursor hook configuration exists. It composes each imported hook command as PowerShell, then evaluates the resulting string with bash. Bash dies on the syntax, and a hook that errors blocks the tool call. So the agent could read and reason but could not write a file or run a shell command, and it still finished with exit 0 and a clean result event. In print mode the failure was completely silent.

That is not the whole condition, and I only closed it after the report went up. None of this happens on a stock Windows shell: the trigger is a single environment variable, which I had not isolated when I filed, so it lands where I found it, at the end.

The full write-up is on the Cursor forum. Every claim below is bound to this environment: Windows 11 Enterprise 10.0.26200, cursor-agent 2026.08.11-e8db854 (also reproduced on 2026.08.04-aaa8809), run with -p --output-format stream-json --trust --force --model auto, approvalMode: allowlist, sandbox.mode: disabled.

The symptom in the harness

The agent reports success. It reads the repository, reasons about the change, narrates what it did, and claims it finished. Every action it actually took was rejected, and nothing in the summary output says so.

In an automated harness that is worse than a crash. The agent step “succeeds”, and the next step fails for a completely unrelated-looking reason: a commit with an empty working tree. That is the symptom you start debugging, and it is two removes from the cause.

So I went after the launching shell first, because SHELL, PATH, and Git Bash sitting on PATH are the obvious suspects on a Windows dev box. I reproduced it from PowerShell with SHELL not exported and with a clean Windows PATH. Identical failure, so I crossed the launching shell off the list and moved on. Hold onto that, because it is the one conclusion in this post that turned out to be wrong, and the last two sections are where it comes back.

There was no obvious off-switch either: cursor-agent has no CLAUDE_CONFIG_DIR support, and I could find no setting that disables the Claude hook import. I ran most of the investigation with AI assistance, and none of it pointed here.

Reading the raw stream

The rejections exist in the protocol. They are simply not in anything human-readable. Ask for --output-format stream-json and read the raw tool_call/completed entries, and both editToolCall and shellToolCall carry a rejected block. Here is the one that ended the search, with its long line wrapped:

"rejected": {
  "reason": "Hook blocked with message: --: eval: line 1: syntax error near unexpected token `&'
    --: eval: line 1: `$OutputEncoding = [System.Text.Encoding]::UTF8; Get-Content -LiteralPath
    'C:\\Users\\<user>\\AppData\\Local\\Temp\\cursor-hooks-GwQsZg\\payload.json' -Raw
    | & { $input | npx -y context-mode hook cursor pretooluse }'

    Agent note: Do not suggest workarounds to the blocked tool."
}

The mechanism falls out of that one line. The command being evaluated is unambiguously PowerShell: $OutputEncoding, Get-Content -LiteralPath, the | & { $input | ... } pipeline. The error is unambiguously bash’s: eval: line 1: syntax error near unexpected token. And the cursor-hooks-XXXXXX temp directory in that path is created by Cursor itself, so the PowerShell wrapper is Cursor’s own composition and not anything I wrote. That last point is what turned this from my misconfiguration into a Cursor bug.

The trailer is its own small problem. Every rejection carried Do not suggest workarounds to the blocked tool, so the agent was instructed not to mention the wall it had just walked into.

Whose hooks were running

Nothing was ever registered on the Cursor side. Cursor’s own hook log spells out the resolution order and the outcome:

User config path:        c:\Users\<user>\.cursor\hooks.json
Claude user config path: c:\Users\<user>\.claude\settings.json
No user hooks configuration found
Loaded Claude user hooks

No Cursor hook configuration exists, so Claude Code’s are adopted instead. That log sits under %APPDATA%\Cursor\logs\<timestamp>\window1_wb1\output_<timestamp>\cursor.hooks.workspaceId-*.log. cursor-agent does the same thing: its bundle carries claudeUserHooks, claudeProjectHooks, and claudeProjectLocalHooks.

The two hooks blocking everything on this machine were rtk hook claude, a PreToolUse entry in ~/.claude/settings.json, and npx -y context-mode hook cursor pretooluse, contributed by a Claude Code plugin. Both are perfectly valid Claude Code hooks. Neither was written for Cursor.

The repro I filed

Windows 11 with Claude Code installed and at least one PreToolUse hook in ~/.claude/settings.json, no ~/.cursor/hooks.json and no project-level .cursor/hooks.json, then in an empty git repo:

cursor-agent -p "Create a file named hi.txt containing hello. Nothing else." `
  --output-format stream-json --trust --force --model auto > out.jsonl
Test-Path hi.txt

Expected: hi.txt exists. Actual: it never appears, and the exit code is 0.

That is the repro exactly as it went into the report, and it is missing one condition that I had not found yet.

The workaround

Give cursor-agent a home directory that contains my real .cursor and no .claude:

$fh = "$env:TEMP\cursor-clean-home"
New-Item -ItemType Directory -Force $fh | Out-Null
cmd /c mklink /J "$fh\.cursor" "$env:USERPROFILE\.cursor"   # no elevation needed
$env:USERPROFILE = $fh; $env:HOME = $fh

The junction keeps authentication working, and the result was clean: three rejections and no file written became zero rejections and the file created.

One sharp edge. If you later delete that scratch directory, remove the junction first with cmd /c rmdir "$fh\.cursor", because a recursive delete that follows junctions would walk into your real Cursor config.

The report, and the reply

Three things went into the report as requests. Execute the PowerShell-composed command with PowerShell, or compose a bash-compatible command when executing with bash. Treat a hook that fails to start differently from a hook that denies a tool call, because right now a broken hook silently becomes a full-deny. And document a way to opt out of importing Claude Code hooks, which would help regardless of the first two.

Dean Rie from Cursor replied the same day, August 12, 2026:

Hey, thanks for the report. It’s one of the most detailed ones I’ve seen. Your diagnosis is correct, and we reproduced it: on Windows, the hook command is built as PowerShell, but it gets executed through bash when the environment has Git Bash markers. Bash crashes on the syntax, exit 2 matches the block code, so you get a quiet full-deny of all tool calls in print mode. I’ve passed this to the team.

His reply added the two details I did not have: the bash path is taken when the environment carries Git Bash markers, and bash’s exit 2 collides with the hook block code, which is exactly why a startup failure is indistinguishable from a deliberate deny. He offered two interim ways out: keep the junction trick with a clean HOME and USERPROFILE for headless runs, or remove the Git Bash markers (MSYSTEM, EXEPATH, or a global SHELL pointing at git-bash) from the persistent environment, which may force the CLI to pick PowerShell and let imported hooks run correctly. He called the full-deny-instead-of-an-error behavior a good catch and sent it on as feedback, and pointed at the existing thread tracking third-party config opt-out. No timeline on the main fix yet.

Isolating the trigger

A class of markers is a lead, not an answer, so I went back at it with a fresh environment and one variable at a time. Thirty-five minutes after his reply I posted the follow-up: the class collapses to one variable, MSYSTEM.

Git Bash and MSYS2 export MSYSTEM into every process they spawn. With it set, Cursor executes its PowerShell-composed hook command with bash. Without it, the very same hooks run fine. I isolated that on one machine, same cursor-agent 2026.08.11-e8db854, same ~/.claude hooks, runs minutes apart, each one starting from a fresh default registry-derived environment plus exactly one addition:

Environmenthi.txt createdHook blocked occurrences
clean default environmentyes0
clean + MSYSTEM=MINGW64no5
clean + Git Bash \usr\bin on PATH (no MSYSTEM)yes0

So it is not PATH, and it is not the mere availability of bash. It is MSYSTEM alone, which also shrinks the repro to two lines from cmd on any Windows machine with Claude Code hooks in ~/.claude/settings.json:

set MSYSTEM=MINGW64
cursor-agent -p "Create a file named hi.txt containing hello. Nothing else." --output-format stream-json --trust --force --model auto

hi.txt is not created, the stream carries the rejected entries with the same bash syntax error, and the exit code is still 0.

Two things make that worse than it first looks. MSYSTEM cannot be unset from inside Git Bash, because the MSYS runtime injects it into the environment block of every child: both env -u MSYSTEM and unset MSYSTEM still leave a native Windows child seeing MSYSTEM=MINGW64. And it reaches well past interactive use, since anything descended from Git Bash inherits it. Shell scripts, test harnesses, and any long-running daemon or agent started from an MSYS shell all carry it.

That means a correction to my own report. Having Claude Code hooks configured is necessary but not sufficient, MSYSTEM has to be set as well, and on a stock Windows shell the report will not reproduce at all, which is very likely why it reads as environment-specific.

It also sharpens the two halves of Dean’s reply in different directions. The marker class narrows to MSYSTEM on its own, and his interim options stop being equivalent. Removing the markers is not available to anyone whose tooling launches from a Git Bash-parented process, because that is precisely the case where the variable cannot be unset. For those runs the relocated HOME and USERPROFILE is not one option out of two. It is the only one.

What I took away

Cross-tool config import is a real compatibility surface. A tool that adopts another tool’s hooks inherits its JSON and its execution contract with it. And here that contract turned on a variable neither tool sets, exported into the process by whichever shell happened to launch it.

A hook that cannot start should never be indistinguishable from a hook that decided to deny. Conflating those two is what made the two days necessary, and silent success is the most expensive failure mode a headless harness has, because exit 0 with nothing done sends you to debug the wrong step.

The other lesson is about my own dead end. When you clear a suspect, write down which property of it you actually measured. I cleared the launching shell on SHELL and PATH, and both probes were sound: the table proves Git Bash on PATH is harmless on its own. The shell was guilty anyway, through a third thing it exports into everything it starts. Right suspect, wrong probe.

That is also the exact shape of bug that burns days. Same binary, same repository, same hooks: it works from PowerShell, from cmd, and from a Windows Scheduled Task, and it fails from anything descended from an MSYS shell. Nothing about it is flaky. It is conditioned on an inherited variable that no output anywhere mentions.

The practical advice, scoped to what I actually hit: if you drive cursor-agent headless on Windows with Claude Code installed on the same machine, check whether MSYSTEM is set in the environment you really launch from, and run with --output-format stream-json so you can look for rejected blocks before you trust an exit code. When the surface symptom makes no sense, stop reasoning about it and go read the raw protocol: the answer was in that stream the whole time. Then write the report properly, with the environment, a minimal repro, the exact payload, and the log that proves whose hooks they were. Mine came back confirmed and reproduced the same day, and that reply is what pointed me at the variable.

Everything else I am working on is at lezli01.is-a.dev.

← all posts