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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions agent-bridle-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,33 @@ impl Default for RootfsPolicy {
}
}

/// The default `PATH` for a **fully-authorized** (`exec = Scope::All`) confined
/// child: the ambient `$PATH` when set and non-empty, else the conventional
/// [`RootfsPolicy`] search dirs, joined with the platform separator.
///
/// The sandbox-host engine (`HostShellTool`, in `agent-bridle-tool-shell`) seeds
/// this into the child so bare program names (`grep`/`ls`/`find`) resolve like
/// the host shell would, instead of leaning on the shell's fragile compiled
/// `_CS_PATH` fallback. It is only meaningful — and only called — when
/// `exec` is unrestricted, so seeding it grants nothing the caller's exec
/// authority does not already permit; `env_clear` still scrubs everything else.
/// Mirrors the exec-search-dir precedence that anchors the L3 `Execute`
/// allow-list (`sandbox::exec_search_dirs`); kept here so it constructs on every
/// platform.
#[must_use]
pub fn default_exec_path() -> String {
if let Ok(path) = std::env::var("PATH") {
if !path.is_empty() {
return path;
}
}
let dirs = RootfsPolicy::default().search_dirs;
std::env::join_paths(&dirs)
.ok()
.and_then(|joined| joined.into_string().ok())
.unwrap_or_else(|| dirs.join(if cfg!(windows) { ";" } else { ":" }))
}

/// Toggles for the automatic "normalizations" (assists) — each defaults to today's
/// always-on behavior; only *loosening*-safe ones are exposed as on/off (safety
/// normalizations like fs canonicalization and env-scrub are intentionally absent
Expand Down
6 changes: 3 additions & 3 deletions agent-bridle-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ mod tool;
mod unbridle;

pub use config::{
BackendToggles, BridleConfig, BridleMode, GatePolicy, HostMatch, LimitsPolicy, NetDefault,
NetPolicy, NetRule, NormalizationPolicy, PathList, RootfsPolicy, SandboxPolicy, VmPolicy,
WebPolicy,
default_exec_path, BackendToggles, BridleConfig, BridleMode, GatePolicy, HostMatch,
LimitsPolicy, NetDefault, NetPolicy, NetRule, NormalizationPolicy, PathList, RootfsPolicy,
SandboxPolicy, VmPolicy, WebPolicy,
};
pub use context::ToolContext;
pub use envelope::{Denial, DenialKind, Disclosure, ToolEnvelope};
Expand Down
13 changes: 12 additions & 1 deletion agent-bridle-tool-shell/src/host_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ impl Tool for HostShellTool {
return Ok(self.engine_unavailable(DenialKind::Net, "net"));
}

let env: BTreeMap<String, String> = args
let mut env: BTreeMap<String, String> = args
.get("env")
.and_then(serde_json::Value::as_object)
.map(|m| {
Expand All @@ -193,6 +193,17 @@ impl Tool for HostShellTool {
.collect()
})
.unwrap_or_default();
// Full-access PATH seeding (Track 1a / parity). We only reach here when
// `exec` is unrestricted (a restricted exec/net was refused above), so the
// child is already permitted to run anything on the host. Seed a usable
// `PATH` — the ambient `$PATH`, else the conventional search dirs — so bare
// program names (`grep`/`ls`/`find`, and tools in non-standard dirs like
// `~/.cargo/bin` / `/opt/homebrew/bin`) resolve like the host shell,
// instead of leaning on the shell's fragile compiled `_CS_PATH` fallback.
// `ConfinedCommand`'s `env_clear` still scrubs everything else; a
// caller-provided `PATH` wins.
env.entry("PATH".to_string())
.or_insert_with(agent_bridle_core::default_exec_path);
let cwd = args.get("cwd").and_then(serde_json::Value::as_str);
let max_output = self.max_output;

Expand Down
64 changes: 64 additions & 0 deletions agent-bridle-tool-shell/tests/host_shell_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,67 @@ async fn fully_ambient_grant_runs_the_command() {
"the dynamic construct must have executed: {out}"
);
}

/// Regression (Track 1a — full-access parity): under a fully-authorized grant the
/// engine seeds a usable `PATH` into the child so bare program names
/// (`grep`/`ls`/`find`) resolve like the host shell, instead of relying on the
/// shell's fragile compiled `_CS_PATH` fallback (empty when `env_clear` scrubs
/// `PATH`). Proven by having the child echo its own `$PATH`: **pre-fix it was
/// unset (empty); post-fix it equals `default_exec_path()`**. `printf` is a shell
/// builtin, so this isolates the *seeding* — it needs no PATH itself.
#[tokio::test]
async fn full_access_seeds_default_path_into_the_child() {
use agent_bridle_core::default_exec_path;

let out = HostShellTool::new()
.invoke(
serde_json::json!({ "cmd": "printf '%s' \"$PATH\"" }),
&ctx(Caveats::top()),
)
.await
.expect("invoke");

assert_ne!(out["denied"], true, "ambient grant must run: {out}");
assert_eq!(
out["stdout"].as_str().unwrap_or_default(),
default_exec_path(),
"the child must see the seeded default PATH (was unset pre-fix): {out}"
);
}

/// A caller-provided `PATH` wins over the seeded default, and a **bare program
/// name** then resolves and runs inside the engine — the functional end of the
/// parity fix (grep/ls/find resolving). Deterministic: a temp dir with a marker
/// tool, no dependence on which host binaries live where.
#[cfg(unix)]
#[tokio::test]
async fn bare_name_resolves_when_path_includes_its_dir() {
use std::os::unix::fs::PermissionsExt;

let dir = unique_temp("bin");
std::fs::create_dir_all(&dir).unwrap();
let tool = dir.join("marker-tool");
std::fs::write(&tool, "#!/bin/sh\necho marker-ran\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();

let out = HostShellTool::new()
.invoke(
serde_json::json!({
"cmd": "marker-tool",
"env": { "PATH": dir.to_string_lossy() },
}),
&ctx(Caveats::top()),
)
.await
.expect("invoke");

assert_ne!(out["denied"], true, "ambient grant must run: {out}");
assert_eq!(out["exit_code"], 0, "the bare-name tool must run: {out}");
assert_eq!(
out["stdout"].as_str().unwrap_or_default().trim(),
"marker-ran",
"the bare program name must resolve via the provided PATH: {out}"
);

let _ = std::fs::remove_dir_all(&dir);
}
Loading