From 493fb0e5053b0c61de0c33e402937380c346b67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:08:04 +0000 Subject: [PATCH 1/6] feat(agent): run dependency hooks on the host for Docker sandbox runs --- docs/agent-profiles.md | 2 + docs/sandboxing.md | 4 + src/handlers/agent/validate.rs | 6 +- src/handlers/run/docker_sandbox.rs | 131 +++++++++ src/handlers/run/entry.rs | 92 +++++-- src/handlers/run/proxy.rs | 411 +++++++++++++++++++++++------ 6 files changed, 546 insertions(+), 100 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 1653beb4..836aa309 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -175,6 +175,8 @@ allow_hooks = ["dependency_check", "secret_scan"] The child receives only a scoped local broker token, never your API key. Hooks are disabled by default. +`dependency_check` works with the hooks from `stashbase agent hooks deps install`. With the native backend they run your installed CLI, which asks the Agent Proxy to call the API. The Docker image has no Stashbase CLI, so in a Docker run a small stand-in is mounted as `stashbase` inside the container: it forwards the hook's input to the Agent Proxy, which runs `stashbase agent hooks` on the host, confined the same way as the secret scan below, and returns its answer. If the proxy can't be reached, the stand-in blocks the command instead of letting the install through unchecked. In a Docker run the agent reads only its project-level hook config (for example `.claude/settings.json` in the repo), not the global one in your home directory, so install the hook without `--global` for Docker runs. + `secret_scan` lets the git hooks from `stashbase scan install` run inside the sandbox. The hook sends only a token to the Agent Proxy, which runs `stashbase scan staged` (or `unpushed`) on the host, in the run's working directory, with your key. Because the agent can edit the repo's scan config, these scans ignore its `match` and `output-dir` settings; `excluded-files` and `ignored-secrets` still apply. The host-side scan is confined, so a symlink or a `.git` redirect planted by the agent can't point it at your other files. On Linux it sees only system files plus the run's working tree, git directories and the CLI binary. On macOS it can't read file contents anywhere users' data lives (home directories, `/Volumes`, `/tmp`, `/var/folders`, `/opt`) except those same run paths; system files stay readable, and file names and sizes stay visible because path lookup needs them. The profile's `deny_read` entries stay denied on both. This uses Seatbelt on macOS and bubblewrap on Linux; where neither is available (including Windows), a profile with `secret_scan` refuses to start. Restricted scans also stop at 1 MiB per changed file, 32 MiB in total, 10,000 files or 1,000 commits; larger changes have to be committed from outside the sandbox. The hook needs `curl` in the sandbox (the default Docker image has it). Hooks installed before this existed need `stashbase scan install` once more to pick up the sandbox-aware block. Without `secret_scan`, a scan hook inside the sandbox fails with a message pointing here. ## Audit Logs and Session Revocation diff --git a/docs/sandboxing.md b/docs/sandboxing.md index 512ececd..f3b9e398 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -111,6 +111,10 @@ stashbase agent run --profile coding --docker-image node:22-alpine -- claude Your global `git config user.name` and `user.email` (if configured on the host) are forwarded into the container as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL`. This is the one piece of host configuration deliberately forwarded despite the filesystem allow-list, since it's authorship metadata, not a credential — without it, `git commit` inside the sandbox fails with no identity configured. It does not grant push access: `git push` (or any other authenticated git operation) still needs a real credential, wired through `[secrets]` like `GITHUB_TOKEN`, or run from outside the sandbox. Raw SSH keys are never forwarded. A profile that explicitly sets one of these four env vars itself takes precedence over the forwarded host value. +### Dependency hooks + +The dependency check hooks (`allow_hooks = ["dependency_check"]`) work in the Docker sandbox without the Stashbase CLI in the image: a stand-in `stashbase` is mounted at `/usr/local/bin/stashbase`, and `stashbase agent hooks` runs on the host instead, confined like the secret scan below. Only project-level hook configs are visible to the agent in the container. See [Agent Profiles](agent-profiles.md#api-hooks). + ### Secret scan hooks Git hooks installed with `stashbase scan install` work in the sandbox when the profile sets `allow_hooks = ["secret_scan"]`. The sandbox has neither the Stashbase CLI nor your API key, so the hook asks the Agent Proxy to run the scan on the host against the same working directory, with `curl` and a per-run token. Findings come back to the agent, and the commit or push is blocked, exactly as outside the sandbox. The scan itself runs confined (Seatbelt on macOS, bubblewrap on Linux): outside system files, it can read only the run's working tree, its git directories and the CLI binary, never your other files that the agent points it at through symlinks or `.git` redirects; `secret_scan` is unavailable on Windows for that reason. Like any git hook, it is skipped by `git commit --no-verify` — a safety net, not an enforcement boundary. See [Agent Profiles](agent-profiles.md#api-hooks). diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index 73d531f1..f2b2d02d 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -709,7 +709,11 @@ fn validate_hook_capabilities(profile: &AgentProfile) -> Vec { }) .collect(); } - if profile.allow_hooks.iter().any(|hook| hook == "secret_scan") { + let docker = profile.sandbox.backend == crate::models::agent::SandboxBackend::Docker; + let runs_confined = |hook: &String| { + hook == "secret_scan" || (docker && hook == "dependency_check") + }; + if profile.allow_hooks.iter().any(runs_confined) { if let Some(reason) = crate::handlers::run::scan_sandbox::unavailable_reason() { return vec![fail("Hook capability", format!("{reason}."))]; } diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 107007e6..21d1339d 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -513,6 +513,7 @@ const PROXY_URL_ENV_KEYS: &[&str] = &[ "https_proxy", crate::api::dependencies::HOOK_BROKER_URL_ENV, crate::handlers::run::proxy::SCAN_BROKER_URL_ENV, + crate::handlers::run::proxy::AGENT_HOOK_URL_ENV, ]; /// Rewrites the proxy's child-process env vars so the container reaches the @@ -891,6 +892,7 @@ pub(crate) fn docker_run_command( append_git_mounts(&mut args, git_mounts)?; } append_ca_bundle_mount(&mut args, &cwd_str, env_vars)?; + append_agent_hook_shim_mount(&mut args, env_vars)?; // A named Docker volume, not a bind mount of the real host home // directory, persists login/config state (e.g. Claude Code's @@ -1520,6 +1522,54 @@ fn append_ca_bundle_mount( Ok(()) } +/// Stands in for the Stashbase CLI, which the sandbox image doesn't have, so +/// agent hooks configured as `stashbase agent hooks` run on the host through +/// the Agent Proxy. Exit 2 blocks the tool call in Claude Code and Codex, so +/// an unreachable broker never lets an install through unchecked. +pub(crate) const AGENT_HOOK_SHIM: &str = r#"#!/bin/sh +# Stashbase agent hook stand-in, mounted by `stashbase agent run`. +if [ "$#" -eq 2 ] && [ "$1" = agent ] && [ "$2" = hooks ]; then + if [ -z "${STASHBASE_AGENT_HOOK_URL:-}" ] || ! command -v curl >/dev/null 2>&1; then + echo "Stashbase dependency check is unavailable in this sandbox." >&2 + exit 2 + fi + curl -sS --fail-with-body --noproxy '*' -X POST \ + -H "Authorization: Bearer ${STASHBASE_HOOK_BROKER_TOKEN:-}" \ + --data-binary @- "$STASHBASE_AGENT_HOOK_URL" || { + echo "Stashbase dependency check failed; blocking this command." >&2 + exit 2 + } + exit 0 +fi +echo "stashbase: only 'stashbase agent hooks' is available inside the agent sandbox." >&2 +exit 1 +"#; + +/// Mounts `AGENT_HOOK_SHIM` as `stashbase` on the container's PATH when the +/// run serves the agent hook route. +fn append_agent_hook_shim_mount( + args: &mut Vec, + env_vars: &std::collections::HashMap, +) -> Result<(), String> { + if !env_vars.contains_key(crate::handlers::run::proxy::AGENT_HOOK_URL_ENV) { + return Ok(()); + } + let path = std::env::temp_dir().join("stashbase-agent-hook-shim-v1"); + std::fs::write(&path, AGENT_HOOK_SHIM) + .map_err(|error| format!("failed to write the agent hook stand-in: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("failed to make the agent hook stand-in executable: {error}"))?; + } + args.extend([ + "-v".to_owned(), + format!("{}:/usr/local/bin/stashbase:ro", path.to_string_lossy()), + ]); + Ok(()) +} + fn is_nested_under(path: &str, ancestor: &str) -> bool { PathBuf::from(path) != PathBuf::from(ancestor) && PathBuf::from(path).starts_with(PathBuf::from(ancestor)) @@ -1695,6 +1745,87 @@ mod tests { assert_eq!(rewritten["STASHBASE_GH_TOKEN"], "127.0.0.1"); } + #[test] + fn agent_hook_shim_is_mounted_only_when_the_route_is_served() { + let mut without = Vec::new(); + append_agent_hook_shim_mount(&mut without, &std::collections::HashMap::new()).unwrap(); + assert!(without.is_empty()); + + let env_vars = std::collections::HashMap::from([( + crate::handlers::run::proxy::AGENT_HOOK_URL_ENV.to_owned(), + "http://host.docker.internal:1/__stashbase/agent-hook".to_owned(), + )]); + let mut with = Vec::new(); + append_agent_hook_shim_mount(&mut with, &env_vars).unwrap(); + + assert_eq!(with[0], "-v"); + assert!(with[1].ends_with(":/usr/local/bin/stashbase:ro"), "{}", with[1]); + let source = with[1].trim_end_matches(":/usr/local/bin/stashbase:ro"); + assert_eq!(std::fs::read_to_string(source).unwrap(), AGENT_HOOK_SHIM); + } + + #[cfg(unix)] + fn run_shim(args: &[&str], env: &[(&str, &str)], with_curl: bool) -> (i32, String) { + use std::os::unix::fs::PermissionsExt; + + let bin = std::env::temp_dir().join(format!("stashbase-shim-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&bin).unwrap(); + if with_curl { + let curl = bin.join("curl"); + std::fs::write(&curl, "#!/bin/sh\necho \"curl $*\"\nexit ${FAKE_CURL_EXIT:-0}\n").unwrap(); + std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(AGENT_HOOK_SHIM) + .arg("stashbase") + .args(args) + .env_clear() + .env("PATH", &bin) + .envs(env.iter().copied()) + .output() + .unwrap(); + let _ = std::fs::remove_dir_all(&bin); + let text = String::from_utf8_lossy(&output.stdout).into_owned() + + &String::from_utf8_lossy(&output.stderr); + (output.status.code().unwrap(), text) + } + + #[cfg(unix)] + const SHIM_ENV: [(&str, &str); 2] = [ + ("STASHBASE_AGENT_HOOK_URL", "http://h:1/__stashbase/agent-hook"), + ("STASHBASE_HOOK_BROKER_TOKEN", "t"), + ]; + + #[cfg(unix)] + #[test] + fn agent_hook_shim_forwards_to_the_broker() { + let (code, out) = run_shim(&["agent", "hooks"], &SHIM_ENV, true); + + assert_eq!(code, 0, "{out}"); + assert!(out.contains("http://h:1/__stashbase/agent-hook"), "{out}"); + assert!(out.contains("Authorization: Bearer t"), "{out}"); + } + + #[cfg(unix)] + #[test] + fn agent_hook_shim_blocks_when_the_broker_fails_or_is_missing() { + let mut failing = SHIM_ENV.to_vec(); + failing.push(("FAKE_CURL_EXIT", "22")); + assert_eq!(run_shim(&["agent", "hooks"], &failing, true).0, 2); + assert_eq!(run_shim(&["agent", "hooks"], &[], true).0, 2); + assert_eq!(run_shim(&["agent", "hooks"], &SHIM_ENV, false).0, 2); + } + + #[cfg(unix)] + #[test] + fn agent_hook_shim_refuses_other_commands() { + let (code, out) = run_shim(&["scan", "staged"], &SHIM_ENV, true); + + assert_eq!(code, 1, "{out}"); + assert!(!out.contains("curl"), "{out}"); + } + #[test] fn rewrite_proxy_urls_rewrites_the_scan_broker_url() { let mut env_vars = std::collections::HashMap::new(); diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 42cc9760..246dd686 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -388,6 +388,7 @@ fn hook_broker_config( api_key: Option, workdir: &Path, denied_read: &[String], + docker: bool, ) -> anyhow::Result> { let Some(api_key) = api_key.filter(|key| !key.is_empty()) else { return Ok(None); @@ -395,36 +396,43 @@ fn hook_broker_config( if !hooks.any() { return Ok(None); } - // The scan runs the host's own CLI in the directory the agent works in. - let secret_scan = if hooks.secret_scan { + // Both run the host's own CLI, confined, in the directory the agent + // works in. The agent hook route is only needed under Docker, whose + // image has no Stashbase CLI; natively the hook runs the real CLI. + let confined = |timeout_secs: u64| -> anyhow::Result { if let Some(reason) = super::scan_sandbox::unavailable_reason() { anyhow::bail!(reason); } - let exe = std::env::current_exe().map_err(|error| { - anyhow::anyhow!("secret_scan cannot locate the stashbase binary: {error}") - })?; - Some(super::proxy::SecretScanConfig { + let exe = std::env::current_exe() + .map_err(|error| anyhow::anyhow!("cannot locate the stashbase binary: {error}"))?; + Ok(super::proxy::SecretScanConfig { workdir: workdir.to_owned(), isolation: super::proxy::ScanIsolation::Confined( super::scan_sandbox::ScanConfinement::for_run(workdir, &exe, denied_read), ), - timeout: std::time::Duration::from_secs(120), + timeout: std::time::Duration::from_secs(timeout_secs), }) - } else { - None }; + let secret_scan = hooks.secret_scan.then(|| confined(120)).transpose()?; + let agent_hook = (hooks.dependency_check && docker) + .then(|| confined(60)) + .transpose()?; Ok(Some(super::proxy::HookBrokerConfig { api_key, dependency_check: hooks.dependency_check, secret_scan, + agent_hook, })) } /// Checked before any sandbox setup, so a refusal leaves nothing to clean up. -fn ensure_secret_scan_can_be_confined( +/// Under Docker, `dependency_check` also runs the host's CLI confined. +fn ensure_hooks_can_be_confined( hooks: crate::models::agent::EnabledHooks, + backend: crate::models::agent::SandboxBackend, ) -> anyhow::Result<()> { - if hooks.secret_scan { + let docker = backend == crate::models::agent::SandboxBackend::Docker; + if hooks.secret_scan || (hooks.dependency_check && docker) { if let Some(reason) = super::scan_sandbox::unavailable_reason() { anyhow::bail!(reason); } @@ -447,13 +455,13 @@ pub async fn handle_remote_agent_run( source_env_names: Vec, silent: bool, ) -> anyhow::Result<()> { - ensure_secret_scan_can_be_confined(hooks)?; let cmd = command.first().context("no command provided")?.clone(); let args = command.into_iter().skip(1).collect(); let denied_read_paths = policy.denied_read_paths.clone(); let mut denied_write_paths = policy.denied_write_paths.clone(); let allow_network_listeners = policy.allow_network_listeners; let backend = policy.backend; + ensure_hooks_can_be_confined(hooks, backend)?; let agent_image_source = super::docker_sandbox::AgentImageSource::from_profile( policy.sandbox_image.as_deref(), policy.sandbox_dockerfile.as_deref(), @@ -499,7 +507,13 @@ pub async fn handle_remote_agent_run( Some(workdir) => workdir.to_owned(), None => std::env::current_dir()?, }; - let remote_hooks = hook_broker_config(hooks, Some(api_key), &scan_workdir, &denied_read_paths)?; + let remote_hooks = hook_broker_config( + hooks, + Some(api_key), + &scan_workdir, + &denied_read_paths, + docker_network.is_some(), + )?; let proxy_start_result = if let Some(network) = &docker_network { super::proxy::Proxy::start_remote_with_hook_and_bind_host( remote, @@ -1581,7 +1595,6 @@ async fn handle_run( hook_api_key: Option, local_session: Option, ) -> anyhow::Result<()> { - ensure_secret_scan_can_be_confined(hooks)?; apply_secret_bindings(&mut secrets, secret_bindings); let secrets_hash_map = if secret_bindings.is_empty() { env::expand_and_inject_env(&mut secrets) @@ -1701,6 +1714,7 @@ async fn handle_run( .as_ref() .map(|policy| policy.backend) .unwrap_or_default(); + ensure_hooks_can_be_confined(hooks, backend)?; let agent_image_source = super::docker_sandbox::AgentImageSource::from_profile( proxy_policy .as_ref() @@ -1766,8 +1780,13 @@ async fn handle_run( Some(workdir) => workdir.to_owned(), None => std::env::current_dir()?, }; - let local_hooks = - hook_broker_config(hooks, hook_api_key, &scan_workdir, &denied_read_paths)?; + let local_hooks = hook_broker_config( + hooks, + hook_api_key, + &scan_workdir, + &denied_read_paths, + docker_network.is_some(), + )?; let proxy_start_result = if let Some(network) = &docker_network { super::proxy::Proxy::start_with_hook_and_bind_host( secrets_hash_map, @@ -2068,7 +2087,7 @@ mod tests { secret_scan: true, }; - let result = hook_broker_config(scan_only, Some("k".to_owned()), workdir, &[]); + let result = hook_broker_config(scan_only, Some("k".to_owned()), workdir, &[], false); if let Some(reason) = crate::handlers::run::scan_sandbox::unavailable_reason() { // Refused up front rather than run unconfined. @@ -2089,6 +2108,37 @@ mod tests { )); } + #[test] + fn hook_broker_config_serves_the_agent_hook_only_under_docker() { + let workdir = std::path::Path::new("/repo"); + let deps_only = EnabledHooks { + dependency_check: true, + secret_scan: false, + }; + + let native = hook_broker_config(deps_only, Some("k".to_owned()), workdir, &[], false) + .unwrap() + .unwrap(); + assert!(native.agent_hook.is_none()); + + let docker = hook_broker_config(deps_only, Some("k".to_owned()), workdir, &[], true); + if let Some(reason) = crate::handlers::run::scan_sandbox::unavailable_reason() { + let Err(error) = docker else { + panic!("the agent hook ran without confinement"); + }; + assert_eq!(error.to_string(), reason); + return; + } + let docker = docker.unwrap().unwrap(); + assert!(docker.dependency_check); + let hook = docker.agent_hook.unwrap(); + assert_eq!(hook.workdir, workdir); + assert!(matches!( + hook.isolation, + crate::handlers::run::proxy::ScanIsolation::Confined(_) + )); + } + #[test] fn hook_broker_config_needs_a_hook_and_an_api_key() { let workdir = std::path::Path::new("/repo"); @@ -2098,21 +2148,21 @@ mod tests { }; assert!( - hook_broker_config(EnabledHooks::default(), Some("k".to_owned()), workdir, &[]) + hook_broker_config(EnabledHooks::default(), Some("k".to_owned()), workdir, &[], false) .unwrap() .is_none() ); - assert!(hook_broker_config(both, None, workdir, &[]) + assert!(hook_broker_config(both, None, workdir, &[], false) .unwrap() .is_none()); - assert!(hook_broker_config(both, Some(String::new()), workdir, &[]) + assert!(hook_broker_config(both, Some(String::new()), workdir, &[], false) .unwrap() .is_none()); let deps_only = EnabledHooks { dependency_check: true, secret_scan: false, }; - let config = hook_broker_config(deps_only, Some("k".to_owned()), workdir, &[]) + let config = hook_broker_config(deps_only, Some("k".to_owned()), workdir, &[], false) .unwrap() .unwrap(); assert!(config.dependency_check && config.secret_scan.is_none()); diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index 0371273b..36aaf090 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -88,6 +88,11 @@ const SECRET_SCAN_HOOK_PATH: &str = "/__stashbase/scan"; /// Where a sandboxed git hook asks the proxy to run `stashbase scan` on the host. pub const SCAN_BROKER_URL_ENV: &str = "STASHBASE_SCAN_BROKER_URL"; const SECRET_SCAN_BODY_LIMIT: usize = 1024 * 1024; +const AGENT_HOOK_PATH: &str = "/__stashbase/agent-hook"; +/// Where the `stashbase` stand-in in the Docker sandbox sends agent hook +/// payloads, so `stashbase agent hooks` runs on the host. +pub const AGENT_HOOK_URL_ENV: &str = "STASHBASE_AGENT_HOOK_URL"; +const AGENT_HOOK_BODY_LIMIT: usize = 1024 * 1024; /// Authenticated hooks the proxy serves on the parent's behalf, so the child /// never holds the Stashbase API key. @@ -96,6 +101,9 @@ pub struct HookBrokerConfig { pub dependency_check: bool, /// Enables the secret-scan route. pub secret_scan: Option, + /// Enables the route that runs `stashbase agent hooks` on the host, for + /// a sandbox without the CLI. Only served with `dependency_check`. + pub agent_hook: Option, } pub struct SecretScanConfig { @@ -138,6 +146,7 @@ struct HookBroker { api_key: String, dependency_check: bool, secret_scan: Option, + agent_hook: Option, // One scan at a time: concurrent hooks would race on the same index. scan_lock: tokio::sync::Mutex<()>, } @@ -1415,6 +1424,7 @@ impl Proxy { api_key: hooks.api_key, dependency_check: hooks.dependency_check, secret_scan: hooks.secret_scan, + agent_hook: hooks.agent_hook, scan_lock: tokio::sync::Mutex::new(()), }) }), @@ -1474,6 +1484,12 @@ impl Proxy { format!("http://{address}{DEPENDENCY_HOOK_PATH}"), ); } + if broker.dependency_check && broker.agent_hook.is_some() { + child_env.insert( + AGENT_HOOK_URL_ENV.to_owned(), + format!("http://{address}{AGENT_HOOK_PATH}"), + ); + } if broker.secret_scan.is_some() { child_env.insert( SCAN_BROKER_URL_ENV.to_owned(), @@ -1908,6 +1924,9 @@ fn proxy_request( if request.uri().path() == DEPENDENCY_HOOK_PATH { return Ok(handle_dependency_hook(request, &state).await); } + if request.uri().path() == AGENT_HOOK_PATH { + return Ok(handle_agent_hook(request, &state, started).await); + } if let Some(mode) = request .uri() .path() @@ -2515,70 +2534,41 @@ async fn read_capped(mut reader: impl tokio::io::AsyncRead + Unpin, limit: usize kept } -/// Runs the host's own `stashbase scan` for a sandboxed git hook. The hook -/// sends only the mode; what gets scanned, with which key and which config -/// options, is decided here. -async fn handle_secret_scan_hook( - request: Request, - mode: &str, - state: &ProxyState, - started: Instant, -) -> Response { - let request_id = new_local_request_id(); - let Some((broker, scan)) = state - .hook_broker - .as_ref() - .filter(|broker| broker.authorized(&request)) - .and_then(|broker| broker.secret_scan.as_ref().map(|scan| (broker, scan))) - else { - return proxy_error_response( - StatusCode::FORBIDDEN, - "proxy.secret_scan_not_allowed", - "Secret scan hook is not enabled for this run; add \"secret_scan\" to allow_hooks", - ); - }; - let mode = match mode { - "/staged" => "staged", - "/unpushed" => "unpushed", - _ => { - return proxy_error_response( - StatusCode::NOT_FOUND, - "proxy.secret_scan_unknown_mode", - "Unknown scan mode; expected staged or unpushed", - ) - } - }; +enum HostCommandError { + CouldNotStart, + TimedOut, +} +/// Runs the host's own CLI with `args` for a hook in the sandbox: confined, +/// with the parent's API key and the run's API URL, a scratch home, and +/// nothing from the broker's own environment. Commands are serialized, and +/// both output streams are capped. +async fn run_host_command( + broker: &HookBroker, + config: &SecretScanConfig, + args: &[&str], + stdin: Option, + restricted_scan: bool, +) -> Result<(std::process::ExitStatus, Vec, Vec), HostCommandError> { let _guard = broker.scan_lock.lock().await; - let scan_failed = || { - proxy_error_response( - StatusCode::BAD_GATEWAY, - "proxy.secret_scan_failed", - "Secret scan could not be started", - ) - }; - let Ok(home) = ScratchHome::create() else { - return scan_failed(); - }; - let scan_args = ["scan", mode, "--json", "--silent"]; - let (program, args) = match &scan.isolation { - ScanIsolation::Confined(confinement) => match confinement.wrap(&scan_args, &home.0) { - Ok(command) => command, - Err(_) => return scan_failed(), - }, + let home = ScratchHome::create().map_err(|_| HostCommandError::CouldNotStart)?; + let (program, command_args) = match &config.isolation { + ScanIsolation::Confined(confinement) => confinement + .wrap(args, &home.0) + .map_err(|_| HostCommandError::CouldNotStart)?, #[cfg(test)] ScanIsolation::Unconfined { exe } => ( exe.to_string_lossy().into_owned(), - scan_args.iter().map(|arg| (*arg).to_owned()).collect(), + args.iter().map(|arg| (*arg).to_owned()).collect(), ), }; let mut command = tokio::process::Command::new(program); command - .args(args) - .current_dir(&scan.workdir) + .args(command_args) + .current_dir(&config.workdir) .env("HOME", &home.0) // The API this run uses, whether it came from the environment or was - // built in, so the scan never falls back to a different default. + // built in, so the command never falls back to a different default. .env( crate::api::client::API_URL_ENV_VAR, crate::api::client::get_api_url(), @@ -2589,20 +2579,34 @@ async fn handle_secret_scan_hook( .env_remove("XDG_DATA_HOME") .env_remove("XDG_STATE_HOME") .env("STASHBASE_API_KEY", &broker.api_key) - .env(crate::models::scans::SCAN_RESTRICTED_ENV, "1") - // Keeps the scan's telemetry off, like everything else in the session. + // Keeps the command's telemetry off, like everything else in the session. .env("STASHBASE_SANDBOX", "1") + // Calls the API directly instead of looping back into this broker. .env_remove(crate::api::dependencies::HOOK_MODE_ENV) .env_remove(crate::api::dependencies::HOOK_BROKER_URL_ENV) .env_remove(crate::api::dependencies::HOOK_BROKER_TOKEN_ENV) .env_remove(SCAN_BROKER_URL_ENV) - .stdin(std::process::Stdio::null()) + .env_remove(AGENT_HOOK_URL_ENV) + .stdin(if stdin.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); + if restricted_scan { + command.env(crate::models::scans::SCAN_RESTRICTED_ENV, "1"); + } let run = async move { + use tokio::io::AsyncWriteExt; + let mut child = command.spawn()?; + if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) { + // A command that exits without reading its input is not an error. + let _ = pipe.write_all(&input).await; + } let stdout = child.stdout.take().expect("stdout is piped"); let stderr = child.stderr.take().expect("stderr is piped"); let (stdout, stderr, status) = tokio::join!( @@ -2613,37 +2617,99 @@ async fn handle_secret_scan_hook( status.map(|status| (status, stdout, stderr)) }; // On timeout `run` is dropped with the child, which `kill_on_drop` ends. - let response = match tokio::time::timeout(scan.timeout, run).await { - Err(_) => proxy_error_response( + match tokio::time::timeout(config.timeout, run).await { + Err(_) => Err(HostCommandError::TimedOut), + Ok(Err(_)) => Err(HostCommandError::CouldNotStart), + Ok(Ok(output)) => Ok(output), + } +} + +fn text_response(status: StatusCode, body: Vec) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .body(full_body(Bytes::from(body))) + .unwrap() +} + +/// A command killed before printing anything (e.g. by the confinement) +/// would otherwise leave an empty body. +fn failure_body( + name: &str, + exit: std::process::ExitStatus, + stdout: Vec, + stderr: Vec, +) -> Vec { + let mut body = stdout; + body.extend_from_slice(&stderr); + body.extend_from_slice(format!("\n{name} exited with {exit}\n").as_bytes()); + body.truncate(SECRET_SCAN_BODY_LIMIT); + body +} + +/// Runs the host's own `stashbase scan` for a sandboxed git hook. The hook +/// sends only the mode; what gets scanned, with which key and which config +/// options, is decided here. +async fn handle_secret_scan_hook( + request: Request, + mode: &str, + state: &ProxyState, + started: Instant, +) -> Response { + let request_id = new_local_request_id(); + let Some((broker, scan)) = state + .hook_broker + .as_ref() + .filter(|broker| broker.authorized(&request)) + .and_then(|broker| broker.secret_scan.as_ref().map(|scan| (broker, scan))) + else { + return proxy_error_response( + StatusCode::FORBIDDEN, + "proxy.secret_scan_not_allowed", + "Secret scan hook is not enabled for this run; add \"secret_scan\" to allow_hooks", + ); + }; + let mode = match mode { + "/staged" => "staged", + "/unpushed" => "unpushed", + _ => { + return proxy_error_response( + StatusCode::NOT_FOUND, + "proxy.secret_scan_unknown_mode", + "Unknown scan mode; expected staged or unpushed", + ) + } + }; + + let args = ["scan", mode, "--json", "--silent"]; + let response = match run_host_command(broker, scan, &args, None, true).await { + Err(HostCommandError::TimedOut) => proxy_error_response( StatusCode::GATEWAY_TIMEOUT, "proxy.secret_scan_timeout", "Secret scan did not finish in time", ), - Ok(Err(_)) => proxy_error_response( + Err(HostCommandError::CouldNotStart) => proxy_error_response( StatusCode::BAD_GATEWAY, "proxy.secret_scan_failed", "Secret scan could not be started", ), - Ok(Ok((exit, stdout, stderr))) => { - let status = match exit.code() { - Some(0) => StatusCode::OK, - Some(1) => StatusCode::UNPROCESSABLE_ENTITY, - _ => StatusCode::BAD_GATEWAY, - }; - let mut body = stdout; - body.extend_from_slice(&stderr); - if status == StatusCode::BAD_GATEWAY { - // A scan killed before printing anything (e.g. by the - // confinement) would otherwise leave an empty body. - body.extend_from_slice(format!("\nstashbase scan exited with {exit}\n").as_bytes()); + Ok((exit, stdout, stderr)) => match exit.code() { + Some(code @ (0 | 1)) => { + let mut body = stdout; + body.extend_from_slice(&stderr); + body.truncate(SECRET_SCAN_BODY_LIMIT); + let status = if code == 0 { + StatusCode::OK + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + text_response(status, body) } - body.truncate(SECRET_SCAN_BODY_LIMIT); - Response::builder() - .status(status) - .header("content-type", "text/plain; charset=utf-8") - .body(full_body(Bytes::from(body))) - .unwrap() - } + _ => text_response( + StatusCode::BAD_GATEWAY, + failure_body("stashbase scan", exit, stdout, stderr), + ), + }, }; state.record_audit_with_request( &request_id, @@ -2657,6 +2723,72 @@ async fn handle_secret_scan_hook( response } +/// Runs the host's own `stashbase agent hooks` for an agent hook in the +/// Docker sandbox, which has no Stashbase CLI. The body is the agent's hook +/// payload, passed through as the command's stdin; its stdout is the +/// agent's hook response. +async fn handle_agent_hook( + request: Request, + state: &ProxyState, + started: Instant, +) -> Response { + let request_id = new_local_request_id(); + let Some((broker, hook)) = state + .hook_broker + .as_ref() + .filter(|broker| broker.dependency_check && broker.authorized(&request)) + .and_then(|broker| broker.agent_hook.as_ref().map(|hook| (broker, hook))) + else { + return proxy_error_response( + StatusCode::FORBIDDEN, + "proxy.agent_hook_not_allowed", + "Agent hook is not enabled for this run; add \"dependency_check\" to allow_hooks", + ); + }; + let payload = match http_body_util::Limited::new(request.into_body(), AGENT_HOOK_BODY_LIMIT) + .collect() + .await + { + Ok(body) => body.to_bytes(), + Err(_) => { + return proxy_error_response( + StatusCode::PAYLOAD_TOO_LARGE, + "proxy.agent_hook_payload_too_large", + "Agent hook payload is too large", + ) + } + }; + + let args = ["agent", "hooks"]; + let response = match run_host_command(broker, hook, &args, Some(payload), false).await { + Err(HostCommandError::TimedOut) => proxy_error_response( + StatusCode::GATEWAY_TIMEOUT, + "proxy.agent_hook_timeout", + "Dependency check did not finish in time", + ), + Err(HostCommandError::CouldNotStart) => proxy_error_response( + StatusCode::BAD_GATEWAY, + "proxy.agent_hook_failed", + "Dependency check could not be started", + ), + Ok((exit, stdout, _)) if exit.success() => text_response(StatusCode::OK, stdout), + Ok((exit, stdout, stderr)) => text_response( + StatusCode::BAD_GATEWAY, + failure_body("stashbase agent hooks", exit, stdout, stderr), + ), + }; + state.record_audit_with_request( + &request_id, + "dependency_agent_hook", + None, + Some(&Method::POST), + None, + Some(response.status()), + Some(started.elapsed()), + ); + response +} + /// Standard remote-proxy requests are built per request so a new connection /// always observes the latest rotated token. Existing response streams retain /// the client and session that opened them. @@ -5510,6 +5642,7 @@ mod tests { api_key: "parent-api-key".to_owned(), dependency_check: true, secret_scan: None, + agent_hook: None, }), ) .await @@ -5580,6 +5713,7 @@ mod tests { timeout, isolation: ScanIsolation::Unconfined { exe }, }), + agent_hook: None, }), ) .await @@ -5690,6 +5824,7 @@ mod tests { api_key: "parent-api-key".to_owned(), dependency_check: true, secret_scan: None, + agent_hook: None, }), ) .await @@ -5795,6 +5930,126 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[cfg(unix)] + fn fake_hook_exe(dir: &std::path::Path, exit_code: i32) -> PathBuf { + let path = dir.join("fake-stashbase-hooks"); + std::fs::write( + &path, + format!( + "#!/bin/sh\necho \"args=$*\"\necho \"stdin=$(cat)\"\n\ + echo \"key=$STASHBASE_API_KEY\"\n\ + echo \"hook_mode=${{STASHBASE_HOOK_MODE:-unset}}\"\nexit {exit_code}\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[cfg(unix)] + async fn start_agent_hook_proxy(exe: PathBuf, workdir: PathBuf, dependency_check: bool) -> Proxy { + Proxy::start_with_hook( + HashMap::new(), + ProxyPolicy::permissive(), + None, + None, + Some(HookBrokerConfig { + api_key: "parent-api-key".to_owned(), + dependency_check, + secret_scan: None, + agent_hook: Some(SecretScanConfig { + workdir, + timeout: Duration::from_secs(10), + isolation: ScanIsolation::Unconfined { exe }, + }), + }), + ) + .await + .unwrap() + } + + #[cfg(unix)] + async fn post_agent_hook(proxy: &Proxy, body: Vec) -> (u16, String) { + let url = proxy.child_env()[AGENT_HOOK_URL_ENV].clone(); + let response = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .post(url) + .bearer_auth(hook_token(proxy)) + .body(body) + .send() + .await + .unwrap(); + (response.status().as_u16(), response.text().await.unwrap()) + } + + #[cfg(unix)] + #[tokio::test] + async fn agent_hook_runs_the_host_cli_with_the_payload_as_stdin() { + let dir = scan_test_dir(); + let proxy = start_agent_hook_proxy(fake_hook_exe(&dir, 0), dir.clone(), true).await; + + let (status, body) = + post_agent_hook(&proxy, br#"{"tool_input":{"command":"npm i left-pad"}}"#.to_vec()) + .await; + + assert_eq!(status, 200, "{body}"); + assert!(body.contains("args=agent hooks"), "{body}"); + assert!(body.contains(r#"stdin={"tool_input":{"command":"npm i left-pad"}}"#), "{body}"); + assert!(body.contains("key=parent-api-key"), "{body}"); + // Calls the API directly rather than looping back into the broker. + assert!(body.contains("hook_mode=unset"), "{body}"); + assert!(!proxy.child_env().contains_key("STASHBASE_API_KEY")); + proxy.stop().await; + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn agent_hook_reports_how_a_failed_hook_exited() { + let dir = scan_test_dir(); + let proxy = start_agent_hook_proxy(fake_hook_exe(&dir, 3), dir.clone(), true).await; + + let (status, body) = post_agent_hook(&proxy, b"{}".to_vec()).await; + + assert_eq!(status, 502, "{body}"); + assert!(body.contains("stashbase agent hooks exited with"), "{body}"); + proxy.stop().await; + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn agent_hook_rejects_an_oversized_payload() { + let dir = scan_test_dir(); + let proxy = start_agent_hook_proxy(fake_hook_exe(&dir, 0), dir.clone(), true).await; + + let (status, _) = post_agent_hook(&proxy, vec![b'a'; AGENT_HOOK_BODY_LIMIT + 1]).await; + + assert_eq!(status, 413); + proxy.stop().await; + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn agent_hook_is_not_served_without_dependency_check() { + let dir = scan_test_dir(); + let proxy = start_agent_hook_proxy(fake_hook_exe(&dir, 0), dir.clone(), false).await; + assert!(!proxy.child_env().contains_key(AGENT_HOOK_URL_ENV)); + let url = format!( + "{}{AGENT_HOOK_PATH}", + proxy.child_env()["HTTP_PROXY"].trim_end_matches('/') + ); + + let (status, _) = post_to(url, Some(&hook_token(&proxy))).await; + + assert_eq!(status, 403); + proxy.stop().await; + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn child_environment_uses_the_binding_name_for_its_default_placeholder() { let proxy = Proxy::start( From 9eb67e859ba96595675f91f156aa4b9183c67709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:16:04 +0000 Subject: [PATCH 2/6] feat(agent): install dependency hooks into the Docker sandbox home with --docker --- docs/agent-profiles.md | 2 +- docs/sandboxing.md | 2 +- src/cmd/deps.rs | 5 + src/handlers/entry/deps.rs | 311 +++++++++++++++++------------ src/handlers/run/docker_sandbox.rs | 69 +++++++ 5 files changed, 264 insertions(+), 125 deletions(-) diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 836aa309..13bda690 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -175,7 +175,7 @@ allow_hooks = ["dependency_check", "secret_scan"] The child receives only a scoped local broker token, never your API key. Hooks are disabled by default. -`dependency_check` works with the hooks from `stashbase agent hooks deps install`. With the native backend they run your installed CLI, which asks the Agent Proxy to call the API. The Docker image has no Stashbase CLI, so in a Docker run a small stand-in is mounted as `stashbase` inside the container: it forwards the hook's input to the Agent Proxy, which runs `stashbase agent hooks` on the host, confined the same way as the secret scan below, and returns its answer. If the proxy can't be reached, the stand-in blocks the command instead of letting the install through unchecked. In a Docker run the agent reads only its project-level hook config (for example `.claude/settings.json` in the repo), not the global one in your home directory, so install the hook without `--global` for Docker runs. +`dependency_check` works with the hooks from `stashbase agent hooks deps install`. With the native backend they run your installed CLI, which asks the Agent Proxy to call the API. The Docker image has no Stashbase CLI, so in a Docker run a small stand-in is mounted as `stashbase` inside the container: it forwards the hook's input to the Agent Proxy, which runs `stashbase agent hooks` on the host, confined the same way as the secret scan below, and returns its answer. If the proxy can't be reached, the stand-in blocks the command instead of letting the install through unchecked. In a Docker run the agent can't see the global hook config in your home directory. Either install the hook per repository (without `--global`; the container sees the repo), or install it into the Docker sandbox's own home, which every Docker run shares: `stashbase agent hooks deps install claude --docker` (likewise `check` and `uninstall`). `secret_scan` lets the git hooks from `stashbase scan install` run inside the sandbox. The hook sends only a token to the Agent Proxy, which runs `stashbase scan staged` (or `unpushed`) on the host, in the run's working directory, with your key. Because the agent can edit the repo's scan config, these scans ignore its `match` and `output-dir` settings; `excluded-files` and `ignored-secrets` still apply. The host-side scan is confined, so a symlink or a `.git` redirect planted by the agent can't point it at your other files. On Linux it sees only system files plus the run's working tree, git directories and the CLI binary. On macOS it can't read file contents anywhere users' data lives (home directories, `/Volumes`, `/tmp`, `/var/folders`, `/opt`) except those same run paths; system files stay readable, and file names and sizes stay visible because path lookup needs them. The profile's `deny_read` entries stay denied on both. This uses Seatbelt on macOS and bubblewrap on Linux; where neither is available (including Windows), a profile with `secret_scan` refuses to start. Restricted scans also stop at 1 MiB per changed file, 32 MiB in total, 10,000 files or 1,000 commits; larger changes have to be committed from outside the sandbox. The hook needs `curl` in the sandbox (the default Docker image has it). Hooks installed before this existed need `stashbase scan install` once more to pick up the sandbox-aware block. Without `secret_scan`, a scan hook inside the sandbox fails with a message pointing here. diff --git a/docs/sandboxing.md b/docs/sandboxing.md index f3b9e398..ae513075 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -113,7 +113,7 @@ Your global `git config user.name` and `user.email` (if configured on the host) ### Dependency hooks -The dependency check hooks (`allow_hooks = ["dependency_check"]`) work in the Docker sandbox without the Stashbase CLI in the image: a stand-in `stashbase` is mounted at `/usr/local/bin/stashbase`, and `stashbase agent hooks` runs on the host instead, confined like the secret scan below. Only project-level hook configs are visible to the agent in the container. See [Agent Profiles](agent-profiles.md#api-hooks). +The dependency check hooks (`allow_hooks = ["dependency_check"]`) work in the Docker sandbox without the Stashbase CLI in the image: a stand-in `stashbase` is mounted at `/usr/local/bin/stashbase`, and `stashbase agent hooks` runs on the host instead, confined like the secret scan below. The agent in the container sees project-level hook configs and its own home's; `stashbase agent hooks deps install --docker` installs into the latter for every Docker run. See [Agent Profiles](agent-profiles.md#api-hooks). ### Secret scan hooks diff --git a/src/cmd/deps.rs b/src/cmd/deps.rs index 83ada67f..324f0015 100644 --- a/src/cmd/deps.rs +++ b/src/cmd/deps.rs @@ -41,6 +41,11 @@ pub struct AgentHookTarget { /// Apply to the global configuration instead of only this repository #[arg(long)] pub global: bool, + + /// Apply to the Docker sandbox's home, which every Docker-backend agent + /// run shares, instead of this machine's configuration + #[arg(long, conflicts_with = "global")] + pub docker: bool, } #[derive(Clone, Copy, Debug, clap::ValueEnum)] diff --git a/src/handlers/entry/deps.rs b/src/handlers/entry/deps.rs index 978759e6..547d08de 100644 --- a/src/handlers/entry/deps.rs +++ b/src/handlers/entry/deps.rs @@ -3,33 +3,121 @@ use std::{collections::BTreeSet, fs, io::Read, path::Path}; use crate::{ api::dependencies::check_batch, - cmd::deps::{AgentDepsSubcommand, AgentHooksCommand, AgentHooksSubcommand, HookAgent}, + cmd::deps::{ + AgentDepsSubcommand, AgentHookTarget, AgentHooksCommand, AgentHooksSubcommand, HookAgent, + }, models::dependencies::{DependencyCheckRequest, DependencyDecision}, }; pub async fn handle_agent_hooks_commands(cmd: AgentHooksCommand, api_key: String) -> Result<()> { match cmd.subcommand { Some(AgentHooksSubcommand::Deps(args)) => match args.subcommand { - AgentDepsSubcommand::Install(args) => install_hook(args.agent, args.global), - AgentDepsSubcommand::Check(args) => check_hook(args.agent, args.global), - AgentDepsSubcommand::Uninstall(args) => uninstall_hook(args.agent, args.global), + AgentDepsSubcommand::Install(args) => in_scope(&args, HookAction::Install), + AgentDepsSubcommand::Check(args) => in_scope(&args, HookAction::Check), + AgentDepsSubcommand::Uninstall(args) => in_scope(&args, HookAction::Uninstall), }, None => return handle_hook(api_key).await, } } -fn check_hook(agent: HookAgent, global: bool) -> Result<()> { - let root = if global { - directories::BaseDirs::new() +/// Which agent configuration a hook command reads or changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HookScope { + /// This repository's config, which a Docker run also sees. + Project, + /// This machine's home directory config. + Global, + /// The Docker sandbox's home volume, shared by every Docker-backend run. + /// The config files are staged in a host directory while they change. + Docker, +} + +impl HookScope { + fn label(self) -> &'static str { + match self { + HookScope::Project => "project", + HookScope::Global => "global", + HookScope::Docker => "Docker sandbox", + } + } +} + +#[derive(Clone, Copy)] +enum HookAction { + Install, + Check, + Uninstall, +} + +/// Agent config files kept in the Docker sandbox's home, relative to it. +const DOCKER_HOOK_FILES: &[&str] = &[ + ".claude/settings.json", + ".codex/hooks.json", + ".codex/config.toml", + ".cursor/hooks.json", +]; + +fn in_scope(target: &AgentHookTarget, action: HookAction) -> Result<()> { + if target.docker { + let write_back = !matches!(action, HookAction::Check); + return crate::handlers::run::docker_sandbox::with_persistent_home_files( + DOCKER_HOOK_FILES, + write_back, + |root| apply(target.agent, action, root, HookScope::Docker), + ); + } + let (scope, root) = if target.global { + let home = directories::BaseDirs::new() .map(|dirs| dirs.home_dir().to_path_buf()) - .context("Could not determine the home directory for global hooks.")? + .context("Could not determine the home directory for global hooks.")?; + (HookScope::Global, home) } else { - git2::Repository::discover(".") - .context("Hook check must run inside a git repository.")? + let root = git2::Repository::discover(".") + .context("Hook commands without --global or --docker must run inside a git repository.")? .workdir() .map(Path::to_path_buf) - .context("Git repository has no working directory.")? + .context("Git repository has no working directory.")?; + (HookScope::Project, root) }; + apply(target.agent, action, &root, scope) +} + +fn apply(agent: HookAgent, action: HookAction, root: &Path, scope: HookScope) -> Result<()> { + match (action, agent) { + (HookAction::Install, HookAgent::Claude) => install_claude_hook(root, scope), + (HookAction::Install, HookAgent::Codex) => install_codex_hook(root, scope), + (HookAction::Install, HookAgent::Cursor) => install_cursor_hook(root, scope), + (HookAction::Uninstall, HookAgent::Claude) => uninstall_claude_hook(root, scope), + (HookAction::Uninstall, HookAgent::Codex) => uninstall_codex_hook(root, scope), + (HookAction::Uninstall, HookAgent::Cursor) => uninstall_cursor_hook(root, scope), + (HookAction::Check, agent) => check_hook(agent, root, scope), + } +} + +/// Codex keeps its global config in `CODEX_HOME` when that is set. Inside +/// the Docker sandbox it is not, so the volume's `.codex` is used. +fn codex_directory(root: &Path, scope: HookScope) -> std::path::PathBuf { + if scope == HookScope::Global { + if let Some(home) = std::env::var_os("CODEX_HOME") { + return std::path::PathBuf::from(home); + } + } + root.join(".codex") +} + +/// `path` as the agent sees it: for the Docker scope, inside the sandbox's +/// home rather than the host staging directory. +fn shown(path: &Path, root: &Path, scope: HookScope) -> String { + match (scope, path.strip_prefix(root)) { + (HookScope::Docker, Ok(relative)) => Path::new("/home/agent") + .join(relative) + .display() + .to_string(), + _ => path.display().to_string(), + } +} + +fn check_hook(agent: HookAgent, root: &Path, scope: HookScope) -> Result<()> { let (path, installed) = match agent { HookAgent::Claude => { let path = root.join(".claude/settings.json"); @@ -37,14 +125,7 @@ fn check_hook(agent: HookAgent, global: bool) -> Result<()> { (path, installed) } HookAgent::Codex => { - let directory = if global { - std::env::var_os("CODEX_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| root.join(".codex")) - } else { - root.join(".codex") - }; - let path = directory.join("hooks.json"); + let path = codex_directory(root, scope).join("hooks.json"); let installed = has_dependency_hook(&path)?; (path, installed) } @@ -55,10 +136,10 @@ fn check_hook(agent: HookAgent, global: bool) -> Result<()> { } }; if installed { - println!("Dependency hook installed in {}", path.display()); + println!("Dependency hook installed in {}", shown(&path, root, scope)); Ok(()) } else { - bail!("No dependency hook found in {}", path.display()); + bail!("No dependency hook found in {}", shown(&path, root, scope)); } } @@ -341,107 +422,65 @@ fn parse_package_spec(spec: &str) -> Result { } } -fn install_hook(agent: HookAgent, global: bool) -> Result<()> { - let root = if global { - directories::BaseDirs::new() - .map(|dirs| dirs.home_dir().to_path_buf()) - .context("Could not determine the home directory for global hooks.")? - } else { - git2::Repository::discover(".") - .context("Hook installation must run inside a git repository.")? - .workdir() - .map(Path::to_path_buf) - .context("Git repository has no working directory.")? - }; - match agent { - HookAgent::Claude => install_claude_hook(&root, global), - HookAgent::Codex => install_codex_hook(&root, global), - HookAgent::Cursor => install_cursor_hook(&root, global), - } -} - -fn uninstall_hook(agent: HookAgent, global: bool) -> Result<()> { - let root = if global { - directories::BaseDirs::new() - .map(|dirs| dirs.home_dir().to_path_buf()) - .context("Could not determine the home directory for global hooks.")? - } else { - git2::Repository::discover(".") - .context("Hook removal must run inside a git repository.")? - .workdir() - .map(Path::to_path_buf) - .context("Git repository has no working directory.")? - }; - match agent { - HookAgent::Claude => uninstall_claude_hook(&root, global), - HookAgent::Codex => uninstall_codex_hook(&root, global), - HookAgent::Cursor => uninstall_cursor_hook(&root, global), - } -} - -fn uninstall_claude_hook(root: &Path, global: bool) -> Result<()> { +fn uninstall_claude_hook(root: &Path, scope: HookScope) -> Result<()> { let path = root.join(".claude/settings.json"); if !path.exists() { - println!("No Claude dependency hook found in {}", path.display()); + println!("No Claude dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } let mut settings = read_json(&path).context("Failed to read .claude/settings.json.")?; if !remove_tool_hook(&mut settings) { - println!("No Claude dependency hook found in {}", path.display()); + println!("No Claude dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } write_json(&path, &settings)?; println!( "Removed {} Claude dependency hook from {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } -fn uninstall_codex_hook(root: &Path, global: bool) -> Result<()> { - let directory = if global { - std::env::var_os("CODEX_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| root.join(".codex")) - } else { - root.join(".codex") - }; +fn uninstall_codex_hook(root: &Path, scope: HookScope) -> Result<()> { + let directory = codex_directory(root, scope); let path = directory.join("hooks.json"); if !path.exists() { - println!("No Codex dependency hook found in {}", path.display()); + println!("No Codex dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } let mut hooks = read_json(&path).context("Failed to read .codex/hooks.json.")?; if !remove_tool_hook(&mut hooks) { - println!("No Codex dependency hook found in {}", path.display()); + println!("No Codex dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } write_json(&path, &hooks)?; println!( "Removed {} Codex dependency hook from {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } -fn install_cursor_hook(root: &Path, global: bool) -> Result<()> { +fn install_cursor_hook(root: &Path, scope: HookScope) -> Result<()> { let directory = root.join(".cursor"); - let other_path = if global { - git2::Repository::discover(".") + let other_path = match scope { + HookScope::Global => git2::Repository::discover(".") .ok() .and_then(|repo| repo.workdir().map(Path::to_path_buf)) - .map(|root| root.join(".cursor/hooks.json")) - } else { - directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".cursor/hooks.json")) + .map(|root| root.join(".cursor/hooks.json")), + HookScope::Project => { + directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".cursor/hooks.json")) + } + HookScope::Docker => None, }; let path = directory.join("hooks.json"); if let Some(other_path) = other_path.as_deref().filter(|other| *other != path) { if has_cursor_dependency_hook(other_path)? { println!( "Dependency hook already exists in the other Cursor configuration scope; remove it before installing in {}.", - path.display() + shown(&path, root, scope) ); return Ok(()); } @@ -476,28 +515,28 @@ fn install_cursor_hook(root: &Path, global: bool) -> Result<()> { write_json(&path, &config)?; println!( "Installed {} Cursor dependency hook in {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } -fn uninstall_cursor_hook(root: &Path, global: bool) -> Result<()> { +fn uninstall_cursor_hook(root: &Path, scope: HookScope) -> Result<()> { let path = root.join(".cursor/hooks.json"); if !path.exists() { - println!("No Cursor dependency hook found in {}", path.display()); + println!("No Cursor dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } let mut config = read_json(&path).context("Failed to read .cursor/hooks.json.")?; if !remove_cursor_hook(&mut config) { - println!("No Cursor dependency hook found in {}", path.display()); + println!("No Cursor dependency hook found in {}", shown(&path, root, scope)); return Ok(()); } write_json(&path, &config)?; println!( "Removed {} Cursor dependency hook from {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } @@ -532,15 +571,17 @@ fn remove_cursor_hook(config: &mut serde_json::Value) -> bool { before != entries.len() } -fn install_claude_hook(root: &Path, global: bool) -> Result<()> { +fn install_claude_hook(root: &Path, scope: HookScope) -> Result<()> { let path = root.join(".claude/settings.json"); - let other_path = if global { - git2::Repository::discover(".") + let other_path = match scope { + HookScope::Global => git2::Repository::discover(".") .ok() .and_then(|repo| repo.workdir().map(Path::to_path_buf)) - .map(|root| root.join(".claude/settings.json")) - } else { - directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".claude/settings.json")) + .map(|root| root.join(".claude/settings.json")), + HookScope::Project => { + directories::BaseDirs::new().map(|dirs| dirs.home_dir().join(".claude/settings.json")) + } + HookScope::Docker => None, }; if let Some(other_path) = other_path .as_deref() @@ -549,7 +590,7 @@ fn install_claude_hook(root: &Path, global: bool) -> Result<()> { if has_dependency_hook(other_path)? { println!( "Dependency hook already exists in the other Claude configuration scope; remove it before installing in {}.", - path.display() + shown(&path, root, scope) ); return Ok(()); } @@ -630,31 +671,22 @@ fn install_claude_hook(root: &Path, global: bool) -> Result<()> { write_json(&path, &settings)?; println!( "Installed {} Claude dependency hook in {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } -fn install_codex_hook(root: &Path, global: bool) -> Result<()> { - let directory = if global { - std::env::var_os("CODEX_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| root.join(".codex")) - } else { - root.join(".codex") - }; - let other_directory = if global { - git2::Repository::discover(".") +fn install_codex_hook(root: &Path, scope: HookScope) -> Result<()> { + let directory = codex_directory(root, scope); + let other_directory = match scope { + HookScope::Global => git2::Repository::discover(".") .ok() .and_then(|repo| repo.workdir().map(Path::to_path_buf)) - .map(|root| root.join(".codex")) - } else { - directories::BaseDirs::new().map(|dirs| { - std::env::var_os("CODEX_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| dirs.home_dir().join(".codex")) - }) + .map(|root| root.join(".codex")), + HookScope::Project => directories::BaseDirs::new() + .map(|dirs| codex_directory(dirs.home_dir(), HookScope::Global)), + HookScope::Docker => None, }; if let Some(other_directory) = other_directory .as_deref() @@ -663,7 +695,7 @@ fn install_codex_hook(root: &Path, global: bool) -> Result<()> { if has_dependency_hook(&other_directory.join("hooks.json"))? { println!( "Dependency hook already exists in the other Codex configuration scope; remove it before installing in {}.", - directory.display() + shown(&directory, root, scope) ); return Ok(()); } @@ -690,8 +722,8 @@ fn install_codex_hook(root: &Path, global: bool) -> Result<()> { enable_codex_hooks(&directory.join("config.toml"))?; println!( "Installed {} Codex dependency hook in {}", - if global { "global" } else { "project" }, - path.display() + scope.label(), + shown(&path, root, scope) ); Ok(()) } @@ -823,14 +855,47 @@ fn write_json(path: &Path, value: &serde_json::Value) -> Result<()> { #[cfg(test)] mod tests { use super::{ - install_claude_hook, install_cursor_hook, parse_preinstall_dependencies, - remove_cursor_hook, remove_tool_hook, + apply, codex_directory, install_claude_hook, install_cursor_hook, + parse_preinstall_dependencies, remove_cursor_hook, remove_tool_hook, shown, HookAction, + HookScope, }; + use crate::cmd::deps::HookAgent; use std::{ fs, + path::Path, time::{SystemTime, UNIX_EPOCH}, }; + #[test] + fn docker_scope_installs_into_the_staged_sandbox_home() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("stashbase-docker-hook-test-{suffix}")); + + for agent in [HookAgent::Claude, HookAgent::Codex, HookAgent::Cursor] { + apply(agent, HookAction::Install, &root, HookScope::Docker).unwrap(); + apply(agent, HookAction::Check, &root, HookScope::Docker).unwrap(); + } + + assert!(fs::read_to_string(root.join(".claude/settings.json")) + .unwrap() + .contains("stashbase agent hooks")); + // Never CODEX_HOME: the host's value means nothing inside the sandbox. + assert_eq!(codex_directory(&root, HookScope::Docker), root.join(".codex")); + assert!(root.join(".codex/hooks.json").exists()); + assert!(root.join(".cursor/hooks.json").exists()); + assert_eq!( + shown(&root.join(".claude/settings.json"), &root, HookScope::Docker), + Path::new("/home/agent/.claude/settings.json").display().to_string() + ); + + apply(HookAgent::Claude, HookAction::Uninstall, &root, HookScope::Docker).unwrap(); + assert!(apply(HookAgent::Claude, HookAction::Check, &root, HookScope::Docker).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn parses_multiple_preinstall_packages_with_optional_versions() { for command in [ @@ -952,7 +1017,7 @@ mod tests { .as_nanos(); let root = std::env::temp_dir().join(format!("stashbase-claude-hook-test-{suffix}")); - install_claude_hook(&root, true).unwrap(); + install_claude_hook(&root, HookScope::Global).unwrap(); let settings: serde_json::Value = serde_json::from_str(&fs::read_to_string(root.join(".claude/settings.json")).unwrap()) .unwrap(); @@ -968,7 +1033,7 @@ mod tests { assert!(matchers.contains(&"Bash(yarn add *)")); assert!(matchers.contains(&"Bash(yarn install *)")); - install_cursor_hook(&root, true).unwrap(); + install_cursor_hook(&root, HookScope::Global).unwrap(); let hooks: serde_json::Value = serde_json::from_str(&fs::read_to_string(root.join(".cursor/hooks.json")).unwrap()) .unwrap(); diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 21d1339d..677de92e 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -1522,6 +1522,75 @@ fn append_ca_bundle_mount( Ok(()) } +/// Copies `files` (relative to the sandbox home) out of the persistent home +/// volume into a host staging directory, runs `edit` on it, and, when +/// `write_back`, copies them back. Runs the default image as the same user +/// agent runs use, with no network, so the files keep the owner the agent +/// expects. +pub(crate) fn with_persistent_home_files( + files: &[&str], + write_back: bool, + edit: impl FnOnce(&Path) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let image_exists = std::process::Command::new("docker") + .args(["image", "inspect", DEFAULT_SANDBOX_IMAGE]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|error| anyhow::anyhow!("Docker is not available: {error}"))? + .success(); + if !image_exists { + anyhow::bail!( + "the Docker sandbox image {DEFAULT_SANDBOX_IMAGE} is missing; build it with `stashbase agent docker build`" + ); + } + let staging = + std::env::temp_dir().join(format!("stashbase-docker-home-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&staging)?; + let staging_text = staging.to_string_lossy().into_owned(); + let copy = |mount: String, script: &str| -> anyhow::Result<()> { + let mut args = vec!["run".to_owned(), "--rm".to_owned(), "--network".to_owned(), "none".to_owned()]; + args.extend(docker_run_user_flag_args()); + args.extend([ + "-v".to_owned(), + format!("{PERSISTENT_HOME_VOLUME}:{CONTAINER_HOME}"), + "-v".to_owned(), + mount, + "--entrypoint".to_owned(), + "/bin/sh".to_owned(), + DEFAULT_SANDBOX_IMAGE.to_owned(), + "-c".to_owned(), + script.to_owned(), + "sh".to_owned(), + ]); + args.extend(files.iter().map(|file| (*file).to_owned())); + let output = std::process::Command::new("docker").args(&args).output()?; + if !output.status.success() { + anyhow::bail!( + "failed to access the Docker sandbox home: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + }; + let result = (|| { + copy( + format!("{staging_text}:/stashbase-staging"), + r#"for f in "$@"; do if [ -f "/home/agent/$f" ]; then mkdir -p "/stashbase-staging/$(dirname "$f")" && cp "/home/agent/$f" "/stashbase-staging/$f" || exit 1; fi; done"#, + )?; + edit(&staging)?; + if write_back { + copy( + format!("{staging_text}:/stashbase-staging:ro"), + r#"for f in "$@"; do if [ -f "/stashbase-staging/$f" ]; then mkdir -p "/home/agent/$(dirname "$f")" && cp "/stashbase-staging/$f" "/home/agent/$f" || exit 1; fi; done"#, + )?; + } + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&staging); + result +} + /// Stands in for the Stashbase CLI, which the sandbox image doesn't have, so /// agent hooks configured as `stashbase agent hooks` run on the host through /// the Agent Proxy. Exit 2 blocks the tool call in Claude Code and Codex, so From 8564c564fe0483ba15a8e38fa9df6c8e64e3e2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:29:54 +0000 Subject: [PATCH 3/6] docs: README coverage for sandboxed scan and dependency hooks --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 871c3284..598a5f06 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,8 @@ stashbase scan install --all stashbase scan uninstall pre-commit --file .husky/pre-commit ``` +In a `stashbase agent run` session, these hooks need `allow_hooks = ["secret_scan"]` in the agent profile: the scan then runs on the host with your key, confined to the run's files. See [Agent Profiles](docs/agent-profiles.md#api-hooks). + ### Dependency Security Hooks Block suspicious package installs before they run. Install hooks for Codex, Claude Code, or Cursor: @@ -546,8 +548,15 @@ stashbase agent hooks deps uninstall cursor stashbase agent hooks deps uninstall codex --global stashbase agent hooks deps uninstall claude --global stashbase agent hooks deps uninstall cursor --global + +# Install, check or remove in the Docker sandbox's home (shared by every Docker agent run) +stashbase agent hooks deps install claude --docker +stashbase agent hooks deps check claude --docker +stashbase agent hooks deps uninstall claude --docker ``` +In a `stashbase agent run` session, the hook needs `allow_hooks = ["dependency_check"]` in the agent profile. A Docker run can't see your global hooks: use per-repository hooks or `--docker`. See [Agent Profiles](docs/agent-profiles.md#api-hooks). + Use `dependencies` as an alias for `deps`. The hook supports npm, Bun, pnpm, and Yarn. For package-specific installs, it sends only package names and versions to Stashbase. For project-wide installs like `npm ci`, it scans direct dependencies from `package.json` and uses versions from lockfiles when available. This is not a full dependency-tree audit; transitive dependencies are not scanned. ### Diagnose CLI Setup From db21513cc71418153ee283416614991760aad723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:32:38 +0000 Subject: [PATCH 4/6] style: cargo fmt --- src/handlers/agent/validate.rs | 5 +-- src/handlers/entry/deps.rs | 65 ++++++++++++++++++++++++------ src/handlers/run/docker_sandbox.rs | 32 +++++++++++---- src/handlers/run/entry.rs | 20 +++++---- src/handlers/run/proxy.rs | 19 ++++++--- 5 files changed, 107 insertions(+), 34 deletions(-) diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index f2b2d02d..a239f9a1 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -710,9 +710,8 @@ fn validate_hook_capabilities(profile: &AgentProfile) -> Vec { .collect(); } let docker = profile.sandbox.backend == crate::models::agent::SandboxBackend::Docker; - let runs_confined = |hook: &String| { - hook == "secret_scan" || (docker && hook == "dependency_check") - }; + let runs_confined = + |hook: &String| hook == "secret_scan" || (docker && hook == "dependency_check"); if profile.allow_hooks.iter().any(runs_confined) { if let Some(reason) = crate::handlers::run::scan_sandbox::unavailable_reason() { return vec![fail("Hook capability", format!("{reason}."))]; diff --git a/src/handlers/entry/deps.rs b/src/handlers/entry/deps.rs index 547d08de..5d6abdb4 100644 --- a/src/handlers/entry/deps.rs +++ b/src/handlers/entry/deps.rs @@ -73,7 +73,9 @@ fn in_scope(target: &AgentHookTarget, action: HookAction) -> Result<()> { (HookScope::Global, home) } else { let root = git2::Repository::discover(".") - .context("Hook commands without --global or --docker must run inside a git repository.")? + .context( + "Hook commands without --global or --docker must run inside a git repository.", + )? .workdir() .map(Path::to_path_buf) .context("Git repository has no working directory.")?; @@ -425,12 +427,18 @@ fn parse_package_spec(spec: &str) -> Result { fn uninstall_claude_hook(root: &Path, scope: HookScope) -> Result<()> { let path = root.join(".claude/settings.json"); if !path.exists() { - println!("No Claude dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Claude dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } let mut settings = read_json(&path).context("Failed to read .claude/settings.json.")?; if !remove_tool_hook(&mut settings) { - println!("No Claude dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Claude dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } write_json(&path, &settings)?; @@ -446,12 +454,18 @@ fn uninstall_codex_hook(root: &Path, scope: HookScope) -> Result<()> { let directory = codex_directory(root, scope); let path = directory.join("hooks.json"); if !path.exists() { - println!("No Codex dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Codex dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } let mut hooks = read_json(&path).context("Failed to read .codex/hooks.json.")?; if !remove_tool_hook(&mut hooks) { - println!("No Codex dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Codex dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } write_json(&path, &hooks)?; @@ -524,12 +538,18 @@ fn install_cursor_hook(root: &Path, scope: HookScope) -> Result<()> { fn uninstall_cursor_hook(root: &Path, scope: HookScope) -> Result<()> { let path = root.join(".cursor/hooks.json"); if !path.exists() { - println!("No Cursor dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Cursor dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } let mut config = read_json(&path).context("Failed to read .cursor/hooks.json.")?; if !remove_cursor_hook(&mut config) { - println!("No Cursor dependency hook found in {}", shown(&path, root, scope)); + println!( + "No Cursor dependency hook found in {}", + shown(&path, root, scope) + ); return Ok(()); } write_json(&path, &config)?; @@ -883,16 +903,37 @@ mod tests { .unwrap() .contains("stashbase agent hooks")); // Never CODEX_HOME: the host's value means nothing inside the sandbox. - assert_eq!(codex_directory(&root, HookScope::Docker), root.join(".codex")); + assert_eq!( + codex_directory(&root, HookScope::Docker), + root.join(".codex") + ); assert!(root.join(".codex/hooks.json").exists()); assert!(root.join(".cursor/hooks.json").exists()); assert_eq!( - shown(&root.join(".claude/settings.json"), &root, HookScope::Docker), - Path::new("/home/agent/.claude/settings.json").display().to_string() + shown( + &root.join(".claude/settings.json"), + &root, + HookScope::Docker + ), + Path::new("/home/agent/.claude/settings.json") + .display() + .to_string() ); - apply(HookAgent::Claude, HookAction::Uninstall, &root, HookScope::Docker).unwrap(); - assert!(apply(HookAgent::Claude, HookAction::Check, &root, HookScope::Docker).is_err()); + apply( + HookAgent::Claude, + HookAction::Uninstall, + &root, + HookScope::Docker, + ) + .unwrap(); + assert!(apply( + HookAgent::Claude, + HookAction::Check, + &root, + HookScope::Docker + ) + .is_err()); fs::remove_dir_all(root).unwrap(); } diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 677de92e..84f70596 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -1549,7 +1549,12 @@ pub(crate) fn with_persistent_home_files( std::fs::create_dir_all(&staging)?; let staging_text = staging.to_string_lossy().into_owned(); let copy = |mount: String, script: &str| -> anyhow::Result<()> { - let mut args = vec!["run".to_owned(), "--rm".to_owned(), "--network".to_owned(), "none".to_owned()]; + let mut args = vec![ + "run".to_owned(), + "--rm".to_owned(), + "--network".to_owned(), + "none".to_owned(), + ]; args.extend(docker_run_user_flag_args()); args.extend([ "-v".to_owned(), @@ -1629,8 +1634,9 @@ fn append_agent_hook_shim_mount( #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) - .map_err(|error| format!("failed to make the agent hook stand-in executable: {error}"))?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).map_err( + |error| format!("failed to make the agent hook stand-in executable: {error}"), + )?; } args.extend([ "-v".to_owned(), @@ -1828,7 +1834,11 @@ mod tests { append_agent_hook_shim_mount(&mut with, &env_vars).unwrap(); assert_eq!(with[0], "-v"); - assert!(with[1].ends_with(":/usr/local/bin/stashbase:ro"), "{}", with[1]); + assert!( + with[1].ends_with(":/usr/local/bin/stashbase:ro"), + "{}", + with[1] + ); let source = with[1].trim_end_matches(":/usr/local/bin/stashbase:ro"); assert_eq!(std::fs::read_to_string(source).unwrap(), AGENT_HOOK_SHIM); } @@ -1837,11 +1847,16 @@ mod tests { fn run_shim(args: &[&str], env: &[(&str, &str)], with_curl: bool) -> (i32, String) { use std::os::unix::fs::PermissionsExt; - let bin = std::env::temp_dir().join(format!("stashbase-shim-test-{}", uuid::Uuid::new_v4())); + let bin = + std::env::temp_dir().join(format!("stashbase-shim-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&bin).unwrap(); if with_curl { let curl = bin.join("curl"); - std::fs::write(&curl, "#!/bin/sh\necho \"curl $*\"\nexit ${FAKE_CURL_EXIT:-0}\n").unwrap(); + std::fs::write( + &curl, + "#!/bin/sh\necho \"curl $*\"\nexit ${FAKE_CURL_EXIT:-0}\n", + ) + .unwrap(); std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o755)).unwrap(); } let output = std::process::Command::new("/bin/sh") @@ -1862,7 +1877,10 @@ mod tests { #[cfg(unix)] const SHIM_ENV: [(&str, &str); 2] = [ - ("STASHBASE_AGENT_HOOK_URL", "http://h:1/__stashbase/agent-hook"), + ( + "STASHBASE_AGENT_HOOK_URL", + "http://h:1/__stashbase/agent-hook", + ), ("STASHBASE_HOOK_BROKER_TOKEN", "t"), ]; diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 246dd686..2b359cab 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -2147,17 +2147,23 @@ mod tests { secret_scan: true, }; + assert!(hook_broker_config( + EnabledHooks::default(), + Some("k".to_owned()), + workdir, + &[], + false + ) + .unwrap() + .is_none()); + assert!(hook_broker_config(both, None, workdir, &[], false) + .unwrap() + .is_none()); assert!( - hook_broker_config(EnabledHooks::default(), Some("k".to_owned()), workdir, &[], false) + hook_broker_config(both, Some(String::new()), workdir, &[], false) .unwrap() .is_none() ); - assert!(hook_broker_config(both, None, workdir, &[], false) - .unwrap() - .is_none()); - assert!(hook_broker_config(both, Some(String::new()), workdir, &[], false) - .unwrap() - .is_none()); let deps_only = EnabledHooks { dependency_check: true, secret_scan: false, diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index 36aaf090..83816061 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -5947,7 +5947,11 @@ mod tests { } #[cfg(unix)] - async fn start_agent_hook_proxy(exe: PathBuf, workdir: PathBuf, dependency_check: bool) -> Proxy { + async fn start_agent_hook_proxy( + exe: PathBuf, + workdir: PathBuf, + dependency_check: bool, + ) -> Proxy { Proxy::start_with_hook( HashMap::new(), ProxyPolicy::permissive(), @@ -5990,13 +5994,18 @@ mod tests { let dir = scan_test_dir(); let proxy = start_agent_hook_proxy(fake_hook_exe(&dir, 0), dir.clone(), true).await; - let (status, body) = - post_agent_hook(&proxy, br#"{"tool_input":{"command":"npm i left-pad"}}"#.to_vec()) - .await; + let (status, body) = post_agent_hook( + &proxy, + br#"{"tool_input":{"command":"npm i left-pad"}}"#.to_vec(), + ) + .await; assert_eq!(status, 200, "{body}"); assert!(body.contains("args=agent hooks"), "{body}"); - assert!(body.contains(r#"stdin={"tool_input":{"command":"npm i left-pad"}}"#), "{body}"); + assert!( + body.contains(r#"stdin={"tool_input":{"command":"npm i left-pad"}}"#), + "{body}" + ); assert!(body.contains("key=parent-api-key"), "{body}"); // Calls the API directly rather than looping back into the broker. assert!(body.contains("hook_mode=unset"), "{body}"); From 1b8c24f5da965916b3fee55ac99b45525ce1b99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:38:02 +0000 Subject: [PATCH 5/6] fix(agent): show in-container hook paths with / on every host --- src/handlers/entry/deps.rs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/handlers/entry/deps.rs b/src/handlers/entry/deps.rs index 5d6abdb4..e1d4d587 100644 --- a/src/handlers/entry/deps.rs +++ b/src/handlers/entry/deps.rs @@ -111,10 +111,14 @@ fn codex_directory(root: &Path, scope: HookScope) -> std::path::PathBuf { /// home rather than the host staging directory. fn shown(path: &Path, root: &Path, scope: HookScope) -> String { match (scope, path.strip_prefix(root)) { - (HookScope::Docker, Ok(relative)) => Path::new("/home/agent") - .join(relative) - .display() - .to_string(), + // A Linux path whatever the host is, so `/` rather than `Path::join`. + (HookScope::Docker, Ok(relative)) => { + let parts = relative + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect::>(); + format!("/home/agent/{}", parts.join("/")) + } _ => path.display().to_string(), } } @@ -882,7 +886,6 @@ mod tests { use crate::cmd::deps::HookAgent; use std::{ fs, - path::Path, time::{SystemTime, UNIX_EPOCH}, }; @@ -910,14 +913,8 @@ mod tests { assert!(root.join(".codex/hooks.json").exists()); assert!(root.join(".cursor/hooks.json").exists()); assert_eq!( - shown( - &root.join(".claude/settings.json"), - &root, - HookScope::Docker - ), - Path::new("/home/agent/.claude/settings.json") - .display() - .to_string() + shown(&root.join(".claude/settings.json"), &root, HookScope::Docker), + "/home/agent/.claude/settings.json" ); apply( From b4c2428e6ffd44d21f2e9da06ac444aeacd051b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Fri, 9 Oct 2026 10:39:10 +0000 Subject: [PATCH 6/6] style: cargo fmt --- src/handlers/entry/deps.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/handlers/entry/deps.rs b/src/handlers/entry/deps.rs index e1d4d587..3928a24f 100644 --- a/src/handlers/entry/deps.rs +++ b/src/handlers/entry/deps.rs @@ -913,7 +913,11 @@ mod tests { assert!(root.join(".codex/hooks.json").exists()); assert!(root.join(".cursor/hooks.json").exists()); assert_eq!( - shown(&root.join(".claude/settings.json"), &root, HookScope::Docker), + shown( + &root.join(".claude/settings.json"), + &root, + HookScope::Docker + ), "/home/agent/.claude/settings.json" );