diff --git a/README.md b/README.md index 328f81f..c7671a2 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,9 @@ need to. What it is not: it does not call another model to summarize, it does not intercept API traffic, it does not rewrite your conversation history, and it -does not touch small outputs at all. +does not touch small outputs at all. If anything goes wrong inside Scopelet, a +corrupt cache, a full disk, a bug, the hook steps aside and your agent gets +the output exactly as printed. ## How it works diff --git a/docs/design.md b/docs/design.md index 97842f9..8393a6b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -132,7 +132,11 @@ Code a `SubagentStart` hook gives each subagent the recovery line and asks its final report for conclusions with `file:line` evidence, without restating commands or raw output, since that report becomes its parent's input. These are preferences, never forced truncation or a reason to omit verification. -Host settings are merged, original bytes backed up, and only exact Scopelet hook +A hook never blocks or breaks its host: a malformed or oversized event, an +unwritable store or state, a panic (release builds abort on one) or an +unwritable stdout all answer `{}` with status 0, so the host keeps its native +output; each Claude Code hook also carries a 5-second timeout, about thirty +times the slowest measured call (a 30 MB output). Host settings are merged, original bytes backed up, and only exact Scopelet hook entries are removed. The resolved executable is copied during installation so hooks never trigger downloads. A package manager upgrades the `scopelet` on the PATH, not that copy: at a Claude Code session start, when the pinned copy diff --git a/docs/verification.md b/docs/verification.md index a556eac..d3fe411 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -59,6 +59,10 @@ The replacement shape follows the Codex and OpenCode adapters have equivalent contract tests under `tests/automatic.rs`. +A hook fails open: `tests/automatic.rs` sends every host malformed events, a +cache that cannot be written and a forced panic, and each answers with status +0 and JSON, `{}` on a failure, so the host keeps its native output. + The installer is exercised in temporary configurations for every host: hook installation, idempotent reinstall, mode changes, preservation of unrelated hooks, and uninstall. `scripts/check_install.py` covers the published launcher. diff --git a/site/index.html b/site/index.html index 09c8762..096ff68 100644 --- a/site/index.html +++ b/site/index.html @@ -164,7 +164,8 @@

Deterministic

Out of the way

Small outputs, other tools, MCP results and outputs the host already saved to a file - pass through. scopelet mode off idles the hooks in one command.

+ pass through. If anything inside Scopelet fails, the hook steps aside and the agent + gets the output as printed. scopelet mode off idles the hooks in one command.

Repeats and loops

diff --git a/src/integration.rs b/src/integration.rs index 3321d85..17f244c 100644 --- a/src/integration.rs +++ b/src/integration.rs @@ -934,6 +934,11 @@ pub fn hook_stdin(agent: Agent) -> Result { .read_to_end(&mut bytes)?; ensure!(bytes.len() <= MAX_INPUT * 3, "hook input too large"); let event: Value = serde_json::from_slice(&bytes)?; + // Lets the tests check that a panic still leaves the host's output alone. + #[cfg(debug_assertions)] + if std::env::var_os("SCOPELET_TEST_HOOK_PANIC").is_some() { + panic!("test panic"); + } hook(agent, &event) } fn hook(agent: Agent, event: &Value) -> Result { diff --git a/src/main.rs b/src/main.rs index 6ccdbd2..45c789b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -333,9 +333,15 @@ fn execute(cli: Cli) -> Result { return Ok(0); } Cmd::Hook { agent } => { - // Hook failures must not prevent the host from executing the native call. + // A hook never blocks or breaks its host: an error, a panic (a + // release build aborts on one) or an unwritable stdout all answer + // `{}` with status 0, and the host keeps its native output. + std::panic::set_hook(Box::new(|_| { + let _ = writeln!(std::io::stdout(), "{{}}"); + std::process::exit(0); + })); let result = integration::hook_stdin(*agent).unwrap_or_else(|_| json!({})); - print(&result)?; + let _ = print(&result); return Ok(0); } _ => {} diff --git a/tests/automatic.rs b/tests/automatic.rs index f79cc57..646a20d 100644 --- a/tests/automatic.rs +++ b/tests/automatic.rs @@ -1132,6 +1132,63 @@ fn shown(event: &Value, result: &Value, stream: &str) -> String { .to_owned() } +/// A hook never blocks or breaks its host. Malformed events, a cache that +/// cannot be written and even a panic answer with status 0 and JSON, and a +/// failure answers `{}`, so the host keeps its native output. +#[test] +fn a_hook_fails_open_whatever_goes_wrong() { + let dir = tempfile::tempdir().unwrap(); + let log: String = (0..3000) + .map(|i| format!("line {i} of the build, error E{}\n", i % 7)) + .collect(); + let good = bash_result("f", "npm test", &log).to_string(); + let inputs: Vec> = vec![ + Vec::new(), + b"\xff\xfe not json".to_vec(), + b"[1,2]".to_vec(), + b"null".to_vec(), + json!({"hook_event_name":"PostToolUse","tool_name":"Bash","tool_response":{"stdout":42}}) + .to_string() + .into_bytes(), + json!({"hook_event_name":"PostToolUseFailure","tool_name":"Bash","tool_input":{"command":"cargo test"},"error":7}) + .to_string() + .into_bytes(), + json!({"hook_event_name":"PostToolUse","session_id":"../../x","tool_name":"Bash","tool_input":{"command":{"a":1}},"tool_response":{"stdout":log}}) + .to_string() + .into_bytes(), + ]; + let answer = |out: &std::process::Output| -> Value { + assert!(out.status.success(), "{out:?}"); + serde_json::from_slice(&out.stdout).unwrap() + }; + for agent in ["claude", "codex", "opencode"] { + for input in &inputs { + let out = cli(dir.path()) + .args(["hook", agent]) + .write_stdin(input.clone()) + .output() + .unwrap(); + answer(&out); + } + } + let file = dir.path().join("file"); + fs::write(&file, "").unwrap(); + let out = cli(dir.path()) + .env("SCOPELET_CACHE_DIR", &file) + .args(["hook", "claude"]) + .write_stdin(good.clone()) + .output() + .unwrap(); + assert_eq!(answer(&out), json!({})); + let out = cli(dir.path()) + .env("SCOPELET_TEST_HOOK_PANIC", "1") + .args(["hook", "claude"]) + .write_stdin(good) + .output() + .unwrap(); + assert_eq!(answer(&out), json!({})); +} + fn bash_result(session: &str, command: &str, stdout: &str) -> Value { json!({"hook_event_name":"PostToolUse","session_id":session,"tool_name":"Bash","tool_input":{"command":command},"tool_response":{"stdout":stdout,"stderr":"","interrupted":false,"isImage":false}}) }