Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions bt-daemon/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions bt-daemon/tests/cursor_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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])
Expand All @@ -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}");
Expand Down
24 changes: 12 additions & 12 deletions src/plugins/cursor/content/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 11 additions & 4 deletions src/plugins/cursor/test/test_capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand All @@ -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
5 changes: 4 additions & 1 deletion src/plugins/cursor/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}]
Expand Down
Loading