Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
32a4a2d
feat(scan): add restricted mode that ignores agent-editable config reach
radim10 Oct 9, 2026
6052061
feat(agent): broker secret scans on the host through the agent proxy
radim10 Oct 9, 2026
4e8d017
fix(docker): rewrite the scan broker URL for the container
radim10 Oct 9, 2026
2f4f0ba
feat(agent): enable the secret scan hook with allow_hooks = ["secret_…
radim10 Oct 9, 2026
8c5247c
feat(scan): route sandboxed git hooks through the agent proxy broker
radim10 Oct 9, 2026
a22f585
fix(run): pass EnabledHooks on the egress-only run path
radim10 Oct 9, 2026
972f3c0
fix(run): serve API hooks for egress-only agent profiles
radim10 Oct 9, 2026
d66fa03
fix(scan): cap brokered scan output while reading and keep the hook s…
radim10 Oct 9, 2026
815e26a
fix(scan): escape shell parameter braces in the hook format string
radim10 Oct 9, 2026
51a4557
style: cargo fmt
radim10 Oct 9, 2026
cadcc33
fix(scan): cap restricted scan input and ignore a symlinked scan config
radim10 Oct 9, 2026
4a6927e
fix(agent): confine the host-side secret scan to the agent's view of …
radim10 Oct 9, 2026
c36db51
fix: compile errors in the scan confinement and budget tests
radim10 Oct 9, 2026
a4805dc
fix(agent): deny the host-side scan file reads by default, allowing o…
radim10 Oct 9, 2026
d305406
fix(agent): express the scan's read denial as one deny with exclusions
radim10 Oct 9, 2026
1d4f9f9
fix(agent): give the brokered scan the run's resolved API URL
radim10 Oct 9, 2026
923601b
fix(agent): allow the dyld cache's real path and report how a failed …
radim10 Oct 9, 2026
0c4ad29
fix(agent): deny the scan only users' data locations on macOS so the …
radim10 Oct 9, 2026
902d1db
ci: install bubblewrap so the Linux scan confinement tests run
radim10 Oct 9, 2026
a7fc184
test: fail the scan confinement tests in CI instead of skipping
radim10 Oct 9, 2026
9396583
style: format scan sandbox changes
radim10 Oct 9, 2026
b9f92c3
ci: allow bubblewrap user namespaces
radim10 Oct 9, 2026
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,13 @@ jobs:
if: runner.os == 'Linux'
run: cargo fmt --check

# The secret-scan confinement tests skip without a working bubblewrap.
- name: Install bubblewrap
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap apparmor-profiles
sudo apparmor_parser -r /usr/share/apparmor/extra-profiles/bwrap-userns-restrict

- name: Run tests
run: cargo test --locked
10 changes: 6 additions & 4 deletions docs/agent-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,17 @@ allow_tools = ["search_issues", "get_issue"]

The HTTP rule controls credential injection at the endpoint. The MCP rule controls which tools the agent may call. Omit `allow_tools` or leave it empty to deny all tools by default. Use `allow_tools = ["*"]` to allow all tools (a matching `deny_tools` still takes precedence).

## Dependency Hooks
## API Hooks

If your profile enables authenticated hooks (e.g., dependency checking), grant capability explicitly:
Hooks that call the Stashbase API (dependency checking, secret scanning) must be granted explicitly:

```toml
allow_hooks = ["dependency_check"]
allow_hooks = ["dependency_check", "secret_scan"]
```

The child receives only a scoped local broker token. Hooks are disabled by default.
The child receives only a scoped local broker token, never your API key. Hooks are disabled by default.

`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

Expand Down
4 changes: 4 additions & 0 deletions docs/sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### 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).

### Notifications, herdr and cmux

Agent notifications work in the sandbox with no setup. Your terminal's identity (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`) is forwarded into the container, so Claude Code and Codex send the same notifications they would outside it (OSC 9/777/99 or the bell) when a turn finishes or they need input. Ghostty, iTerm2, kitty, [cmux](https://cmux.com), [herdr](https://herdr.dev), tmux and other terminals and multiplexers pick them up as usual. Only the terminal's name and version are forwarded; nothing else about your terminal or session reaches the container.
Expand Down
2 changes: 1 addition & 1 deletion src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::models::api_client::{
use crate::{REQUEST_ABORTED, REQUEST_TIMEOUT_SECS};

const DEFAULT_API_URL: &str = "https://api.stashbase.dev";
const API_URL_ENV_VAR: &str = "STASHBASE_API_URL";
pub(crate) const API_URL_ENV_VAR: &str = "STASHBASE_API_URL";
const BUILD_TIME_API_URL: Option<&str> = option_env!("STASHBASE_API_URL");
pub const CLI_USER_AGENT: &str = concat!("stashbase/cli/", env!("CARGO_PKG_VERSION"));

Expand Down
8 changes: 5 additions & 3 deletions src/handlers/agent/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const PROFILE_TEMPLATE: &str = r#"# Stashbase Agent Proxy profile.
# Add only destinations the agent genuinely needs to contact.
egress_hosts = []

# Enable installed dependency hooks for this profile.
# allow_hooks = ["dependency_check"]
# Enable installed dependency and secret-scan hooks for this profile.
# allow_hooks = ["dependency_check", "secret_scan"]

# Optional: allow local test servers and Unix-socket IPC (macOS only).
# Linux keeps loopback available for the embedded proxy; this setting has no effect there.
Expand Down Expand Up @@ -126,7 +126,9 @@ mod tests {
fn template_starts_closed_and_includes_a_generic_rule() {
assert!(PROFILE_TEMPLATE.contains("egress_hosts = []"));
assert!(PROFILE_TEMPLATE.contains("allow_network_listeners = true"));
assert!(PROFILE_TEMPLATE.contains("# allow_hooks = [\"dependency_check\"]"));
assert!(
PROFILE_TEMPLATE.contains("# allow_hooks = [\"dependency_check\", \"secret_scan\"]")
);
assert!(PROFILE_TEMPLATE.contains("[secrets.SECRET_NAME]"));
assert!(PROFILE_TEMPLATE.contains("[[secrets.SECRET_NAME.rules]]"));
}
Expand Down
33 changes: 32 additions & 1 deletion src/handlers/agent/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,7 @@ fn validate_hook_capabilities(profile: &AgentProfile) -> Vec<Check> {
let unsupported = profile
.allow_hooks
.iter()
.filter(|hook| hook.as_str() != "dependency_check")
.filter(|hook| !matches!(hook.as_str(), "dependency_check" | "secret_scan"))
.collect::<Vec<_>>();
if !unsupported.is_empty() {
return unsupported
Expand All @@ -709,6 +709,11 @@ fn validate_hook_capabilities(profile: &AgentProfile) -> Vec<Check> {
})
.collect();
}
if profile.allow_hooks.iter().any(|hook| hook == "secret_scan") {
if let Some(reason) = crate::handlers::run::scan_sandbox::unavailable_reason() {
return vec![fail("Hook capability", format!("{reason}."))];
}
}
vec![ok(
"Hook capabilities",
if profile.allow_hooks.is_empty() {
Expand Down Expand Up @@ -1132,6 +1137,32 @@ mod tests {
.contains("Unsupported hook capability 'anything_else'"));
}

#[test]
fn secret_scan_is_a_known_hook() {
let profile = AgentProfile {
file: None,
egress_hosts: None,
allow_network_listeners: false,
deny_hosts: None,
filesystem: Default::default(),
sandbox: Default::default(),
workspace: Default::default(),
mcp_servers: HashMap::new(),
secrets: HashMap::new().into(),
personal_credentials: HashMap::new(),
policy_tests: Vec::new(),
allow_hooks: vec!["dependency_check".to_owned(), "secret_scan".to_owned()],
};

let checks = validate_hook_capabilities(&profile);

let confinable = crate::handlers::run::scan_sandbox::unavailable_reason().is_none();
assert_eq!(
checks.iter().all(|check| check.status != Status::Fail),
confinable
);
}

#[test]
fn docker_backend_profile_gets_a_docker_runtime_check_not_native_ones() {
// A Docker-backend profile never touches Seatbelt/systemd-run/
Expand Down
81 changes: 49 additions & 32 deletions src/handlers/entry/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,11 +878,8 @@ pub async fn handle_cli(args: Cli) -> Exit {
}
}

let dependency_hooks_requested = profile
.allow_hooks
.iter()
.any(|hook| hook == "dependency_check");
let dependency_hooks = dependency_hooks_enabled(&profile, &api_key);
let requested_hooks = profile.requested_hooks();
let hooks = enabled_hooks(&profile, &api_key);
if !silent {
eprintln!("Network sandbox: enabled");
if profile.sandbox.backend
Expand All @@ -891,17 +888,10 @@ pub async fn handle_cli(args: Cli) -> Exit {
eprintln!("Sandbox backend: Docker (container-isolated)");
}
print_agent_egress_warnings(&profile);
eprintln!(
"API hook broker: {}",
if dependency_hooks {
"enabled (dependency_check)"
} else {
"disabled"
}
);
if dependency_hooks_requested && !dependency_hooks {
eprintln!("API hook broker: {}", hooks.label());
if requested_hooks.any() && !hooks.any() {
eprintln!(
"Warning: dependency_check is configured but no API key is available; dependency checks are disabled."
"Warning: allow_hooks is configured but no API key is available; API hooks are disabled."
);
}
}
Expand Down Expand Up @@ -1314,7 +1304,7 @@ pub async fn handle_cli(args: Cli) -> Exit {
);
let result = handle_remote_agent_run(
api_key.clone(),
dependency_hooks,
hooks,
command,
policy,
crate::handlers::run::proxy::RemoteProxyConfig { proxy_url, session: remote_session.clone(), placeholders, child_env, protocol, ca_file: remote_ca_file, routing },
Expand Down Expand Up @@ -1362,7 +1352,7 @@ pub async fn handle_cli(args: Cli) -> Exit {
set_comments: Vec::new(),
print_secrets: None,
no_print_secrets: true,
dependency_hooks,
hooks,
local_session,
config_file: None,
file: profile.file,
Expand Down Expand Up @@ -1422,7 +1412,7 @@ pub async fn handle_cli(args: Cli) -> Exit {
json_format: raw_output,
silent,
scope: run_cmd.scope,
dependency_hooks: false,
hooks: crate::models::agent::EnabledHooks::default(),
local_session: None,
};

Expand Down Expand Up @@ -1590,12 +1580,15 @@ fn uses_local_dependency_hook_broker(entity_type: &EntityType) -> bool {
)
}

fn dependency_hooks_enabled(profile: &crate::models::agent::AgentProfile, api_key: &str) -> bool {
!api_key.is_empty()
&& profile
.allow_hooks
.iter()
.any(|hook| hook == "dependency_check")
fn enabled_hooks(
profile: &crate::models::agent::AgentProfile,
api_key: &str,
) -> crate::models::agent::EnabledHooks {
if api_key.is_empty() {
crate::models::agent::EnabledHooks::default()
} else {
profile.requested_hooks()
}
}

fn uses_local_dependency_hook_broker_mode(entity_type: &EntityType, mode: Option<&str>) -> bool {
Expand Down Expand Up @@ -2379,12 +2372,11 @@ fn spawn_remote_session_rotation(
mod tests {
use super::{
audit_binding_sources, codex_mcp_binding_header_overrides, configured_host_matches,
dependency_hooks_enabled, directory_profile_git_warning,
ensure_replacement_session_is_compatible, exit_for, infer_remote_agent_type,
is_bare_agent_hook, remote_bindings, remote_codex_command_with_mcp_binding_headers,
remote_session_rotation_delay_for, remote_session_started_message,
remote_session_transport_identity, remote_source_env_names, secret_child_name,
summarize_audit_events, uses_local_dependency_hook_broker_mode,
directory_profile_git_warning, enabled_hooks, ensure_replacement_session_is_compatible,
exit_for, infer_remote_agent_type, is_bare_agent_hook, remote_bindings,
remote_codex_command_with_mcp_binding_headers, remote_session_rotation_delay_for,
remote_session_started_message, remote_session_transport_identity, remote_source_env_names,
secret_child_name, summarize_audit_events, uses_local_dependency_hook_broker_mode,
};
use crate::api::remote_proxy::{RemoteBinding, RemoteBindingSource};
use crate::cmd::root::Cli;
Expand Down Expand Up @@ -2488,8 +2480,33 @@ mod tests {
"allow_hooks": ["dependency_check"]
}))
.unwrap();
assert!(!dependency_hooks_enabled(&profile, ""));
assert!(dependency_hooks_enabled(&profile, "key"));
assert!(!enabled_hooks(&profile, "").dependency_check);
assert!(enabled_hooks(&profile, "key").dependency_check);
}

#[test]
fn enabled_hooks_reads_both_hook_kinds_and_needs_an_api_key() {
let profile_with = |hooks: &[&str]| -> AgentProfile {
serde_json::from_value(serde_json::json!({
"file": null,
"egress_hosts": null,
"deny_hosts": null,
"allow_hooks": hooks
}))
.unwrap()
};

let hooks = enabled_hooks(&profile_with(&["secret_scan"]), "key");
assert!(hooks.secret_scan && !hooks.dependency_check);
assert_eq!(hooks.label(), "enabled (secret_scan)");
assert_eq!(
enabled_hooks(&profile_with(&["dependency_check", "secret_scan"]), "key").label(),
"enabled (dependency_check, secret_scan)"
);
assert_eq!(
enabled_hooks(&profile_with(&["secret_scan"]), "").label(),
"disabled"
);
}

#[test]
Expand Down
18 changes: 18 additions & 0 deletions src/handlers/run/docker_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ const PROXY_URL_ENV_KEYS: &[&str] = &[
"http_proxy",
"https_proxy",
crate::api::dependencies::HOOK_BROKER_URL_ENV,
crate::handlers::run::proxy::SCAN_BROKER_URL_ENV,
];

/// Rewrites the proxy's child-process env vars so the container reaches the
Expand Down Expand Up @@ -1694,6 +1695,23 @@ mod tests {
assert_eq!(rewritten["STASHBASE_GH_TOKEN"], "127.0.0.1");
}

#[test]
fn rewrite_proxy_urls_rewrites_the_scan_broker_url() {
let mut env_vars = std::collections::HashMap::new();
env_vars.insert(
crate::handlers::run::proxy::SCAN_BROKER_URL_ENV.to_owned(),
"http://127.0.0.1:9999/__stashbase/scan".to_owned(),
);

let rewritten =
rewrite_proxy_urls_for_container(&env_vars, "127.0.0.1", "host.docker.internal");

assert_eq!(
rewritten[crate::handlers::run::proxy::SCAN_BROKER_URL_ENV],
"http://host.docker.internal:9999/__stashbase/scan"
);
}

#[test]
fn rewrite_proxy_urls_is_a_no_op_when_hosts_match() {
let mut env_vars = std::collections::HashMap::new();
Expand Down
Loading
Loading