Generated from
compatibility/cursor.json,
the same record leastgrant doctor reads. Last verified 2026-09-03.
Cursor
the real agent ran with LeastGrant installed and enforcement was observed happening.
How it attaches
Per-step command hooks in ~/.cursor/hooks.json. Six steps: the generic preToolUse scoped by matcher to the file tools, the three specialised gates, and two after* events for learning. On Windows the payload is delivered through PowerShell from a temp file rather than on stdin, prefixed with a UTF-8 BOM.
Install it with leastgrant install cursor. Configuration is written to
~/.cursor/hooks.json, alongside anything
already there — LeastGrant never removes a hook it did not add.
What a verdict does here
The three verdicts do not travel equally well. This is the table that decides whether LeastGrant is a prompt, a veto, or a suggestion on this agent.
| verdict | lands? | what actually happens |
|---|---|---|
| allow | yesprobe | Observed on beforeReadFile: an ordinary project file returned allow and the read completed. |
| ask | partialprobe | Real on two surfaces and absent on two others, and the difference decides how everything else is mapped. beforeShellExecution and beforeMCPExecution raise the approval card — observed, with Skip / Always Run / Run. beforeReadFile has no ask at all, and preToolUse ACCEPTS one and silently allows the action: Cursor logs it as a valid response, merges it, shows nothing, and the tool runs. So on the file surfaces LeastGrant never emits ask; a floored action is refused and an unfamiliar one is allowed. |
| deny | yesprobe | Honoured on every surface, and stronger than the host’s own flow: a deny on a command Cursor would otherwise auto-run without prompting was blocked outright, with LeastGrant’s reason reaching the model verbatim and the model reporting it did not retry. On preToolUse a deny stops a write, a delete, and a read — the read before the file is opened. |
Interactive and unattended
This agent does not tell the hook which mode it is in, so LeastGrant cannot distinguish an attended session from an unattended one and treats every session as unattended.
When the hook breaks
The question nobody asks until it matters: if LeastGrant crashes, times out, or cannot start, does the tool call still run?
- On crash — the call is refused. (probe) Observed with failClosed: true as the installer writes it. A handler pointed at a script that does not exist blocked the call, and the model was told "Cursor blocked the terminal tool because a hook failed closed: ... returned no output."
- On timeout — the call is refused, after 60s by default. Follows from failClosed on the gating events. Per-script timeout defaults to 60s.
- Can it be made to fail closed? — yes. The installer sets failClosed: true on all three before* gates and it was observed doing what it says.
What it can see
A verdict is only worth as much as the set of actions it is asked about. gated means LeastGrant is consulted before the thing happens; seen after means it is told afterwards and can at best withhold the result; not covered means the action happens with LeastGrant never hearing about it.
| tool class | coverage | detail |
|---|---|---|
| shell commands | gatedprobe | beforeShellExecution fires before the command, and afterShellExecution after it. Round trip measured at 97–105 ms. |
| file reads | gatedprobe | Gated, as of the preToolUse layer — this was observed and is no longer. preToolUse fires for Read with {file_path} and NO content, before the file is opened. Measured: denying a credential read there meant beforeReadFile was never requested at all and the bytes were never loaded. The old route is still registered as a second layer for reads that do not go through the Read tool, and on that route the payload does arrive with the file already loaded. |
| file writes | gatedprobe | Gated, as of the preToolUse layer — this was none and is no longer. preToolUse fires for Write with {file_path, content} and a deny stops the write. Verified live on the file that decides whether LeastGrant runs at all: an edit to <repo>/.cursor/hooks.json was refused and the file was unchanged, with the model reporting it would not work around the block. |
| deletions | gatedprobe | Gated, and refused. Cursor reports tool_name "Delete" with {file_path}, and the engine marks every delete gap.blast — more than it will ever approve on its own, meaning a person must decide — exactly as it does for a shell rm. This surface has no way to reach a person, so the faithful translation is a refusal. Agent-initiated deletes therefore do not happen on Cursor through the file tools; a terminal rm goes through beforeShellExecution, which prompts. Before the generic gate they reached no hook at all and simply happened. |
| MCP calls | gatedsource | |
| subagent spawn | gatedsource | |
| network / web | unknownunknown |
What decides this later
The highest-value write on your machine is not any single tool call. It is the file that
settles what the agent may do next time — its hook configuration, its standing
permission grants, the servers it is wired to, the memory a later session is handed as
context. LeastGrant floors an agent write to every path below, so it always reaches a
person, and test/control-files.test.ts reads this same list and fails the
build if any of them stops being floored.
The list is longer than the install path on purpose. Walking it across every agent, rather
than reading it, turned up three paths that were not floored at all — and the one that
mattered most was Antigravity's config.json, which holds that host's own
“Always allow” grants and outlives uninstalling LeastGrant entirely.
| path | kind | what it decides |
|---|---|---|
~/.cursor/hooks.json | hook config | the per-step hooks LeastGrant registers |
~/.cursor/cli.json | settings | CLI-side permission configuration |
<repo>/.cursor/rules | instructions | rules files a later session follows |
What has actually been run
Four different things, deliberately not collapsed into one badge. Reproducing an agent's invocation is not running the agent; a passing conformance suite says our side is right and nothing about whether the host ever calls us.
- Live agent test — Two human-assisted rounds through the real IDE 3.18.25 on a disposable workspace with a synthetic .env. Round one established that Cursor loads ~/.cursor/hooks.json and registers its steps (from its own log), that an ask raises the approval card on beforeShellExecution, that a deny outranks the host’s auto-run flow and reaches the model verbatim, and that failClosed blocks when the handler cannot run. Round two established the generic preToolUse gate: it is registrable locally and fires; Write arrives with {file_path, content} and a deny stops the write; Delete arrives with {file_path} and a deny stops the delete; Read arrives with {file_path} and NO content, and denying it means beforeReadFile is never requested and the file is never opened;
askthere is accepted, merged and silently allowed; and the matcher scopes, so a shell command fires only beforeShellExecution. Confirmed end to end with the shipped adapter, including a refused edit to the hooks file that decides whether LeastGrant runs. 3.18.25 · win32 · 2026-09-03 - Real transport probed — Observed rather than reproduced. Cursor delivers the payload through PowerShell with a UTF-8 BOM — first bytes ef bb bf on every captured invocation — which is the bug that once had LeastGrant refusing every Cursor tool call. Parsed correctly throughout, 87–105 ms round trip. preToolUse carries tool_name, tool_input, tool_use_id, conversation_id, generation_id, model, session_id, cursor_version, workspace_roots, user_email and transcript_path; beforeReadFile adds content and attachments; afterShellExecution adds output and duration. 3.18.25 · win32 · 2026-09-03
- Contract read from the shipped build — The 21-step event list, response validator, failClosed handling and the post-execution nature of beforeReadFile read from the shipped 42 MB workbench and extension-host bundles. 3.18.25 · win32 · 2026-09-02
- Conformance suite — Driven by test/conformance.test.ts. 3.18.25 · win32 · 2026-09-02
Not exercised on darwin, linux.
What it cannot do
Not a disclaimer. The point of everything above is that this list exists and is specific.
- preToolUse accepts
askand silently allows the action. Cursor logs the response as valid and merges it, no prompt appears, and the tool runs. Only allow and deny are enforced on that surface, so LeastGrant refuses a floored file action rather than pretending a person was consulted. - beforeReadFile is post-execution: its payload carries the file content, so a deny there suppresses what reaches the model without preventing the read. It is kept as a second layer behind preToolUse, which does prevent it.
- No permission mode reaches the hook, so LeastGrant cannot tell an attended session from an unattended one.
- Cursor has its own sandbox and its own auto-run flow, both acting before the user sees anything. A command may be attempted sandboxed, blocked by that sandbox, and retried unsandboxed, with LeastGrant consulted separately each time.
- Cursor also ingests Claude Code hooks from .claude/settings.json and ~/.claude/settings.json, so installing both adapters may double-fire.
- Cursor’s generic step is spelled
preToolUseand Claude Code’s isPreToolUse. Case-folded they are the same string, so a shared dispatcher will answer one in the other’s wire format unless it routes on the agent first. - There is no equivalent of Antigravity’s force_ask: nothing LeastGrant can return compels a prompt the host’s own settings cannot satisfy.
- preToolUse has no ask, so any verdict the engine will not auto-approve — gap.blast, a tainted session, a previously refused action — becomes a refusal rather than a prompt. Only allow and deny are enforced there.
- MCP has never been exercised live. beforeMCPExecution is registered and was never invoked, so its behaviour is contract-derived.
- The generic gate is scoped by matcher to the file tools. Cursor was observed sending Read, Write and Delete; the matcher also covers Edit, MultiEdit, ApplyPatch, StrReplace, SearchReplace, CreateFile, DeleteFile and the lower-case spellings read_file, write_file, delete_file, edit_file and search_replace, none of which have been observed and all of which are covered against a rename upstream rather than because they were seen. Move and Rename are deliberately NOT in the matcher: neither maps to a known tool kind, so gating them would refuse every move and rename rather than judge it.
- Verified on Windows only. The POSIX transport passes the payload on native stdin rather than through PowerShell and has not been exercised.
- The hook timeout was not tested; only the crash path was.
- Agent-initiated deletes through Cursor’s file tools are refused outright, not prompted. The engine says every delete needs a person and this surface cannot produce one, so refusing is the honest translation — but it is stricter than before the generic gate existed, when deletes were invisible. The refusal message points at the terminal, where a prompt does appear.