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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ <h3>Deterministic</h3>
<div class="card">
<h3>Out of the way</h3>
<p>Small outputs, other tools, MCP results and outputs the host already saved to a file
pass through. <code>scopelet mode off</code> idles the hooks in one command.</p>
pass through. If anything inside Scopelet fails, the hook steps aside and the agent
gets the output as printed. <code>scopelet mode off</code> idles the hooks in one command.</p>
</div>
<div class="card">
<h3>Repeats and loops</h3>
Expand Down
5 changes: 5 additions & 0 deletions src/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,11 @@ pub fn hook_stdin(agent: Agent) -> Result<Value> {
.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<Value> {
Expand Down
10 changes: 8 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,15 @@ fn execute(cli: Cli) -> Result<i32> {
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);
}
_ => {}
Expand Down
57 changes: 57 additions & 0 deletions tests/automatic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>> = 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}})
}
Expand Down
Loading