diff --git a/bt-daemon/src/hook.rs b/bt-daemon/src/hook.rs index 72d45800..61616635 100644 --- a/bt-daemon/src/hook.rs +++ b/bt-daemon/src/hook.rs @@ -90,14 +90,31 @@ pub(crate) fn should_flush_ingress_event(env: &wire::Envelope) -> bool { /// Returns `Ok` once the daemon has durably journaled the event. Delivery /// happens later; an `Err` means capture failed before acknowledgement. pub async fn run_hook(args: HookArgs, route: SessionRoute, host: HostInfo) -> anyhow::Result<()> { - if suppress_inherited_hook(&args) { - return Ok(()); + let response = + (args.source == "cursor").then(|| cursor_hook_response_for_event(args.event.as_deref())); + let result = async move { + if suppress_inherited_hook(&args) { + return Ok(()); + } + let settings = settings::AgentSettings::load_for_hook(&args.source)?; + if !settings.tracing_enabled() { + return Ok(()); + } + run_hook_with_route(args, settings.route.unwrap_or(route), host).await + } + .await; + if let Some(response) = response { + println!("{response}"); } - let settings = settings::AgentSettings::load_for_hook(&args.source)?; - if !settings.tracing_enabled() { - return Ok(()); + result +} + +fn cursor_hook_response_for_event(event: Option<&str>) -> &'static str { + if event == Some("beforeSubmitPrompt") { + r#"{"continue":true}"# + } else { + "{}" } - run_hook_with_route(args, settings.route.unwrap_or(route), host).await } /// A managed run injects its own hook definitions. Suppress an inherited diff --git a/bt-daemon/tests/cursor_pipeline.rs b/bt-daemon/tests/cursor_pipeline.rs index dacbf00e..f862226c 100644 --- a/bt-daemon/tests/cursor_pipeline.rs +++ b/bt-daemon/tests/cursor_pipeline.rs @@ -16,9 +16,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -/// Smoke-test Cursor's proposed direct Windows command through the native -/// command shell. This checks executable lookup and stdin forwarding, which -/// the POSIX-only launcher cannot exercise on a Windows runner. +/// Smoke-test Cursor's portable direct command through the native Windows +/// shell. This checks executable lookup, stdin forwarding, and its JSON +/// response without relying on a POSIX-only launcher. #[cfg(windows)] #[allow( clippy::disallowed_methods, @@ -35,14 +35,14 @@ fn cursor_direct_hook_command_receives_native_stdin_on_windows() { let shim = tmp.path().join("bt.cmd"); std::fs::write( &shim, - "@echo off\r\nif /I not \"%~1 %~2\"==\"trace hook\" exit /b 13\r\n> \"%CURSOR_HOOK_ARGS_FILE%\" echo %*\r\nset /p BT_EVENT=\r\n> \"%CURSOR_HOOK_INPUT_FILE%\" echo %BT_EVENT%\r\nexit /b 0\r\n", + "@echo off\r\nif /I not \"%~1 %~2\"==\"trace hook\" exit /b 13\r\n> \"%CURSOR_HOOK_ARGS_FILE%\" echo %*\r\nset /p BT_EVENT=\r\n> \"%CURSOR_HOOK_INPUT_FILE%\" echo %BT_EVENT%\r\necho {}\r\nexit /b 0\r\n", ) .unwrap(); let path = std::env::join_paths(std::iter::once(tmp.path().to_owned()).chain( std::env::split_paths(&std::env::var_os("PATH").expect("Windows PATH is present")), )) .unwrap(); - let command = "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000"; + let command = "bt trace hook --source cursor --event afterAgentResponse --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000"; let payload = r#"{"conversation_id":"windows-smoke","hook_event_name":"afterAgentResponse","transcript_path":null}"#; let mut child = Command::new("cmd.exe") .args(["/d", "/s", "/c", command]) @@ -67,8 +67,8 @@ fn cursor_direct_hook_command_receives_native_stdin_on_windows() { String::from_utf8_lossy(&output.stderr) ); assert!( - output.stdout.is_empty(), - "direct tracing command must not write a policy response" + String::from_utf8_lossy(&output.stdout).trim() == "{}", + "Cursor CLI hook response must be valid JSON" ); let args = std::fs::read_to_string(args_path).unwrap(); assert!(args.starts_with("trace hook --source cursor "), "{args}"); diff --git a/src/plugins/cursor/content/hooks/hooks.json b/src/plugins/cursor/content/hooks/hooks.json index 93572fa1..a09de420 100644 --- a/src/plugins/cursor/content/hooks/hooks.json +++ b/src/plugins/cursor/content/hooks/hooks.json @@ -3,84 +3,84 @@ "hooks": { "sessionStart": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event sessionStart --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "sessionEnd": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event sessionEnd --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "beforeSubmitPrompt": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event beforeSubmitPrompt --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "postToolUse": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event postToolUse --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "postToolUseFailure": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event postToolUseFailure --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "subagentStop": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event subagentStop --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "afterShellExecution": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event afterShellExecution --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "afterMCPExecution": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event afterMCPExecution --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "afterFileEdit": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event afterFileEdit --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "preCompact": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event preCompact --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "stop": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event stop --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } ], "afterAgentResponse": [ { - "command": "bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", + "command": "bt trace hook --source cursor --event afterAgentResponse --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000", "timeout": 10, "failClosed": false } diff --git a/src/plugins/cursor/test/test_capture.sh b/src/plugins/cursor/test/test_capture.sh index 15855e7a..bc8b1d1c 100755 --- a/src/plugins/cursor/test/test_capture.sh +++ b/src/plugins/cursor/test/test_capture.sh @@ -20,13 +20,17 @@ with tempfile.TemporaryDirectory(prefix="cursor-capture-") as temporary: fake_bt.write_text('''#!/bin/sh printf '%s\\n' "$*" >> "$CAPTURE_DIR/args" /bin/cat > "$CAPTURE_DIR/payload" +case " $* " in + *" --event beforeSubmitPrompt "*) printf '%s\\n' '{"continue":true}' ;; + *) printf '%s\\n' '{}' ;; +esac exit "${BT_EXIT_CODE:-0}" ''') fake_bt.chmod(0o755) environment = dict(os.environ, CAPTURE_DIR=str(root), PATH=str(fake_bin)) payload = b'{"conversation_id":"session-a","hook_event_name":"postToolUse","transcript_path":null,"tool_input":{"command":"echo $HOME; `pwd`"},"additive_field":42}\n' - # One successful and one failed invocation exercise direct executable - # forwarding; Cursor's failClosed=false setting keeps capture fail-open. + # Cursor hook responses come from the CLI after capture. The protocol + # response must still be present when capture itself fails. for exit_code in ('0', '1'): environment['BT_EXIT_CODE'] = exit_code for event, registrations in config['hooks'].items(): @@ -35,10 +39,13 @@ exit "${BT_EXIT_CODE:-0}" input=payload, env=environment, capture_output=True, executable='/bin/sh') assert result.returncode == int(exit_code), (event, result.returncode) - assert result.stdout == b'', (event, result.stdout) + expected_output = (b'{"continue":true}\n' if event == 'beforeSubmitPrompt' + else b'{}\n') + assert result.stdout == expected_output, (event, result.stdout) assert (root / 'payload').read_bytes() == payload args = (root / 'args').read_text().splitlines()[-1] assert args == ('trace hook --source cursor ' + f'--event {event} ' '--session-id-field conversation_id --event-field hook_event_name ' '--transcript-path-field transcript_path --flush-on-turn-end ' '--capture-timeout-ms 8000'), args @@ -51,5 +58,5 @@ exit "${BT_EXIT_CODE:-0}" capture_output=True, executable='/bin/sh') assert result.returncode != 0, (event, result.returncode) assert registrations[0]['failClosed'] is False -print('cursor capture: direct command forwarding and fail-open configuration OK') +print('cursor capture: portable command forwarding OK') PY diff --git a/src/plugins/cursor/validate.sh b/src/plugins/cursor/validate.sh index ec8f9649..62a0700f 100755 --- a/src/plugins/cursor/validate.sh +++ b/src/plugins/cursor/validate.sh @@ -51,7 +51,10 @@ expected = { assert set(config['hooks']) == expected for event, registrations in config['hooks'].items(): assert registrations == [{ - 'command': 'bt trace hook --source cursor --session-id-field conversation_id --event-field hook_event_name --transcript-path-field transcript_path --flush-on-turn-end --capture-timeout-ms 8000', + 'command': ('bt trace hook --source cursor ' + f'--event {event} --session-id-field conversation_id ' + '--event-field hook_event_name --transcript-path-field transcript_path ' + '--flush-on-turn-end --capture-timeout-ms 8000'), 'timeout': 10, 'failClosed': False, }]