Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix(desktop): refuse reserved keys in the baked build env
The baked `BUZZ_BUILD_AGENT_ENV` pairs are written into a spawned agent's
environment last, after Desktop sets the access gates and identity vars,
and nothing filtered them. A build packaged with `BUZZ_ACP_RESPOND_TO` or
`BUZZ_ACP_ALLOWED_RESPOND_TO` set to `anyone` therefore produced agents
that answer anyone while the UI shows the access locked to "Only me", so
the build capability could disable its own enforcement.

Reject reserved keys where the value enters the system, in `build.rs`, so
such a build fails instead of shipping, and keep a runtime filter in
`baked_build_env()` for a binary built without that check. The key list
is `include!`d from one source into both consumers, matching the existing
`reconnect_hook_config.rs` pattern, because a build script cannot import
from the crate and the two copies must not drift.

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
  • Loading branch information
npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca and brow committed Aug 5, 2026
commit 34f8ac823661d55a7003318e184e264afffdeb73
17 changes: 17 additions & 0 deletions desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Shared schema, included from the same source the runtime command parses with,
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");
// Same source of truth the runtime filters with, so a baked build env cannot
// carry a reserved key the runtime believes it already rejected.
include!("src/managed_agents/reserved_env_keys.rs");

use base64::Engine as _;

Expand Down Expand Up @@ -66,6 +69,20 @@ fn main() {
line
);
}
// The baked env is written into every spawned agent's environment
// LAST (see `managed_agents/runtime.rs`), after Buzz sets the
// access gates and identity vars. A baked reserved key would
// therefore silently override the gate the UI promises, so reject
// it at build time instead of shipping a binary that bypasses its
// own enforcement.
if is_reserved_env_key(key) {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \
into a build (it would override Buzz's own identity/access env)",
line_no + 1,
key
);
}
}
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,10 @@ fn child_shell_is_the_resolved_shell_not_the_inherited_one() {
/// grows a new secret, this points at the file to update.
#[test]
fn reserved_keys_are_covered() {
let source = include_str!("../../../src/managed_agents/env_vars.rs");
// The list lives in its own file because `build.rs` `include!`s the same
// source (see `managed_agents/reserved_env_keys.rs`); read it there rather
// than through the module that includes it.
let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs");
let declared: Vec<&str> = source
.lines()
.skip_while(|line| !line.contains("RESERVED_ENV_KEYS"))
Expand Down
75 changes: 75 additions & 0 deletions desktop/src-tauri/src/managed_agents/agent_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ fn build_env_map(
}
}
}
// Defense in depth. `build.rs` already refuses to bake a reserved key, so
// reaching this filter means the binary was produced by a build that
// skipped that check. Drop the key rather than let it override the access
// gate: the baked map is written into the spawned agent's environment last
// (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would
// otherwise win over the gate Desktop just set.
map.retain(|key, _| {
if super::env_vars::is_reserved_env_key(key) {
eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env");
return false;
}
true
});
map
}

Expand Down Expand Up @@ -356,4 +369,66 @@ mod tests {
"unrelated merged_env keys must pass through unchanged"
);
}

// ── baked reserved-key filtering ──────────────────────────────────────
//
// The baked map is written into a spawned agent's environment LAST (see
// `managed_agents/runtime.rs`), after Buzz sets the access gates. If a
// baked reserved key survived here, an internal build packaged with
// `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows
// "Only me". `build.rs` rejects such a key at build time; these tests pin
// the runtime backstop for a binary built without that check.

#[test]
fn build_env_map_drops_baked_access_gate_keys() {
use base64::Engine as _;
let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8";
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let map = build_env_map(None, None, Some(&blob));
for key in [
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_ALLOWED_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
] {
assert!(
!map.contains_key(key),
"baked `{key}` must not reach the spawned agent env"
);
}
assert_eq!(
map.get("DATABRICKS_MODEL").map(String::as_str),
Some("goose-claude-opus-4-8"),
"non-reserved baked keys must still pass through"
);
}

#[test]
fn build_env_map_drops_baked_reserved_keys_case_insensitively() {
use base64::Engine as _;
// `is_reserved_env_key` compares case-insensitively, and so must the
// baked filter: env lookup is case-sensitive on Unix, but a lowercase
// spelling would still be a reserved key smuggled past a case-sensitive
// check on Windows.
let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake";
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let map = build_env_map(None, None, Some(&blob));
assert!(
map.is_empty(),
"reserved keys in any casing must be dropped from the baked env: {map:?}"
);
}

#[test]
fn build_env_map_drops_every_reserved_key() {
use base64::Engine as _;
for key in super::super::env_vars::RESERVED_ENV_KEYS {
let raw = format!("{key}=baked-value");
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
let map = build_env_map(None, None, Some(&blob));
assert!(
map.is_empty(),
"baked reserved key `{key}` must be dropped, got {map:?}"
);
}
}
}
66 changes: 3 additions & 63 deletions desktop/src-tauri/src/managed_agents/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,69 +39,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
.any(|k| k.eq_ignore_ascii_case(key))
}

