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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# Path to a file containing the heartbeat prompt.
# BUZZ_ACP_HEARTBEAT_PROMPT_FILE=

# Optional exact mention set for new stream messages and forum posts/comments.
# When configured, every top-level message must carry exactly these
# comma-separated hex pubkeys or npubs as p tags. Replies may omit p tags only
# when their signed parent exists in the same channel; any p tags they do carry
# must exactly match this set. Edits and diff messages are unaffected. The CLI
# and harness broker both reject mismatches before relay submission.
# BUZZ_OUTBOUND_TOP_LEVEL_MENTION_PUBKEYS=

# ── Desktop development ──────────────────────────────────────────────────────
# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
# app launch while keeping the current identity and relay data.
Expand Down
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
Loading