Other agents: Cursor and Gemini CLI
September 20, 2026 · View on GitHub
jev-bouncer's judges, tripwires, settings, cache and log are agent-agnostic. Only the hook wire
format is Claude Code's. bouncer.py cursor <event> and bouncer.py gemini <event> are thin
translation layers: they read the foreign stdin JSON, build the same internal payload that pre
and post already consume, call the same judge_command / judge_edit / judge_mcp /
scan_text, and render the same verdict in that agent's dialect. There is no second copy of any
rule. Log rows get an agent field (cursor, gemini); Claude Code rows are unchanged and are
read as claude by default.
Verification level: the protocols below are taken from the vendors' own documentation, quoted
field name by field name. Neither adapter has been run inside Cursor or Gemini CLI by the author.
The offline test suite drives each adapter with a fixture built from the documented schema and
asserts the documented output shape; it cannot prove the real client agrees. Treat the first run in
your own editor as the real test, and start in mode: dry.
Sources
Retrieved 2026-09-20. Neither vendor versions its hook protocol in the document, so re-check these pages before trusting the field names below.
The two protocols, side by side
| Claude Code (native) | Cursor | Gemini CLI | |
|---|---|---|---|
| Before a shell command | PreToolUse, tool_name: "Bash" | beforeShellExecution | BeforeTool, tool_name: "run_shell_command" |
| Command text | tool_input.command | command | tool_input.command |
| Working directory | cwd | cwd | cwd |
| Before a file edit | PreToolUse, tool_name: "Write"/"Edit"/"MultiEdit" | preToolUse (generic; edit tool names not documented) | BeforeTool, tool_name: "write_file" / "replace" |
| Edit path / content | tool_input.file_path, .content / .old_string / .new_string | tool_input (shape not documented for edit tools) | tool_input.file_path, .content / .old_string / .new_string |
| Before an MCP call | PreToolUse, tool_name: "mcp__<server>__<tool>" | beforeMCPExecution | BeforeTool, tool_name: "mcp_<server>_<tool>" |
| MCP arguments | tool_input (object) | tool_input (JSON string) + mcp_server_name | tool_input (object) |
| After a tool returns | PostToolUse, tool_response | postToolUse, tool_output (JSON string) | AfterTool, tool_response (llmContent, returnDisplay, error) |
| Session id | session_id | conversation_id | session_id |
| Allow | {"hookSpecificOutput":{"permissionDecision":"allow"}} | {"permission":"allow"} | {"decision":"allow"} |
| Deny | …"permissionDecision":"deny" | {"permission":"deny","user_message":…,"agent_message":…} | {"decision":"deny","reason":…} |
| Ask the human | …"permissionDecision":"ask" | {"permission":"ask"} | no equivalent |
| Say nothing | print nothing | not available on permission hooks — empty or off-schema stdout blocks the action | print nothing |
| Add context after a tool | hookSpecificOutput.additionalContext | additional_context | hookSpecificOutput.additionalContext |
| Replace/suppress a tool result | {"decision":"block","reason":…} | not available | {"decision":"deny","reason":…} (replaces what the model sees) |
| Exit code 2 | n/a | block (same as permission: "deny") | block; stderr is the reason |
| Other non-zero exit | n/a | fail open unless failClosed: true | warning, interaction proceeds |
| Config file | hooks/hooks.json (plugin) | ~/.cursor/hooks.json or <project>/.cursor/hooks.json | ~/.gemini/settings.json or <project>/.gemini/settings.json |
| Config shape | hooks.<Event>[].hooks[] | {"version": 1, "hooks": {"<event>": [{"command", "matcher", "timeout", "failClosed"}]}} | {"hooks": {"<Event>": [{"matcher", "hooks": [{"type": "command", "command", "timeout"}]}]}} |
| Timeout unit | seconds | not stated in the docs (examples use 30; assumed seconds) | milliseconds, default 60000 |
Cursor specifics
- Base input on every hook:
conversation_id,generation_id,model,model_id,model_params,hook_event_name,cursor_version,workspace_roots,user_email,transcript_path. The adapter usesconversation_idas the session id and falls back toworkspace_roots[0]when an event carries nocwd. beforeShellExecutioninput is{"command", "cwd", "sandbox"}; output is{"permission": "allow" | "deny" | "ask", "user_message", "agent_message"}.beforeMCPExecutioninput is{"tool_name", "tool_input", "mcp_server_name"}plus either{"url", "mcp_server_url"}(HTTP/SSE) or{"command"}(stdio). Notetool_inputhere is a JSON params string, not an object; the adapter parses it.postToolUseinput is{"tool_name", "tool_input", "tool_output", "tool_use_id", "cwd", "duration", …};tool_outputis a JSON-stringified payload. Output is{"updated_mcp_tool_output", "additional_context"}— there is no way to block from here, soinject_action: blockdegrades towarnon Cursor.preToolUseinput is{"tool_name", "tool_input", "tool_use_id", "cwd", "agent_message"}; output is{"permission": "allow" | "deny", "user_message", "agent_message", "updated_input"}. The docs give thetool_inputshape only for"tool_name": "Shell"({"command", "working_directory"}) and say"Shell, Read, Write, MCP, Task, etc."without enumerating the file-edit tools or their arguments. Not confirmed, not invented: the adapter does not match on Cursor tool names at all. It judges apreToolUsepayload as an edit whentool_input.file_pathis present andtool_input.commandis not, readingcontent/old_string+new_string/edits[]— the same shapesedit_partsalready handles. If your Cursor build names those arguments differently, the edit guard silently does nothing; checkbouncer.py reportforedits: judgedrows after the first session.afterFileEditfires after the edit and has no output fields, so it cannot gate an edit. It is not wired.- There is no "no opinion" answer on a Cursor permission hook. The docs are explicit: for
beforeShellExecution,beforeMCPExecution,beforeReadFile,beforeTabFileRead,subagentStartandpreToolUse, "invalid JSON or a response that doesn't match the hook's schema blocks the action". The adapter therefore always prints a schema-valid object and uses"ask"as the neutral. Consequences:- On
beforeShellExecution/beforeMCPExecution, anything jev-bouncer does not auto-allow or deny becomes a permission prompt. Inmode: drythat means Cursor asks you about every judged command instead of staying silent: dry on Cursor reads as "log everything, decide nothing, never auto-run". It never widens what Cursor would have allowed. - On
preToolUse,"ask"is documented as "accepted by the schema but not enforced forpreToolUsetoday", so the neutral is a genuine no-op there.
- On
- Cursor watches its config files and reloads them automatically.
- Cloud agents load command hooks only from
<repo>/.cursor/hooks.json, and do not runbeforeMCPExecution/afterMCPExecution.
Gemini CLI specifics
- Base input on every hook:
session_id,transcript_path,cwd,hook_event_name,timestamp. BeforeToolinput addstool_name,tool_input,mcp_context,original_request_name.AfterTooladdstool_response, an object withllmContent,returnDisplayand an optionalerror(the plugin's existingflattenhandles it unchanged).- Output:
decisionis"allow"or"deny"(alias"block"), withreasonrequired on a deny — that text is sent to the agent as a tool error.hookSpecificOutput.additionalContextis appended to the tool result.systemMessageis shown to the user. There is noask, so a jev-bouncerdeferprints nothing and Gemini's own approval rules apply — andfail: askhas no Gemini equivalent, so an API failure inmode: onfails open there. - Built-in tool argument names, from the tools docs:
run_shell_commandtakescommand,description,directory;write_filetakesfile_path,content;replacetakesfile_path,old_string,new_string. These line up with Claude Code'sBash,WriteandEditone for one, which is why the adapter is a rename. - MCP tools are named
mcp_<serverName>_<toolName>. Gemini's own docs warn that the parser splits on the first underscore after themcp_prefix and that a server name containing an underscore breaks it; the adapter splits the same way and inherits the same limitation. - Hooks must print nothing to stdout but the final JSON. If stdout is not JSON, "the CLI will
default to 'Allow' and treat the entire output as a
systemMessage" — so a crash in the adapter fails open, matching the plugin's own default. timeoutis in milliseconds here (default60000), unlike Claude Code's seconds.
What is not confirmed
- Cursor's file-edit tool names and their
tool_inputargument names (see above). The adapter matches on payload shape instead of inventing names. - Whether an empty stdout on a Cursor permission hook is treated as "no decision" or as invalid output. The docs only say off-schema output blocks, so the adapter never relies on silence there.
- The unit of Cursor's
timeoutfield. The examples use30, which is only sensible as seconds. - Whether Gemini's
{"decision": "allow"}onBeforeToolskips Gemini's own confirmation prompt or merely declines to block. The docs say"allow"means the hook consents and that "specific impact depends on the event", without stating it forBeforeTool. If it does not skip the prompt,mode: onsimply behaves likemode: guardon Gemini. - Neither vendor states a minimum client version for these fields.
Install
Nothing here writes into ~/.cursor or ~/.gemini. Run this yourself, from the repo root, after
reading it:
#!/usr/bin/env sh
# install.sh -- wire jev-bouncer into Cursor and/or Gemini CLI (user-level config).
set -eu
BOUNCER="$(cd "$(dirname "\$0")" && pwd)/bouncer.py"
test -f "$BOUNCER" || { echo "bouncer.py not found next to this script" >&2; exit 1; }
python3 "$BOUNCER" version >/dev/null
# --- Cursor: ~/.cursor/hooks.json
mkdir -p "$HOME/.cursor"
if [ -e "$HOME/.cursor/hooks.json" ]; then
echo "~/.cursor/hooks.json already exists -- merge adapters/cursor/hooks.json into it by hand." >&2
else
sed "s#python3 /ABSOLUTE/PATH/TO/jev-bouncer/bouncer.py#python3 $BOUNCER#g" \
adapters/cursor/hooks.json > "$HOME/.cursor/hooks.json"
echo "wrote ~/.cursor/hooks.json"
fi
# --- Gemini CLI: ~/.gemini/settings.json
mkdir -p "$HOME/.gemini"
if [ -e "$HOME/.gemini/settings.json" ]; then
echo "~/.gemini/settings.json already exists -- merge the \"hooks\" key from" \
"adapters/gemini/settings.json into it by hand." >&2
else
sed "s#python3 /ABSOLUTE/PATH/TO/jev-bouncer/bouncer.py#python3 $BOUNCER#g" \
adapters/gemini/settings.json > "$HOME/.gemini/settings.json"
echo "wrote ~/.gemini/settings.json"
fi
echo "Start in mode: dry. Set it in ~/.jev-bouncer/config.json, then read 'bouncer.py report'."
Both clients already have config files for most people, so expect the merge branch. The only thing
that matters is that the command strings hold an absolute path to bouncer.py and end in
cursor <event> or gemini <event>.
Checking it works
echo '{"command":"git status","cwd":"'"$PWD"'","hook_event_name":"beforeShellExecution"}' \
| python3 bouncer.py cursor beforeShellExecution
# -> {"permission": "allow", ...} (from the built-in allowlist, no API call)
echo '{"tool_name":"run_shell_command","tool_input":{"command":"git status"},"cwd":"'"$PWD"'","session_id":"x"}' \
| python3 bouncer.py gemini BeforeTool
# -> {"decision": "allow", ...}
Then python3 bouncer.py report — rows judged through an adapter carry "agent": "cursor" or
"agent": "gemini".