/// Env var keys that Buzz sets itself and users must not override from
/// the persona/agent env_vars UI. Three categories:
///
/// 1. **Identity / secrets** — overriding would swap the agent's nsec or
/// leak credentials.
/// 2. **Code-execution surface** — overriding the binary/args lets the
/// user run arbitrary code as the agent process.
/// 3. **Security gates** — overriding the respond-to mode/allowlist or
/// relay URL would silently break the saved security settings (the UI
/// shows owner-only while the running agent answers anyone, for
/// example), or redirect the agent to an attacker-controlled relay.
///
/// This list is deliberately narrow — it only covers keys with security
/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
/// overridable; those have dedicated UI fields but power users may want
/// to bypass them.
pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
// Identity / secrets.
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_API_TOKEN",
"BUZZ_ACP_PRIVATE_KEY",
"BUZZ_ACP_API_TOKEN",
// Relay URL: overriding would let a malicious config redirect the
// agent to an attacker-controlled relay.
"BUZZ_RELAY_URL",
// Code-execution surface: overriding would let the user run arbitrary
// binaries/args as the agent process.
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_MCP_COMMAND",
// Security gates: respond-to mode + allowlist + deployment allowlist +
// legacy owner-only fallback. Overriding would make the running agent's
// gate diverge from the saved/UI-visible settings.
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
"BUZZ_ACP_ALLOWED_RESPOND_TO",
"BUZZ_ACP_AGENT_OWNER",
// Stable agent identity used for git attribution and private-conversation
// provenance must come from the managed-agent record, not user overrides.
"BUZZ_ACP_DISPLAY_NAME",
// Remote lifetime/presence policy: user env must not disable the
// desktop/provider-owned bounds while the saved record still promises them.
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
"BUZZ_ACP_NO_PRESENCE",
// Readiness handoff: desktop is the ONLY readiness source. A saved or
// ambient env var must not be able to forge setup mode (NotReady) on a
// Ready agent or suppress it (empty/stale payload) on a NotReady one.
"BUZZ_ACP_SETUP_PAYLOAD",
// Desktop ownership markers: these brand every spawned harness with the
// launching Desktop instance. A user-supplied override would let a
// definition masquerade as a different instance or fake the nonce used
// for same-session sweep decisions.
"BUZZ_MANAGED_AGENT",
"BUZZ_MANAGED_AGENT_START_NONCE",
];

pub(crate) fn is_reserved_env_key(key: &str) -> bool {
RESERVED_ENV_KEYS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(key))
}
// Canonical reserved-key list + predicate, shared verbatim with `build.rs`.
// See `reserved_env_keys.rs` for why this is `include!`d rather than a module.
include!("reserved_env_keys.rs");

/// Returns true if `key` is a well-formed POSIX-shaped env var name:
/// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic
Expand Down
74 changes: 74 additions & 0 deletions desktop/src-tauri/src/managed_agents/reserved_env_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Canonical reserved-env-key list, `include!`d into BOTH `build.rs`
// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and
// `managed_agents/env_vars.rs` (save-time validation and spawn-time
// filtering). Build scripts cannot import from the crate, so sharing the
// source via `include!` is what guarantees the build-time check and the
// runtime filter use one identical list — zero drift surface. See
// `commands/reconnect_hook_config.rs` for the same pattern.
//
// Keep this file dependency-free: no crate-internal imports, no external
// crates. Both consumers compile it as-is.

/// Env var keys that Buzz sets itself and users must not override from
/// the persona/agent env_vars UI. Three categories:
///
/// 1. **Identity / secrets** — overriding would swap the agent's nsec or
/// leak credentials.
/// 2. **Code-execution surface** — overriding the binary/args lets the
/// user run arbitrary code as the agent process.
/// 3. **Security gates** — overriding the respond-to mode/allowlist or
/// relay URL would silently break the saved security settings (the UI
/// shows owner-only while the running agent answers anyone, for
/// example), or redirect the agent to an attacker-controlled relay.
///
/// This list is deliberately narrow — it only covers keys with security
/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
/// overridable; those have dedicated UI fields but power users may want
/// to bypass them.
pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
// Identity / secrets.
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_API_TOKEN",
"BUZZ_ACP_PRIVATE_KEY",
"BUZZ_ACP_API_TOKEN",
// Relay URL: overriding would let a malicious config redirect the
// agent to an attacker-controlled relay.
"BUZZ_RELAY_URL",
// Code-execution surface: overriding would let the user run arbitrary
// binaries/args as the agent process.
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_MCP_COMMAND",
// Security gates: respond-to mode + allowlist + deployment allowlist +
// legacy owner-only fallback. Overriding would make the running agent's
// gate diverge from the saved/UI-visible settings.
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
"BUZZ_ACP_ALLOWED_RESPOND_TO",
"BUZZ_ACP_AGENT_OWNER",
// Stable agent identity used for git attribution and private-conversation
// provenance must come from the managed-agent record, not user overrides.
"BUZZ_ACP_DISPLAY_NAME",
// Remote lifetime/presence policy: user env must not disable the
// desktop/provider-owned bounds while the saved record still promises them.
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
"BUZZ_ACP_NO_PRESENCE",
// Readiness handoff: desktop is the ONLY readiness source. A saved or
// ambient env var must not be able to forge setup mode (NotReady) on a
// Ready agent or suppress it (empty/stale payload) on a NotReady one.
"BUZZ_ACP_SETUP_PAYLOAD",
// Desktop ownership markers: these brand every spawned harness with the
// launching Desktop instance. A user-supplied override would let a
// definition masquerade as a different instance or fake the nonce used
// for same-session sweep decisions.
"BUZZ_MANAGED_AGENT",
"BUZZ_MANAGED_AGENT_START_NONCE",
];

pub(crate) fn is_reserved_env_key(key: &str) -> bool {
RESERVED_ENV_KEYS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(key))
}