Skip to content
Draft
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
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

# HTTP client (webhook delivery)
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false }

# Cryptography
sha2 = "0.11"
Expand Down
11 changes: 10 additions & 1 deletion crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ url = { workspace = true }
sha2 = { workspace = true }
base64 = "0.22"
hex = { workspace = true }
rand = { workspace = true }
subtle = { workspace = true }
tempfile = "3"
dirs = "6"

# Logging
tracing = { workspace = true }
Expand All @@ -74,8 +78,13 @@ evalexpr = { workspace = true }
# Process-group kill (safe wrapper around killpg) — Unix-only; kill_process_group
# has a #[cfg(not(unix))] fallback in acp.rs.
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal"] }
nix = { version = "0.31", default-features = false, features = ["process", "signal"] }
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
httparse = "1"
axum = { workspace = true }
175 changes: 172 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ fn deep_merge(
/// deep-merged into the result (parent wins on colliding keys at every nesting level;
/// unrelated keys from either side survive).
/// 4. **Forced overlay** — `sandbox_workspace_write.network_access = true` is applied
/// last so relay access is guaranteed regardless of operator / persona config.
/// last so relay access is guaranteed regardless of operator / persona config. When
/// a harness delivery broker is present, its isolated `requests` directory is appended
/// to `sandbox_workspace_write.writable_roots` without replacing existing roots.
///
/// When `has_generated_codex_config` is false, the function returns `None` and the
/// caller handles any persona-supplied `CODEX_CONFIG` with ordinary operator-wins
Expand All @@ -264,7 +266,8 @@ fn deep_merge(
///
/// Returns `Err(AcpError::Protocol)` when `has_generated_codex_config` is true and any
/// `CODEX_CONFIG` value is not valid JSON or is not a JSON object, or when
/// `sandbox_workspace_write` is present but not an object after all merges.
/// `sandbox_workspace_write` is present but not an object after all merges, or when its
/// `writable_roots` value is not an array.
pub(crate) fn build_codex_config_env(
extra_env: &[(String, String)],
parent_codex_config: Option<&str>,
Expand Down Expand Up @@ -335,13 +338,82 @@ pub(crate) fn build_codex_config_env(
}
}

// Force sandbox_workspace_write.network_access = true (our invariant, always wins).
let broker_request_root = extra_env
.iter()
.rev()
.find(|(key, _)| key == buzz_core::delivery_broker::BROKER_DIR_ENV)
.map(|(_, root)| std::path::Path::new(root).join("requests"));

// Force sandbox_workspace_write.network_access = true (our invariant, always wins)
// and add only the broker request inbox as writable. The broker parent,
// processing directory, and signed-response directory remain outside the
// sandbox's writable roots.
let sws_entry = base
.entry("sandbox_workspace_write")
.or_insert_with(|| serde_json::json!({}));
match sws_entry {
serde_json::Value::Object(sws_obj) => {
sws_obj.insert("network_access".to_string(), serde_json::Value::Bool(true));
if let Some(request_root) = broker_request_root {
let request_root = request_root.to_str().ok_or_else(|| {
AcpError::Protocol("delivery broker request path is not valid UTF-8".into())
})?;
let writable_roots = sws_obj
.entry("writable_roots")
.or_insert_with(|| serde_json::json!([]));
let serde_json::Value::Array(roots) = writable_roots else {
return Err(AcpError::Protocol(
"CODEX_CONFIG sandbox_workspace_write.writable_roots is not an array"
.into(),
));
};
let canonical_request_root = std::fs::canonicalize(request_root).map_err(|e| {
AcpError::Protocol(format!(
"canonicalize delivery broker request root {request_root}: {e}"
))
})?;
let canonical_broker_root = canonical_request_root.parent().ok_or_else(|| {
AcpError::Protocol("delivery broker request root has no parent".into())
})?;
for existing in roots.iter() {
let existing = existing.as_str().ok_or_else(|| {
AcpError::Protocol(
"CODEX_CONFIG writable_roots entries must be strings".into(),
)
})?;
let existing_path = std::path::PathBuf::from(existing);
let existing_path = if existing_path.is_absolute() {
existing_path
} else {
std::env::current_dir()
.map_err(|e| AcpError::Protocol(e.to_string()))?
.join(existing_path)
};
let canonical_existing =
std::fs::canonicalize(&existing_path).map_err(|e| {
AcpError::Protocol(format!(
"canonicalize CODEX_CONFIG writable root {}: {e}",
existing_path.display()
))
})?;
if canonical_existing != canonical_request_root
&& (canonical_broker_root.starts_with(&canonical_existing)
|| canonical_existing.starts_with(canonical_broker_root))
{
return Err(AcpError::Protocol(format!(
"CODEX_CONFIG writable root {} overlaps protected delivery broker root {}",
canonical_existing.display(),
canonical_broker_root.display()
)));
}
}
if !roots
.iter()
.any(|value| value.as_str() == Some(request_root))
{
roots.push(serde_json::Value::String(request_root.into()));
}
}
}
other => {
return Err(AcpError::Protocol(format!(
Expand Down Expand Up @@ -505,6 +577,17 @@ impl AcpClient {
// Handled by build_codex_config_env; skip here to avoid double-setting.
continue;
}
if matches!(
key.as_str(),
buzz_core::delivery_broker::BROKER_DIR_ENV
| buzz_core::delivery_broker::BROKER_CAPABILITY_ENV
| buzz_core::delivery_broker::BROKER_RESPONSE_PUBKEY_ENV
) {
// These values are generated per harness lifetime. A stale
// inherited value must never override the live broker.
cmd.env(key, value);
continue;
}
if std::env::var_os(key).is_none() {
cmd.env(key, value);
}
Expand Down Expand Up @@ -4466,6 +4549,92 @@ mod tests {
);
}

#[test]
fn build_codex_config_env_appends_only_broker_request_writable_root() {
let temp = tempfile::tempdir().expect("tempdir");
let broker_root = temp.path().join("broker");
let existing_root = temp.path().join("existing");
std::fs::create_dir_all(broker_root.join("requests")).expect("requests");
std::fs::create_dir(&existing_root).expect("existing root");
let broker_root_text = broker_root.to_string_lossy().into_owned();
let persona = serde_json::json!({
"sandbox_workspace_write": {
"writable_roots": [existing_root.to_string_lossy()]
}
})
.to_string();
let extra = vec![
("CODEX_CONFIG".into(), persona),
("CODEX_CONFIG".into(), GENERATED.into()),
(
buzz_core::delivery_broker::BROKER_DIR_ENV.into(),
broker_root_text,
),
];
let merged = build_codex_config_env(&extra, None, true).unwrap().unwrap();
let value: serde_json::Value = serde_json::from_str(&merged).unwrap();
let roots = value["sandbox_workspace_write"]["writable_roots"]
.as_array()
.expect("writable roots");
assert!(roots
.iter()
.any(|root| root.as_str() == Some(existing_root.to_string_lossy().as_ref())));
let expected = broker_root.join("requests").to_string_lossy().into_owned();
assert_eq!(
roots
.iter()
.filter(|root| root.as_str() == Some(expected.as_str()))
.count(),
1
);
assert!(!roots
.iter()
.any(|root| root.as_str() == Some(broker_root.to_string_lossy().as_ref())));
}

#[test]
fn build_codex_config_env_rejects_non_array_writable_roots_for_broker() {
let temp = tempfile::tempdir().expect("tempdir");
let broker_root = temp.path().join("broker");
std::fs::create_dir_all(broker_root.join("requests")).expect("requests");
let extra = vec![
(
"CODEX_CONFIG".into(),
r#"{"sandbox_workspace_write":{"writable_roots":"/too-broad"}}"#.into(),
),
("CODEX_CONFIG".into(), GENERATED.into()),
(
buzz_core::delivery_broker::BROKER_DIR_ENV.into(),
broker_root.to_string_lossy().into_owned(),
),
];
let error = build_codex_config_env(&extra, None, true).expect_err("invalid roots");
assert!(error.to_string().contains("writable_roots"));
}

#[test]
fn build_codex_config_env_rejects_writable_root_overlapping_broker_parent() {
let temp = tempfile::tempdir().expect("tempdir");
let broker_root = temp.path().join("broker");
std::fs::create_dir_all(broker_root.join("requests")).expect("requests");
let persona = serde_json::json!({
"sandbox_workspace_write": {
"writable_roots": [temp.path().to_string_lossy()]
}
})
.to_string();
let extra = vec![
("CODEX_CONFIG".into(), persona),
("CODEX_CONFIG".into(), GENERATED.into()),
(
buzz_core::delivery_broker::BROKER_DIR_ENV.into(),
broker_root.to_string_lossy().into_owned(),
),
];
let error = build_codex_config_env(&extra, None, true).expect_err("overlap");
assert!(error.to_string().contains("overlaps protected"));
}

#[test]
fn build_codex_config_env_persona_only_signal_false_returns_none() {
// Persona set CODEX_CONFIG; Buzz did not inject a generated overlay (signal=false).
Expand Down
61 changes: 54 additions & 7 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,12 +683,27 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
.iter()
.find_map(|extension| lower.strip_suffix(extension))
.unwrap_or(&lower);
stem.chars()
let identity: String = stem
.chars()
.map(|character| match character {
' ' | '_' => '-',
_ => character,
})
.collect()
.collect();
// Standalone codex-acp releases use platform-qualified binary names. They
// are the same runtime identity and must receive the generated Codex
// network policy, broker environment, and capability gate even when Buzz
// launches the downloaded artifact directly rather than through an npm
// shim named `codex-acp`.
match identity.as_str() {
"codex-acp-x64-linux"
| "codex-acp-arm64-linux"
| "codex-acp-x64-darwin"
| "codex-acp-arm64-darwin"
| "codex-acp-x64-windows"
| "codex-acp-arm64-windows" => "codex-acp".into(),
_ => identity,
}
}

fn default_agent_args(command: &str) -> Option<Vec<String>> {
Expand Down Expand Up @@ -729,11 +744,12 @@ pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'sta
/// Returns `Some(("CODEX_CONFIG", "{\"sandbox_workspace_write\":{\"network_access\":true}}"))` for
/// Codex agents, or `None` for non-Codex agents or when the relay URL cannot be parsed.
///
/// The env var is forwarded by the `@agentclientprotocol/codex-acp` adapter (1.x) as a
/// session-level config override (via `CODEX_CONFIG` → `thread/start config`), which is
/// equivalent to the TOML override `sandbox_workspace_write.network_access = true`.
/// That sets `NetworkSandboxPolicy::Enabled`, causing the Seatbelt policy to include
/// `(allow network-outbound)` — full outbound TCP/TLS at the OS level.
/// The env var is forwarded by a compatible `@agentclientprotocol/codex-acp` adapter as
/// both the session-level config override and the per-turn workspace-write policy. The
/// per-turn propagation matters because Codex treats that policy as authoritative over
/// the thread configuration. It is equivalent to the TOML override
/// `sandbox_workspace_write.network_access = true`, which enables outbound TCP/TLS while
/// retaining the workspace-write filesystem sandbox.
///
/// URL validation is preserved as a guard: injection is skipped when the relay URL cannot
/// be parsed, avoiding accidental sandbox widening for malformed configs.
Expand Down Expand Up @@ -1634,6 +1650,37 @@ mod tests {
assert_eq!(normalize_agent_command_identity("///"), "");
}

#[test]
fn packaged_codex_acp_commands_activate_the_codex_delivery_path() {
let packaged_commands = [
"/opt/buzz/codex-acp-x64-linux",
"/opt/buzz/codex-acp-arm64-linux",
"/opt/buzz/codex-acp-x64-darwin",
"/opt/buzz/codex-acp-arm64-darwin",
r"C:\Buzz\codex-acp-x64-windows.exe",
r"C:\Buzz\codex-acp-arm64-windows.exe",
];
let private_key = Keys::generate().secret_key().to_secret_hex();

for command in packaged_commands {
assert_eq!(normalize_agent_command_identity(command), "codex-acp");
assert!(codex_network_env(command, "wss://relay.example.com").is_some());

let args = CliArgs::parse_from([
"buzz-acp",
"--private-key",
&private_key,
"--agent-command",
command,
]);
let config = Config::from_args(args).expect("packaged Codex config");
assert!(
config.has_generated_codex_config,
"packaged command did not activate Codex policy: {command}"
);
}
}

#[test]
fn default_agent_env_recognizes_hermes_identities() {
for command in [
Expand Down
Loading