From 8405c4a75b0459747665a13740970406d6008289 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 17:28:05 -0700 Subject: [PATCH 001/105] ANAI-29: scaffold openfang-mcp-bridge crate (spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone crate exposing OpenFang's tool surface to MCP clients (primarily Claude Code subprocesses) over stdio. Per architectural decision in ANAI-22: not folded into openfang-runtime — keeps the protocol adapter out of the kernel/compactor blast radius and the dep graph clean. This commit is scaffolding only: * Cargo manifest with rmcp 1.x (server, transport-io, macros) * lib.rs: ToolDispatcher seam trait (runtime-implements, bridge-consumes, one-way dep), ToolDispatchError enum, Bridge struct wrapping an Optional>, single stub `ping` tool * main.rs: stdio MCP server entrypoint, tracing -> stderr (stdout is the transport), no dispatcher attached * Workspace members updated Identity is bound at Bridge construction time, not per-call — the security invariant tracked by ANAI-31. Real tool surface mapping lands in ANAI-30. cargo check -p openfang-mcp-bridge: clean. cargo check --workspace: clean (pre-existing imap-proto future-incompat warning unrelated). Refs: ANAI-22, ANAI-29 Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 54 ++++++++++- Cargo.toml | 1 + crates/openfang-mcp-bridge/Cargo.toml | 38 ++++++++ crates/openfang-mcp-bridge/src/lib.rs | 129 +++++++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 40 ++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 crates/openfang-mcp-bridge/Cargo.toml create mode 100644 crates/openfang-mcp-bridge/src/lib.rs create mode 100644 crates/openfang-mcp-bridge/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index e880570247..3eceb3e02a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4180,6 +4180,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "openfang-mcp-bridge" +version = "0.6.4" +dependencies = [ + "anyhow", + "async-trait", + "rmcp", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openfang-memory" version = "0.6.9" @@ -4487,6 +4502,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -5587,12 +5608,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", + "base64 0.22.1", "chrono", "futures", "http", + "pastey", "pin-project-lite", "process-wrap", "reqwest 0.13.2", + "rmcp-macros", + "schemars 1.2.1", "serde", "serde_json", "sse-stream", @@ -5603,6 +5628,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "rmp" version = "0.8.15" @@ -5827,7 +5865,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -5852,8 +5890,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -5870,6 +5910,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 554d9a30e0..404aa5e33c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/openfang-desktop", "crates/openfang-hands", "crates/openfang-extensions", + "crates/openfang-mcp-bridge", "xtask", ] diff --git a/crates/openfang-mcp-bridge/Cargo.toml b/crates/openfang-mcp-bridge/Cargo.toml new file mode 100644 index 0000000000..9b79ad2787 --- /dev/null +++ b/crates/openfang-mcp-bridge/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "openfang-mcp-bridge" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "MCP (Model Context Protocol) bridge — exposes OpenFang's tool surface to Claude Code subprocesses (and other MCP clients) over stdio, scoped per-agent identity." + +# Spike status: this is the ANAI-29 scaffold. The crate compiles a stub stdio +# MCP server that responds to `initialize` + `tools/list` with a hard-coded +# `ping` tool. Real ToolDispatcher wiring lands in ANAI-30+. + +[dependencies] +# Workspace-shared +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +async-trait = { workspace = true } + +# MCP — Rust SDK for Model Context Protocol. +# Pinned at scaffold time; revisit during ANAI-30 once we know which features +# (server, transport-io, macros) we actually need. +rmcp = { version = "1", features = ["server", "transport-io"] } + +# OpenFang internals — none yet. +# +# The bridge consumes a narrow `ToolDispatcher` trait, defined locally in this +# crate (see lib.rs). The runtime will implement that trait and pass an +# `Arc` in. We deliberately avoid pulling in +# `openfang-runtime` / `openfang-kernel` / `openfang-memory` here — the seam +# is one-way (runtime depends on bridge, not vice versa) so the bridge stays +# testable in isolation and out of the kernel/compactor blast radius. + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs new file mode 100644 index 0000000000..da51688265 --- /dev/null +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -0,0 +1,129 @@ +//! # openfang-mcp-bridge +//! +//! MCP (Model Context Protocol) bridge for OpenFang Agent OS. +//! +//! ## What this crate is +//! +//! A protocol adapter that exposes OpenFang's tool surface to Claude Code +//! subprocesses (and other MCP clients) over stdio. One MCP server instance +//! per parent agent, scoped to that agent's identity and the capabilities +//! declared in its `agent.toml`. +//! +//! ## What this crate is NOT +//! +//! - It does NOT depend on `openfang-runtime`, `openfang-kernel`, or +//! `openfang-memory` directly. The bridge consumes a narrow +//! [`ToolDispatcher`] trait that the runtime exposes; the runtime owns +//! identity, the kernel owns dispatch, the memory subsystem stays untouched. +//! - It does NOT define OpenFang's tool surface. Tools are declared by their +//! owning crates and registered through the dispatcher. +//! +//! ## Project status +//! +//! Spike scaffold (ANAI-29). At this stage the bridge: +//! - Compiles as a standalone crate in the workspace. +//! - Ships a stdio binary (`openfang-mcp-bridge`) that responds to MCP +//! `initialize` and `tools/list` with a single stub `ping` tool. +//! - Defines the [`ToolDispatcher`] seam trait (unused by the stub). +//! +//! Real tool surface mapping lands in ANAI-30. Identity threading lands in +//! ANAI-31. See ANAI-22 for the umbrella tracker. + +use std::sync::Arc; + +use rmcp::{ + ErrorData as McpError, ServerHandler, + handler::server::router::tool::ToolRouter, + model::*, + tool, tool_handler, tool_router, +}; + +/// Narrow seam between the bridge and the OpenFang runtime. +/// +/// The runtime implements this trait and hands an `Arc` to +/// the bridge at startup, scoped to a specific agent identity. The bridge +/// translates incoming MCP `tools/call` requests into [`ToolDispatcher::call`] +/// invocations. +/// +/// **Identity is bound at construction time, not per-call.** A bridge instance +/// only ever speaks for one agent. This is the security invariant tracked by +/// ANAI-31. +#[async_trait::async_trait] +pub trait ToolDispatcher: Send + Sync { + /// Identity of the agent this dispatcher is bound to. Used for audit + /// logging and for cross-checking against `agent.toml` capabilities. + fn agent_id(&self) -> &str; + + /// List of tool names this dispatcher will accept, derived from + /// `agent.toml` capabilities. The bridge filters its advertised tool list + /// against this set. + fn allowed_tools(&self) -> Vec; + + /// Invoke a tool by name with a JSON argument blob. The dispatcher is + /// responsible for capability enforcement; the bridge MUST NOT assume the + /// caller is trusted. + async fn call( + &self, + tool_name: &str, + args: serde_json::Value, + ) -> Result; +} + +/// Errors a [`ToolDispatcher`] can return. Bridge maps these to MCP errors. +#[derive(Debug, thiserror::Error)] +pub enum ToolDispatchError { + #[error("unknown tool: {0}")] + UnknownTool(String), + #[error("tool '{0}' not permitted for this agent")] + NotPermitted(String), + #[error("invalid arguments for tool '{tool}': {reason}")] + InvalidArgs { tool: String, reason: String }, + #[error("tool execution failed: {0}")] + Execution(#[from] anyhow::Error), +} + +/// The MCP server handler — wraps a [`ToolDispatcher`] and serves it over MCP. +/// +/// In the spike scaffold the dispatcher is optional and unused; only the stub +/// `ping` tool is wired. ANAI-30 will replace `ping` with dispatcher-backed +/// tools. +#[derive(Clone)] +pub struct Bridge { + #[allow(dead_code)] // wired in ANAI-30 + dispatcher: Option>, + // Read by code generated by `#[tool_router]` / `#[tool_handler]`; the + // dead-code analyzer doesn't see those reads. + #[allow(dead_code)] + tool_router: ToolRouter, +} + +#[tool_router] +impl Bridge { + pub fn new(dispatcher: Option>) -> Self { + Self { + dispatcher, + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Stub liveness check. Returns 'pong'. Replaced in ANAI-30 by dispatcher-backed tools.")] + async fn ping(&self) -> Result { + Ok(CallToolResult::success(vec![Content::text("pong")])) + } +} + +#[tool_handler] +impl ServerHandler for Bridge { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder().enable_tools().build(), + ) + .with_server_info(Implementation::from_build_env()) + .with_protocol_version(ProtocolVersion::V_2024_11_05) + .with_instructions( + "OpenFang MCP bridge (spike). Exposes OpenFang's tool surface to MCP clients. \ + Currently stubbed with a single `ping` tool; full tool surface lands in ANAI-30." + .to_string(), + ) + } +} diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs new file mode 100644 index 0000000000..dc01997818 --- /dev/null +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -0,0 +1,40 @@ +//! Stdio entrypoint for the OpenFang MCP bridge. +//! +//! In the spike scaffold this binary runs the bridge with no dispatcher +//! attached — it serves only the stub `ping` tool. The real launch path +//! (parent agent spawns this binary as a child, hands it an identity-scoped +//! dispatcher over IPC) is the subject of ANAI-31. +//! +//! Usage (spike validation): +//! +//! ```text +//! npx @modelcontextprotocol/inspector cargo run -p openfang-mcp-bridge +//! ``` + +use anyhow::Result; +use openfang_mcp_bridge::Bridge; +use rmcp::{ServiceExt, transport::stdio}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + // Tracing goes to stderr — stdout is the MCP transport, do not pollute it. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("openfang_mcp_bridge=info,rmcp=warn")), + ) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + tracing::info!("openfang-mcp-bridge starting (spike scaffold, no dispatcher)"); + + let service = Bridge::new(None) + .serve(stdio()) + .await + .inspect_err(|e| tracing::error!(error = ?e, "bridge serve failed"))?; + + service.waiting().await?; + Ok(()) +} From 32b77da7151c073e72debf309346cc02a76f1e10 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 19:58:29 -0700 Subject: [PATCH 002/105] ANAI-30 step 1: daemon-side bridge IPC listener + wire protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the daemon-side foundation for the MCP bridge per the ANAI-30 plan (topology 1b: daemon → CC → bridge → unix socket → daemon dispatcher). - New `protocol` module in openfang-mcp-bridge: Frame/Hello/HelloAck/ CallRequest/CallResponse types with length-prefixed JSON framing (1 MiB cap, 4-byte BE length prefix). Gated by `ipc-codec` feature so type-only consumers can drop the tokio io traits. - New `bridge_ipc` module in openfang-api: BridgeIpcServer binds /run/bridge.sock (0600), accept loop with graceful shutdown via Notify, per-connection Hello validation and CallRequest → CallResponse loop. - run_daemon spawns the listener; failure is non-fatal (HTTP keeps serving; bridge just unavailable). Socket file removed on shutdown. Step 1 stub: the dispatcher returns CallResult::Error ("not yet wired"). Step 2 replaces this with a call into openfang_runtime::tool_runner::execute_tool, scoped to the four-tool allowlist (file_read, file_list, agent_list, channel_send). Identity binding + token-table auth land in ANAI-31. Tests: 3 protocol roundtrip tests + 4 IPC handler tests (handshake/dispatch end-to-end via tempfile socket, version mismatch rejection, empty-token rejection). Refs ANAI-30, ANAI-22. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + crates/openfang-api/Cargo.toml | 1 + crates/openfang-api/src/bridge_ipc.rs | 394 +++++++++++++++++++++ crates/openfang-api/src/lib.rs | 1 + crates/openfang-api/src/server.rs | 14 +- crates/openfang-mcp-bridge/Cargo.toml | 7 + crates/openfang-mcp-bridge/src/lib.rs | 2 + crates/openfang-mcp-bridge/src/protocol.rs | 227 ++++++++++++ 8 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 crates/openfang-api/src/bridge_ipc.rs create mode 100644 crates/openfang-mcp-bridge/src/protocol.rs diff --git a/Cargo.lock b/Cargo.lock index 3eceb3e02a..8030ac9dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3975,6 +3975,7 @@ dependencies = [ "openfang-extensions", "openfang-hands", "openfang-kernel", + "openfang-mcp-bridge", "openfang-memory", "openfang-migrate", "openfang-runtime", diff --git a/crates/openfang-api/Cargo.toml b/crates/openfang-api/Cargo.toml index fcc3e0088c..98994b8476 100644 --- a/crates/openfang-api/Cargo.toml +++ b/crates/openfang-api/Cargo.toml @@ -16,6 +16,7 @@ openfang-skills = { path = "../openfang-skills" } openfang-hands = { path = "../openfang-hands" } openfang-extensions = { path = "../openfang-extensions" } openfang-migrate = { path = "../openfang-migrate" } +openfang-mcp-bridge = { path = "../openfang-mcp-bridge" } dashmap = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs new file mode 100644 index 0000000000..40ea2e54d9 --- /dev/null +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -0,0 +1,394 @@ +//! Daemon-side IPC server for the MCP bridge. +//! +//! ## Topology +//! +//! The bridge runs as a *grandchild* of the daemon: +//! +//! ```text +//! daemon (this process) +//! └── claude (CC subprocess, one per prompt) +//! └── openfang-mcp-bridge (CC spawns this from --mcp-config) +//! └── ───── unix socket ─────► daemon (BridgeIpcServer) +//! ``` +//! +//! Tools that need [`KernelHandle`](openfang_runtime::kernel_handle::KernelHandle) +//! (e.g. `agent_list`, `channel_send`) cannot run inside the bridge process; +//! it doesn't hold the kernel. The bridge forwards each MCP `tools/call` +//! over a unix-domain socket back here, where we dispatch into +//! `openfang_runtime::tool_runner::execute_tool` and ship the result back. +//! +//! ## Status — ANAI-30 step 1 +//! +//! This module currently: +//! - Listens on `/run/bridge.sock`. +//! - Accepts the protocol [`Hello`](openfang_mcp_bridge::protocol::Hello) +//! handshake (any non-empty token; real auth in ANAI-31). +//! - Decodes [`CallRequest`](openfang_mcp_bridge::protocol::CallRequest) +//! frames and returns a stub `Error { message: "dispatch not yet wired" }` +//! response. **Step 2** of the ANAI-30 plan replaces this with a real +//! call into `tool_runner::execute_tool`, scoped to the four tools +//! `file_read`, `file_list`, `agent_list`, `channel_send`. +//! +//! Keeping step 1 a clean stub makes the wire shape independently testable +//! before we tangle in execute_tool's 17-argument context bundle. + +use openfang_kernel::OpenFangKernel; +use openfang_mcp_bridge::protocol::{ + CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_RELATIVE_PATH, + codec, +}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Notify; +use tracing::{debug, error, info, warn}; + +/// Daemon-version string sent in [`HelloAck::Ok`]. +fn daemon_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Resolve the bridge socket path under `home_dir`. Ensures the parent +/// directory exists. +pub fn socket_path(home_dir: &std::path::Path) -> std::io::Result { + let path = home_dir.join(SOCKET_RELATIVE_PATH); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(path) +} + +/// Handle to a running bridge IPC server. Drop / call [`BridgeIpcServer::shutdown`] +/// to stop accepting connections and remove the socket file. +pub struct BridgeIpcServer { + socket_path: PathBuf, + shutdown: Arc, +} + +impl BridgeIpcServer { + /// Start the IPC listener. Returns once the socket is bound; the accept + /// loop runs in a detached tokio task until shutdown is signaled. + pub async fn start(kernel: Arc) -> std::io::Result { + let socket_path = socket_path(&kernel.config.home_dir)?; + + // Remove any stale socket from a prior unclean shutdown. UnixListener + // refuses to bind if the path exists, even if no one's listening. + if socket_path.exists() { + warn!(path = %socket_path.display(), "removing stale bridge socket"); + let _ = std::fs::remove_file(&socket_path); + } + + let listener = UnixListener::bind(&socket_path)?; + // Restrict to user-only — the socket is loopback to ourselves; no + // reason for any other uid to connect. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&socket_path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(&socket_path, perms); + } + } + + info!(path = %socket_path.display(), "bridge IPC server listening"); + + let shutdown = Arc::new(Notify::new()); + let accept_shutdown = shutdown.clone(); + let _accept_kernel = kernel.clone(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = accept_shutdown.notified() => { + debug!("bridge IPC: accept loop shutting down"); + break; + } + res = listener.accept() => { + match res { + Ok((stream, _addr)) => { + let conn_kernel = _accept_kernel.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, conn_kernel).await { + debug!(error = %e, "bridge IPC connection ended with error"); + } + }); + } + Err(e) => { + error!(error = %e, "bridge IPC accept failed"); + // Brief backoff to avoid spinning on a persistent error. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + } + } + }); + + Ok(Self { + socket_path, + shutdown, + }) + } + + /// Signal the accept loop to stop and remove the socket file. + pub fn shutdown(&self) { + self.shutdown.notify_waiters(); + if self.socket_path.exists() { + let _ = std::fs::remove_file(&self.socket_path); + } + } +} + +impl Drop for BridgeIpcServer { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Handle a single bridge connection: Hello/HelloAck handshake, then a loop +/// of CallRequest → CallResponse frames until the peer closes. +async fn handle_connection( + mut stream: UnixStream, + _kernel: Arc, +) -> std::io::Result<()> { + let (read_half, mut write_half) = stream.split(); + let mut read_half = tokio::io::BufReader::new(read_half); + + // --- Handshake --- + let hello = match codec::read_frame(&mut read_half).await? { + Frame::Hello(h) => h, + other => { + warn!(?other, "bridge IPC: first frame was not Hello, closing"); + return Ok(()); + } + }; + + if let Err(reason) = validate_hello(&hello) { + let ack = Frame::HelloAck(HelloAck::Rejected { + reason: reason.clone(), + }); + let _ = codec::write_frame(&mut write_half, &ack).await; + warn!(reason, "bridge IPC: rejected handshake"); + return Ok(()); + } + + let ack = Frame::HelloAck(HelloAck::Ok { + daemon_version: daemon_version(), + }); + codec::write_frame(&mut write_half, &ack).await?; + debug!( + bridge_version = %hello.bridge_version, + "bridge IPC: handshake complete" + ); + + // --- Request loop --- + loop { + let frame = match codec::read_frame(&mut read_half).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + debug!("bridge IPC: peer closed"); + return Ok(()); + } + Err(e) => return Err(e), + }; + + let call = match frame { + Frame::Call(c) => c, + other => { + warn!(?other, "bridge IPC: unexpected frame in request loop"); + continue; + } + }; + + debug!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %call.agent_id, + "bridge IPC: received call (step-1 stub will not dispatch)" + ); + + // ANAI-30 step 1: handler is stubbed. Step 2 wires this to + // openfang_runtime::tool_runner::execute_tool with the four-tool + // allowlist (file_read, file_list, agent_list, channel_send). + let response = Frame::Response(CallResponse { + request_id: call.request_id, + result: CallResult::Error { + message: format!( + "tool dispatch not yet wired in daemon (ANAI-30 step 1 stub); requested tool='{}'", + call.tool_name + ), + }, + }); + codec::write_frame(&mut write_half, &response).await?; + } +} + +/// Validate the bridge's Hello. Returns Err with a human-readable reason on +/// rejection. **Stub for ANAI-30**: we accept any non-empty token. ANAI-31 +/// will replace this with a per-spawn token table populated when the daemon +/// spawns the parent CC subprocess. +fn validate_hello(hello: &Hello) -> Result<(), String> { + if hello.protocol_version != PROTOCOL_VERSION { + return Err(format!( + "protocol version mismatch: bridge={} daemon={}", + hello.protocol_version, PROTOCOL_VERSION + )); + } + if hello.token.trim().is_empty() { + return Err("empty auth token".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openfang_mcp_bridge::protocol::{CallRequest, CallResult}; + use tokio::io::BufReader; + use tokio::net::UnixStream as ClientStream; + + /// End-to-end round trip test: bind a listener at a tempfile path, + /// connect, do the handshake, send a CallRequest, expect the step-1 + /// stub error response. + /// + /// We don't go through `BridgeIpcServer::start` here because that needs + /// a full `OpenFangKernel`. Instead we exercise `handle_connection` + /// directly by spawning the accept loop manually. + #[tokio::test] + async fn ipc_handshake_and_stub_dispatch() { + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + // We can't synthesize a real OpenFangKernel here; the connection + // handler doesn't actually dereference it in step 1 (kernel use + // lands in step 2 alongside execute_tool wiring). Build a minimal + // proxy by inlining the relevant logic. + handle_connection_no_kernel(stream).await.unwrap(); + }); + + let mut client = ClientStream::connect(&sock).await.unwrap(); + let (cr, mut cw) = client.split(); + let mut cr = BufReader::new(cr); + + // Send Hello + let hello = Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: "stub-token".into(), + bridge_version: "test".into(), + }); + codec::write_frame(&mut cw, &hello).await.unwrap(); + + // Receive HelloAck + match codec::read_frame(&mut cr).await.unwrap() { + Frame::HelloAck(HelloAck::Ok { .. }) => {} + other => panic!("expected HelloAck::Ok, got {other:?}"), + } + + // Send a Call + let call = Frame::Call(CallRequest { + request_id: 1, + agent_id: "test-agent".into(), + tool_name: "file_read".into(), + args: serde_json::json!({"path": "x"}), + }); + codec::write_frame(&mut cw, &call).await.unwrap(); + + // Receive stub Response + match codec::read_frame(&mut cr).await.unwrap() { + Frame::Response(CallResponse { + request_id: 1, + result: CallResult::Error { message }, + }) => { + assert!(message.contains("not yet wired")); + } + other => panic!("unexpected response: {other:?}"), + } + + drop(client); + server.await.unwrap(); + } + + /// Test-only twin of [`handle_connection`] that doesn't take a kernel. + /// Kept in lockstep with the real handler's wire behavior; if the + /// production handler diverges, update this too. + async fn handle_connection_no_kernel(mut stream: UnixStream) -> std::io::Result<()> { + let (read_half, mut write_half) = stream.split(); + let mut read_half = BufReader::new(read_half); + + let hello = match codec::read_frame(&mut read_half).await? { + Frame::Hello(h) => h, + _ => return Ok(()), + }; + if let Err(reason) = validate_hello(&hello) { + let _ = codec::write_frame( + &mut write_half, + &Frame::HelloAck(HelloAck::Rejected { reason }), + ) + .await; + return Ok(()); + } + codec::write_frame( + &mut write_half, + &Frame::HelloAck(HelloAck::Ok { + daemon_version: daemon_version(), + }), + ) + .await?; + + loop { + let frame = match codec::read_frame(&mut read_half).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + }; + let call = match frame { + Frame::Call(c) => c, + _ => continue, + }; + let response = Frame::Response(CallResponse { + request_id: call.request_id, + result: CallResult::Error { + message: format!( + "tool dispatch not yet wired in daemon (ANAI-30 step 1 stub); requested tool='{}'", + call.tool_name + ), + }, + }); + codec::write_frame(&mut write_half, &response).await?; + } + } + + #[test] + fn validate_hello_rejects_version_mismatch() { + let h = Hello { + protocol_version: 999, + token: "x".into(), + bridge_version: "t".into(), + }; + assert!(validate_hello(&h).is_err()); + } + + #[test] + fn validate_hello_rejects_empty_token() { + let h = Hello { + protocol_version: PROTOCOL_VERSION, + token: "".into(), + bridge_version: "t".into(), + }; + assert!(validate_hello(&h).is_err()); + } + + #[test] + fn validate_hello_accepts_nonempty_token() { + let h = Hello { + protocol_version: PROTOCOL_VERSION, + token: "tok".into(), + bridge_version: "t".into(), + }; + assert!(validate_hello(&h).is_ok()); + } +} diff --git a/crates/openfang-api/src/lib.rs b/crates/openfang-api/src/lib.rs index 243da788b2..a041e8a420 100644 --- a/crates/openfang-api/src/lib.rs +++ b/crates/openfang-api/src/lib.rs @@ -32,6 +32,7 @@ fn hex_val(b: u8) -> Option { } } +pub mod bridge_ipc; pub mod channel_bridge; pub mod middleware; pub mod openai_compat; diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a1a2bc9c06..2f441c2553 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -15,7 +15,7 @@ use std::time::Instant; use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; -use tracing::info; +use tracing::{info, warn}; /// Daemon info written to `~/.openfang/daemon.json` so the CLI can find us. #[derive(serde::Serialize, serde::Deserialize)] @@ -844,6 +844,18 @@ pub async fn run_daemon( let (app, state) = build_router(kernel.clone(), addr).await; + // Start the MCP bridge IPC listener (ANAI-30). Failure here is + // non-fatal — the daemon can still serve HTTP without bridge support; + // we just log and skip. The handle is held for the lifetime of the + // daemon and dropped on shutdown to remove the socket. + let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start(kernel.clone()).await { + Ok(h) => Some(h), + Err(e) => { + warn!(error = %e, "bridge IPC listener failed to start; MCP bridge unavailable"); + None + } + }; + // Write daemon info file if let Some(info_path) = daemon_info_path { // Check if another daemon is already running with this PID file diff --git a/crates/openfang-mcp-bridge/Cargo.toml b/crates/openfang-mcp-bridge/Cargo.toml index 9b79ad2787..a4c31dc002 100644 --- a/crates/openfang-mcp-bridge/Cargo.toml +++ b/crates/openfang-mcp-bridge/Cargo.toml @@ -36,3 +36,10 @@ rmcp = { version = "1", features = ["server", "transport-io"] } [dev-dependencies] tokio = { workspace = true } + +[features] +default = ["ipc-codec"] +# Enables async length-prefixed framing helpers in `protocol::codec`. Pulled +# in by both the daemon-side IPC server and the bridge binary; consumers that +# only need the protocol type definitions can disable it. +ipc-codec = [] diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index da51688265..798f7842bf 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -29,6 +29,8 @@ //! Real tool surface mapping lands in ANAI-30. Identity threading lands in //! ANAI-31. See ANAI-22 for the umbrella tracker. +pub mod protocol; + use std::sync::Arc; use rmcp::{ diff --git a/crates/openfang-mcp-bridge/src/protocol.rs b/crates/openfang-mcp-bridge/src/protocol.rs new file mode 100644 index 0000000000..ed106ce6c8 --- /dev/null +++ b/crates/openfang-mcp-bridge/src/protocol.rs @@ -0,0 +1,227 @@ +//! Wire protocol for daemon ↔ bridge IPC. +//! +//! The bridge runs as a grandchild of the daemon (daemon → claude → bridge). +//! Tools that need kernel access (`agent_list`, `channel_send`, etc.) cannot +//! be served from the bridge process directly — it doesn't hold a +//! [`KernelHandle`]. Instead the bridge forwards each call over a unix-domain +//! socket back to the daemon, which dispatches into +//! `openfang_runtime::tool_runner::execute_tool` and ships the result back. +//! +//! This module defines the wire shape of that exchange. It is intentionally +//! the *only* surface shared between the bridge crate and the daemon — both +//! sides depend on these types and nothing else. +//! +//! ## Framing +//! +//! Each message is a 4-byte big-endian length prefix followed by that many +//! bytes of UTF-8 JSON. No nested length fields, no streaming. Messages are +//! capped at [`MAX_FRAME_BYTES`] to bound memory; oversized frames are an +//! error and the connection is closed. +//! +//! ## Versioning +//! +//! [`PROTOCOL_VERSION`] is sent in the [`Hello`] message at connection start. +//! Mismatches close the connection. The protocol is private to OpenFang — +//! versioning here is for our own evolution, not external compatibility. + +use serde::{Deserialize, Serialize}; + +/// Wire protocol version. Bumped on incompatible changes. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Maximum size of a single framed message, in bytes (1 MiB). +/// +/// Tool results that exceed this are truncated by the daemon before framing. +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; + +/// Default unix socket path, relative to the OpenFang home directory. +/// +/// Resolved at runtime as `/run/bridge.sock`. +pub const SOCKET_RELATIVE_PATH: &str = "run/bridge.sock"; + +/// Environment variable name used to pass the socket path from daemon to bridge. +pub const SOCKET_ENV_VAR: &str = "OPENFANG_BRIDGE_SOCKET"; + +/// Environment variable name used to pass the per-spawn auth token from +/// daemon to bridge. (Identity binding lands in ANAI-31; for ANAI-30 the +/// agent_id is sent in-band in [`CallRequest`] as a stub.) +pub const TOKEN_ENV_VAR: &str = "OPENFANG_BRIDGE_TOKEN"; + +/// Bridge → daemon: opening message on a fresh connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hello { + pub protocol_version: u32, + /// Per-spawn auth token. Validated by daemon against an in-memory map + /// populated when the daemon spawned the parent CC subprocess. Stubbed + /// for ANAI-30 — daemon currently accepts any non-empty token and + /// expects [`CallRequest::agent_id`] to identify the caller. + pub token: String, + /// Bridge build version, for debug/audit. + pub bridge_version: String, +} + +/// Daemon → bridge: response to [`Hello`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HelloAck { + Ok { + daemon_version: String, + }, + /// Connection rejected. Bridge should log and exit. + Rejected { + reason: String, + }, +} + +/// Bridge → daemon: a single tool call request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallRequest { + /// Caller-assigned correlation id. Daemon echoes in [`CallResponse::request_id`]. + pub request_id: u64, + /// Identity of the caller agent (the parent that spawned this CC subprocess). + /// + /// **Stub for ANAI-30.** ANAI-31 replaces this with token-derived identity + /// validated server-side; do not rely on this field for security. + pub agent_id: String, + pub tool_name: String, + pub args: serde_json::Value, +} + +/// Daemon → bridge: response to a [`CallRequest`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallResponse { + pub request_id: u64, + pub result: CallResult, +} + +/// Outcome of a tool dispatch. Maps directly onto MCP's `CallToolResult` +/// shape (text content + `isError` flag) so the bridge can forward without +/// further translation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CallResult { + /// Tool executed; `is_error` follows OpenFang's `ToolResult` semantics + /// (true ⇒ tool ran but reported an error to the LLM). + Ok { content: String, is_error: bool }, + /// Tool dispatch failed at the protocol layer (unknown tool, not + /// permitted, malformed args, internal panic). Distinct from `Ok { is_error: true }`, + /// which means the tool itself returned an error result. + Error { message: String }, +} + +/// Top-level frame type, for connections that may carry multiple message kinds. +/// +/// At present we only multiplex Hello/HelloAck on connection start and +/// CallRequest/CallResponse thereafter. A single enum keeps the framing +/// uniform and leaves room for future message types (e.g. cancel, ping) +/// without renegotiating the wire shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Frame { + Hello(Hello), + HelloAck(HelloAck), + Call(CallRequest), + Response(CallResponse), +} + +#[cfg(feature = "ipc-codec")] +pub mod codec { + //! Async length-prefixed framing helpers. Gated behind the `ipc-codec` + //! feature so the bare protocol types stay usable in `no-tokio` contexts + //! (tests, type-only consumers). + + use super::{Frame, MAX_FRAME_BYTES}; + use std::io; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// Read one length-prefixed JSON frame from `r`. + pub async fn read_frame(r: &mut R) -> io::Result { + let len = r.read_u32().await? as usize; + if len == 0 || len > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame size {len} out of bounds (max {MAX_FRAME_BYTES})"), + )); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf).await?; + serde_json::from_slice(&buf) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}"))) + } + + /// Write one length-prefixed JSON frame to `w`. + pub async fn write_frame( + w: &mut W, + frame: &Frame, + ) -> io::Result<()> { + let bytes = serde_json::to_vec(frame) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("encode: {e}")))?; + if bytes.len() > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "frame size {} exceeds MAX_FRAME_BYTES {}", + bytes.len(), + MAX_FRAME_BYTES + ), + )); + } + w.write_u32(bytes.len() as u32).await?; + w.write_all(&bytes).await?; + w.flush().await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_roundtrip_call() { + let frame = Frame::Call(CallRequest { + request_id: 42, + agent_id: "coder-openfang".to_string(), + tool_name: "file_read".to_string(), + args: serde_json::json!({ "path": "Cargo.toml" }), + }); + let json = serde_json::to_string(&frame).unwrap(); + let back: Frame = serde_json::from_str(&json).unwrap(); + match back { + Frame::Call(c) => { + assert_eq!(c.request_id, 42); + assert_eq!(c.tool_name, "file_read"); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn frame_roundtrip_response_ok() { + let frame = Frame::Response(CallResponse { + request_id: 7, + result: CallResult::Ok { + content: "hello".into(), + is_error: false, + }, + }); + let s = serde_json::to_string(&frame).unwrap(); + assert!(s.contains("\"ok\"")); + let back: Frame = serde_json::from_str(&s).unwrap(); + if let Frame::Response(r) = back { + assert_eq!(r.request_id, 7); + } else { + panic!("wrong variant"); + } + } + + #[test] + fn hello_ack_rejected_serializes() { + let f = Frame::HelloAck(HelloAck::Rejected { + reason: "bad token".into(), + }); + let s = serde_json::to_string(&f).unwrap(); + assert!(s.contains("rejected")); + assert!(s.contains("bad token")); + } +} From 83c80a15d7299f668dc340dc0b4f74340de112ef Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 20:06:49 -0700 Subject: [PATCH 003/105] ANAI-30 step 2: wire bridge IPC dispatch to execute_tool Replaces the step-1 stub in `BridgeIpcServer` with a real call into `openfang_runtime::tool_runner::execute_tool`, mirroring the argument bundle used by the HTTP /mcp endpoint in routes.rs. - Added ALLOWED_TOOLS allowlist: file_read, file_list, agent_list, channel_send. Rejection happens at the protocol layer (CallResult::Error) before any kernel touch. - Added dispatch_call(): snapshots the skill registry, builds a KernelHandle from Arc, and invokes execute_tool. - ToolResult mapped to CallResult::Ok { content, is_error }, preserving the Ok/Error distinction (Error = bridge couldn't dispatch; Ok with is_error = tool ran but returned an error). - Identity stub: caller_agent_id taken at face value from CallRequest::agent_id. Real per-spawn token-bound identity lands in ANAI-31. Test: ipc_handshake_and_allowlist_gate verifies wire shape end-to-end: disallowed tool gets allowlist Error, allowed tool gets Ok response. Real execute_tool integration tests come once the daemon spawns the bridge for real (ANAI-31). --- crates/openfang-api/src/bridge_ipc.rs | 267 ++++++++++++++++++++------ 1 file changed, 208 insertions(+), 59 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 40ea2e54d9..26cccac98f 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -17,25 +17,30 @@ //! over a unix-domain socket back here, where we dispatch into //! `openfang_runtime::tool_runner::execute_tool` and ship the result back. //! -//! ## Status — ANAI-30 step 1 +//! ## Status — ANAI-30 step 2 //! //! This module currently: //! - Listens on `/run/bridge.sock`. //! - Accepts the protocol [`Hello`](openfang_mcp_bridge::protocol::Hello) //! handshake (any non-empty token; real auth in ANAI-31). //! - Decodes [`CallRequest`](openfang_mcp_bridge::protocol::CallRequest) -//! frames and returns a stub `Error { message: "dispatch not yet wired" }` -//! response. **Step 2** of the ANAI-30 plan replaces this with a real -//! call into `tool_runner::execute_tool`, scoped to the four tools -//! `file_read`, `file_list`, `agent_list`, `channel_send`. +//! frames, enforces the four-tool allowlist +//! ([`ALLOWED_TOOLS`]: `file_read`, `file_list`, `agent_list`, +//! `channel_send`), and dispatches into +//! [`openfang_runtime::tool_runner::execute_tool`] with the kernel-bound +//! context bundle. The shape mirrors the HTTP `/mcp` endpoint in +//! `routes.rs` so the two execution paths stay in lockstep. //! -//! Keeping step 1 a clean stub makes the wire shape independently testable -//! before we tangle in execute_tool's 17-argument context bundle. +//! Identity (`caller_agent_id`) is currently taken at face value from the +//! [`CallRequest::agent_id`] field. ANAI-31 replaces this with +//! token-derived identity bound at daemon-spawn time. Per-agent +//! capability gating (replacing the static [`ALLOWED_TOOLS`] allowlist +//! with `agent.toml` lookups) lands in the same ticket. use openfang_kernel::OpenFangKernel; use openfang_mcp_bridge::protocol::{ - CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_RELATIVE_PATH, - codec, + CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, + SOCKET_RELATIVE_PATH, codec, }; use std::path::PathBuf; use std::sync::Arc; @@ -43,6 +48,20 @@ use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Notify; use tracing::{debug, error, info, warn}; +/// Tools the bridge IPC server is willing to dispatch in the ANAI-30 +/// validation slice. Anything outside this set is rejected at the +/// protocol layer (i.e. it never reaches `execute_tool`). ANAI-31 will +/// replace this static allowlist with per-agent capability lookups +/// driven by `agent.toml`. +/// +/// The chosen four exercise the full diversity of tool dependencies: +/// - `file_read` / `file_list` — workspace-scoped, no kernel needed +/// - `agent_list` — requires [`KernelHandle::list_agents`] +/// - `channel_send` — requires [`KernelHandle::send_channel_message`], +/// one of the OpenFang-only capabilities a CC subprocess wouldn't +/// otherwise have +pub const ALLOWED_TOOLS: &[&str] = &["file_read", "file_list", "agent_list", "channel_send"]; + /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { env!("CARGO_PKG_VERSION").to_string() @@ -150,7 +169,7 @@ impl Drop for BridgeIpcServer { /// of CallRequest → CallResponse frames until the peer closes. async fn handle_connection( mut stream: UnixStream, - _kernel: Arc, + kernel: Arc, ) -> std::io::Result<()> { let (read_half, mut write_half) = stream.split(); let mut read_half = tokio::io::BufReader::new(read_half); @@ -205,25 +224,101 @@ async fn handle_connection( request_id = call.request_id, tool = %call.tool_name, agent = %call.agent_id, - "bridge IPC: received call (step-1 stub will not dispatch)" + "bridge IPC: dispatching call" ); - // ANAI-30 step 1: handler is stubbed. Step 2 wires this to - // openfang_runtime::tool_runner::execute_tool with the four-tool - // allowlist (file_read, file_list, agent_list, channel_send). + let result = dispatch_call(&call, &kernel).await; let response = Frame::Response(CallResponse { request_id: call.request_id, - result: CallResult::Error { - message: format!( - "tool dispatch not yet wired in daemon (ANAI-30 step 1 stub); requested tool='{}'", - call.tool_name - ), - }, + result, }); codec::write_frame(&mut write_half, &response).await?; } } +/// Dispatch a single bridge tool call to the runtime. +/// +/// Enforces the [`ALLOWED_TOOLS`] allowlist before invoking +/// [`openfang_runtime::tool_runner::execute_tool`]. The argument bundle +/// mirrors the HTTP `/mcp` endpoint in `routes.rs` — keep them in sync; +/// they share semantics intentionally. +/// +/// Returns: +/// - [`CallResult::Error`] for protocol-layer rejections (unknown tool, +/// not on the allowlist). +/// - [`CallResult::Ok`] for anything `execute_tool` returned, with +/// `is_error` propagated. A tool that ran but returned an error to +/// the model is `Ok { is_error: true }`, **not** `Error` — the latter +/// means the bridge couldn't even attempt dispatch. +async fn dispatch_call(call: &CallRequest, kernel: &Arc) -> CallResult { + if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { + return CallResult::Error { + message: format!( + "tool '{}' not in bridge allowlist (permitted: {:?})", + call.tool_name, ALLOWED_TOOLS + ), + }; + } + + // Snapshot the skill registry before crossing the await — its read + // guard is !Send and execute_tool spans `.await` points internally. + let skill_snapshot = kernel + .skill_registry + .read() + .unwrap_or_else(|e| e.into_inner()) + .snapshot(); + + // Build the kernel handle. Cloning the Arc is cheap; the cast to + // `dyn KernelHandle` is the same upcast the HTTP /mcp endpoint + // performs. + let kernel_handle: Arc = + kernel.clone() as Arc; + + // execute_tool also enforces an allowlist via its `allowed_tools` + // parameter; passing our four-tool set makes the runtime's check + // belt-and-suspenders with ours. If the two ever drift, the runtime's + // is authoritative — it sits closer to the actual tool implementations. + let allowed_tools_owned: Vec = + ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); + + let result = openfang_runtime::tool_runner::execute_tool( + &format!("bridge-{}", call.request_id), + &call.tool_name, + &call.args, + Some(&kernel_handle), + Some(&allowed_tools_owned), + // Identity stub for ANAI-30: trust the bridge's claimed agent_id. + // ANAI-31 replaces this with token-derived identity bound at + // daemon-spawn time. + Some(call.agent_id.as_str()), + Some(&skill_snapshot), + Some(&kernel.mcp_connections), + Some(&kernel.web_ctx), + Some(&kernel.browser_ctx), + None, // allowed_env_vars — unused by the four allowlisted tools + None, // workspace_root — file_read/file_list use input-relative paths + Some(&kernel.media_engine), + None, // exec_policy — shell tools not in allowlist + if kernel.config.tts.enabled { + Some(&kernel.tts_engine) + } else { + None + }, + if kernel.config.docker.enabled { + Some(&kernel.config.docker) + } else { + None + }, + Some(&*kernel.process_manager), + ) + .await; + + CallResult::Ok { + content: result.content, + is_error: result.is_error, + } +} + /// Validate the bridge's Hello. Returns Err with a human-readable reason on /// rejection. **Stub for ANAI-30**: we accept any non-empty token. ANAI-31 /// will replace this with a per-spawn token table populated when the daemon @@ -248,74 +343,112 @@ mod tests { use tokio::io::BufReader; use tokio::net::UnixStream as ClientStream; - /// End-to-end round trip test: bind a listener at a tempfile path, - /// connect, do the handshake, send a CallRequest, expect the step-1 - /// stub error response. + /// End-to-end wire-shape test: bind a listener at a tempfile path, + /// connect, do the handshake, send two CallRequests: + /// 1. A non-allowlisted tool — expect `CallResult::Error` from the + /// step-2 allowlist check. + /// 2. An allowlisted tool — expect a canned `CallResult::Ok` from + /// the test twin (the real handler would dispatch into + /// `execute_tool`; we can't synthesize an `OpenFangKernel` here). + /// + /// What this test guarantees: + /// - The Hello/HelloAck handshake stays correct. + /// - The allowlist gate fires *before* dispatch (no kernel touched). + /// - The wire framing for `CallResponse::Ok` and `CallResponse::Error` + /// round-trips cleanly. /// - /// We don't go through `BridgeIpcServer::start` here because that needs - /// a full `OpenFangKernel`. Instead we exercise `handle_connection` - /// directly by spawning the accept loop manually. + /// What this test does NOT cover (intentionally — needs a real kernel): + /// - That `execute_tool` is invoked with the right argument bundle. + /// - That tool results are correctly mapped to `CallResult::Ok`. + /// Those land as integration tests once the daemon side spawns the + /// bridge for real (ANAI-31). #[tokio::test] - async fn ipc_handshake_and_stub_dispatch() { + async fn ipc_handshake_and_allowlist_gate() { let tmp = tempfile::tempdir().unwrap(); let sock = tmp.path().join("bridge.sock"); let listener = UnixListener::bind(&sock).unwrap(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - // We can't synthesize a real OpenFangKernel here; the connection - // handler doesn't actually dereference it in step 1 (kernel use - // lands in step 2 alongside execute_tool wiring). Build a minimal - // proxy by inlining the relevant logic. - handle_connection_no_kernel(stream).await.unwrap(); + handle_connection_test_twin(stream).await.unwrap(); }); let mut client = ClientStream::connect(&sock).await.unwrap(); let (cr, mut cw) = client.split(); let mut cr = BufReader::new(cr); - // Send Hello + // Handshake. let hello = Frame::Hello(Hello { protocol_version: PROTOCOL_VERSION, token: "stub-token".into(), bridge_version: "test".into(), }); codec::write_frame(&mut cw, &hello).await.unwrap(); - - // Receive HelloAck match codec::read_frame(&mut cr).await.unwrap() { Frame::HelloAck(HelloAck::Ok { .. }) => {} other => panic!("expected HelloAck::Ok, got {other:?}"), } - // Send a Call - let call = Frame::Call(CallRequest { - request_id: 1, - agent_id: "test-agent".into(), - tool_name: "file_read".into(), - args: serde_json::json!({"path": "x"}), - }); - codec::write_frame(&mut cw, &call).await.unwrap(); - - // Receive stub Response + // 1. Non-allowlisted tool → allowlist Error. + codec::write_frame( + &mut cw, + &Frame::Call(CallRequest { + request_id: 1, + agent_id: "test-agent".into(), + tool_name: "shell_exec".into(), // deliberately not on the list + args: serde_json::json!({"cmd": "rm -rf /"}), + }), + ) + .await + .unwrap(); match codec::read_frame(&mut cr).await.unwrap() { Frame::Response(CallResponse { request_id: 1, result: CallResult::Error { message }, }) => { - assert!(message.contains("not yet wired")); + assert!( + message.contains("not in bridge allowlist"), + "expected allowlist rejection, got: {message}" + ); + } + other => panic!("unexpected response to disallowed tool: {other:?}"), + } + + // 2. Allowlisted tool → twin returns canned Ok. + codec::write_frame( + &mut cw, + &Frame::Call(CallRequest { + request_id: 2, + agent_id: "test-agent".into(), + tool_name: "file_read".into(), + args: serde_json::json!({"path": "x"}), + }), + ) + .await + .unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::Response(CallResponse { + request_id: 2, + result: CallResult::Ok { is_error, .. }, + }) => { + // Twin canned response is a non-error Ok; the real handler + // would set `is_error` from `execute_tool`'s ToolResult. + assert!(!is_error); } - other => panic!("unexpected response: {other:?}"), + other => panic!("unexpected response to allowed tool: {other:?}"), } drop(client); server.await.unwrap(); } - /// Test-only twin of [`handle_connection`] that doesn't take a kernel. - /// Kept in lockstep with the real handler's wire behavior; if the - /// production handler diverges, update this too. - async fn handle_connection_no_kernel(mut stream: UnixStream) -> std::io::Result<()> { + /// Test-only twin of [`handle_connection`]. + /// + /// Mirrors the production handler's *wire* behavior (handshake + + /// request loop + allowlist gate) but stubs the runtime dispatch + /// because we can't synthesize an `OpenFangKernel` in unit tests. + /// If the production handler's wire shape diverges, update this twin. + async fn handle_connection_test_twin(mut stream: UnixStream) -> std::io::Result<()> { let (read_half, mut write_half) = stream.split(); let mut read_half = BufReader::new(read_half); @@ -349,16 +482,32 @@ mod tests { Frame::Call(c) => c, _ => continue, }; - let response = Frame::Response(CallResponse { - request_id: call.request_id, - result: CallResult::Error { + + // Mirror production allowlist logic. + let result = if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { + CallResult::Error { message: format!( - "tool dispatch not yet wired in daemon (ANAI-30 step 1 stub); requested tool='{}'", - call.tool_name + "tool '{}' not in bridge allowlist (permitted: {:?})", + call.tool_name, ALLOWED_TOOLS ), - }, - }); - codec::write_frame(&mut write_half, &response).await?; + } + } else { + // Canned Ok stand-in for `execute_tool` — kernel-free tests + // can't exercise the real dispatch path. + CallResult::Ok { + content: format!("[test-twin canned ok for {}]", call.tool_name), + is_error: false, + } + }; + + codec::write_frame( + &mut write_half, + &Frame::Response(CallResponse { + request_id: call.request_id, + result, + }), + ) + .await?; } } From 4dfaf2fe780241cce9628e7087166227c37cfc7a Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 20:22:53 -0700 Subject: [PATCH 004/105] ANAI-30 step 3: bridge-side RuntimeDispatcher (IPC client) + real tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stub `ping` tool with the four ANAI-30 tools (file_read, file_list, agent_list, channel_send) and wires the bridge binary to forward each `tools/call` over the daemon IPC socket established in step 1. Library (lib.rs): - ToolDispatcher::call now returns DispatchOk { content, is_error } preserving the tool-error-vs-dispatch-error distinction across the seam - built_in_tools() declares the four-tool slice; schemas mirror runtime::tool_runner::builtin_tool_definitions() (kept in lockstep) - Bridge: manual ServerHandler impl (drops the #[tool_router] macro). Filters advertised tools by intersecting built_in_tools() with ToolDispatcher::allowed_tools(); double-checks before dispatch - Bridge::new now requires a dispatcher (was Option<_>) Binary (main.rs): - Reads OPENFANG_BRIDGE_SOCKET / TOKEN / AGENT_ID env vars (last is stub for ANAI-30; ANAI-31 derives identity from token) - Connects to daemon, performs Hello/HelloAck handshake, exits on rejection - IpcDispatcher: bridge-side ToolDispatcher impl. Forwards each call via mpsc to an actor task that owns the stream; correlation-by-request_id with a PendingMap so concurrent tools/call invocations don't serialize at the dispatcher layer - Reader task drains pending oneshots with an error on connection close so in-flight calls don't hang; production path exits the process so CC notices and tears down (gated behind cfg(not(test))) Tests: - lib: built_in_tools_has_anai30_slice, permitted_tools_intersects_with_dispatcher_allowed - main: ipc_dispatcher_round_trip_and_correlation — fake daemon listener, full handshake, two concurrent calls, verifies per-id correlation and the NotPermitted gate Workspace check clean. Daemon-side bridge_ipc tests still pass (4/4). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + crates/openfang-mcp-bridge/Cargo.toml | 1 + crates/openfang-mcp-bridge/src/lib.rs | 293 ++++++++++++++--- crates/openfang-mcp-bridge/src/main.rs | 418 ++++++++++++++++++++++++- 4 files changed, 657 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8030ac9dbb..b287d62f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4190,6 +4190,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/openfang-mcp-bridge/Cargo.toml b/crates/openfang-mcp-bridge/Cargo.toml index a4c31dc002..d7a0b38c0f 100644 --- a/crates/openfang-mcp-bridge/Cargo.toml +++ b/crates/openfang-mcp-bridge/Cargo.toml @@ -36,6 +36,7 @@ rmcp = { version = "1", features = ["server", "transport-io"] } [dev-dependencies] tokio = { workspace = true } +tempfile = { workspace = true } [features] default = ["ipc-codec"] diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 798f7842bf..ce2ae05d34 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -15,19 +15,25 @@ //! `openfang-memory` directly. The bridge consumes a narrow //! [`ToolDispatcher`] trait that the runtime exposes; the runtime owns //! identity, the kernel owns dispatch, the memory subsystem stays untouched. -//! - It does NOT define OpenFang's tool surface. Tools are declared by their -//! owning crates and registered through the dispatcher. +//! - It does NOT define OpenFang's tool surface beyond a small built-in slice +//! (see [`built_in_tools`]). The schemas declared here mirror +//! `openfang_runtime::tool_runner::builtin_tool_definitions()` for the +//! four ANAI-30 allowlisted tools — kept in lockstep deliberately. //! //! ## Project status //! -//! Spike scaffold (ANAI-29). At this stage the bridge: -//! - Compiles as a standalone crate in the workspace. -//! - Ships a stdio binary (`openfang-mcp-bridge`) that responds to MCP -//! `initialize` and `tools/list` with a single stub `ping` tool. -//! - Defines the [`ToolDispatcher`] seam trait (unused by the stub). +//! ANAI-30 step 3. The bridge now: +//! - Defines the [`ToolDispatcher`] seam trait — the runtime (or, in the real +//! topology, the daemon-bound IPC client) implements it. +//! - Registers the four-tool ANAI-30 surface (`file_read`, `file_list`, +//! `agent_list`, `channel_send`) and translates `tools/call` into +//! [`ToolDispatcher::call`]. +//! - Filters its advertised tool list against [`ToolDispatcher::allowed_tools`] +//! so an agent never sees tools its capabilities don't permit. //! -//! Real tool surface mapping lands in ANAI-30. Identity threading lands in -//! ANAI-31. See ANAI-22 for the umbrella tracker. +//! Identity is bound at construction time. The IPC client implementation +//! lives in `main.rs`. ANAI-31 will replace the in-band-agent-id stub with +//! token-derived identity. pub mod protocol; @@ -35,15 +41,15 @@ use std::sync::Arc; use rmcp::{ ErrorData as McpError, ServerHandler, - handler::server::router::tool::ToolRouter, model::*, - tool, tool_handler, tool_router, + service::RequestContext, }; /// Narrow seam between the bridge and the OpenFang runtime. /// -/// The runtime implements this trait and hands an `Arc` to -/// the bridge at startup, scoped to a specific agent identity. The bridge +/// The runtime (or, in the real topology, an IPC-backed adapter that talks +/// to the daemon) implements this trait and hands an `Arc` +/// to the bridge at startup, scoped to a specific agent identity. The bridge /// translates incoming MCP `tools/call` requests into [`ToolDispatcher::call`] /// invocations. /// @@ -59,6 +65,9 @@ pub trait ToolDispatcher: Send + Sync { /// List of tool names this dispatcher will accept, derived from /// `agent.toml` capabilities. The bridge filters its advertised tool list /// against this set. + /// + /// For ANAI-30 this is the static four-tool slice; ANAI-31+ will derive + /// it from `agent.toml`. fn allowed_tools(&self) -> Vec; /// Invoke a tool by name with a JSON argument blob. The dispatcher is @@ -68,7 +77,21 @@ pub trait ToolDispatcher: Send + Sync { &self, tool_name: &str, args: serde_json::Value, - ) -> Result; + ) -> Result; +} + +/// Successful dispatch outcome. Maps onto MCP's `CallToolResult` shape: +/// `content` becomes a single text content block, `is_error` becomes the +/// `isError` flag. +/// +/// Note the distinction from [`ToolDispatchError`]: a tool that ran but +/// reported a failure to the model is `Ok(DispatchOk { is_error: true })`. +/// `Err(_)` means dispatch itself failed (unknown tool, not permitted, +/// transport error) — the bridge surfaces those as MCP errors instead. +#[derive(Debug, Clone)] +pub struct DispatchOk { + pub content: String, + pub is_error: bool, } /// Errors a [`ToolDispatcher`] can return. Bridge maps these to MCP errors. @@ -84,48 +107,230 @@ pub enum ToolDispatchError { Execution(#[from] anyhow::Error), } -/// The MCP server handler — wraps a [`ToolDispatcher`] and serves it over MCP. +/// Built-in tool definitions advertised by the bridge in `tools/list`. +/// +/// **These schemas mirror the equivalent entries in +/// `openfang_runtime::tool_runner::builtin_tool_definitions()`.** They are +/// duplicated here rather than imported because the bridge crate is +/// runtime-free by design (see crate-level docs). If the runtime's schemas +/// drift, update both sides. +/// +/// The set is intentionally limited to the ANAI-30 validation slice: +/// - `file_read`, `file_list` — workspace-scoped, no kernel dependency +/// - `agent_list` — exercises `KernelHandle::list_agents` +/// - `channel_send` — exercises `KernelHandle::send_channel_message`, +/// one of the OpenFang-only capabilities a bare CC subprocess lacks +pub fn built_in_tools() -> Vec { + use serde_json::json; + + fn obj(v: serde_json::Value) -> std::sync::Arc> { + match v { + serde_json::Value::Object(m) => std::sync::Arc::new(m), + _ => std::sync::Arc::new(serde_json::Map::new()), + } + } + + vec![ + Tool::new( + "file_read", + "Read the contents of a file. Paths are relative to the agent workspace.", + obj(json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "The file path to read" } + }, + "required": ["path"] + })), + ), + Tool::new( + "file_list", + "List files in a directory. Paths are relative to the agent workspace.", + obj(json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "The directory path to list" } + }, + "required": ["path"] + })), + ), + Tool::new( + "agent_list", + "List all currently running agents with their IDs, names, states, and models.", + obj(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "channel_send", + "Send a message to a user on a configured channel (email, telegram, slack, \ + discord, etc). For email: recipient is the email address; optionally set \ + subject. Use thread_id to reply in a specific thread/topic.", + obj(json!({ + "type": "object", + "properties": { + "channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" }, + "recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" }, + "subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" }, + "message": { "type": "string", "description": "The message body to send" }, + "thread_id": { "type": "string", "description": "Thread/topic ID to reply in" } + }, + "required": ["channel", "recipient"] + })), + ), + ] +} + +/// The MCP server handler — wraps a [`ToolDispatcher`] and serves the +/// four-tool ANAI-30 surface over MCP. /// -/// In the spike scaffold the dispatcher is optional and unused; only the stub -/// `ping` tool is wired. ANAI-30 will replace `ping` with dispatcher-backed -/// tools. +/// Filtering: `tools/list` advertises only tools that appear in *both* +/// [`built_in_tools`] *and* [`ToolDispatcher::allowed_tools`]. `tools/call` +/// double-checks before dispatch — defense in depth, since the dispatcher +/// itself enforces permissions too. #[derive(Clone)] pub struct Bridge { - #[allow(dead_code)] // wired in ANAI-30 - dispatcher: Option>, - // Read by code generated by `#[tool_router]` / `#[tool_handler]`; the - // dead-code analyzer doesn't see those reads. - #[allow(dead_code)] - tool_router: ToolRouter, + dispatcher: Arc, } -#[tool_router] impl Bridge { - pub fn new(dispatcher: Option>) -> Self { - Self { - dispatcher, - tool_router: Self::tool_router(), - } + pub fn new(dispatcher: Arc) -> Self { + Self { dispatcher } } - #[tool(description = "Stub liveness check. Returns 'pong'. Replaced in ANAI-30 by dispatcher-backed tools.")] - async fn ping(&self) -> Result { - Ok(CallToolResult::success(vec![Content::text("pong")])) + /// Tools the bridge will both advertise and accept calls for, given the + /// dispatcher's allowed set. + fn permitted_tools(&self) -> Vec { + let allowed = self.dispatcher.allowed_tools(); + built_in_tools() + .into_iter() + .filter(|t| allowed.iter().any(|a| a.as_str() == t.name.as_ref())) + .collect() } } -#[tool_handler] impl ServerHandler for Bridge { fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder().enable_tools().build(), - ) - .with_server_info(Implementation::from_build_env()) - .with_protocol_version(ProtocolVersion::V_2024_11_05) - .with_instructions( - "OpenFang MCP bridge (spike). Exposes OpenFang's tool surface to MCP clients. \ - Currently stubbed with a single `ping` tool; full tool surface lands in ANAI-30." - .to_string(), - ) + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::from_build_env()) + .with_protocol_version(ProtocolVersion::V_2024_11_05) + .with_instructions( + "OpenFang MCP bridge. Exposes OpenFang's tool surface to MCP clients, \ + scoped to a single parent agent's identity and capabilities. \ + ANAI-30 surface: file_read, file_list, agent_list, channel_send." + .to_string(), + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.permitted_tools())) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let tool_name = request.name.as_ref(); + let args = request + .arguments + .map(serde_json::Value::Object) + .unwrap_or(serde_json::Value::Null); + + // Defense-in-depth: re-check the allowlist before crossing the seam. + // The dispatcher will enforce again; that's intentional. + let allowed = self.dispatcher.allowed_tools(); + if !allowed.iter().any(|a| a == tool_name) { + return Ok(CallToolResult::error(vec![Content::text(format!( + "tool '{tool_name}' not permitted for this agent" + ))])); + } + + match self.dispatcher.call(tool_name, args).await { + Ok(DispatchOk { content, is_error }) => { + let blocks = vec![Content::text(content)]; + Ok(if is_error { + CallToolResult::error(blocks) + } else { + CallToolResult::success(blocks) + }) + } + Err(ToolDispatchError::UnknownTool(name)) => Ok(CallToolResult::error(vec![ + Content::text(format!("unknown tool: {name}")), + ])), + Err(ToolDispatchError::NotPermitted(name)) => Ok(CallToolResult::error(vec![ + Content::text(format!("not permitted: {name}")), + ])), + Err(ToolDispatchError::InvalidArgs { tool, reason }) => { + Ok(CallToolResult::error(vec![Content::text(format!( + "invalid args for {tool}: {reason}" + ))])) + } + Err(ToolDispatchError::Execution(e)) => Ok(CallToolResult::error(vec![Content::text( + format!("tool execution failed: {e}"), + )])), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct StubDispatcher { + agent: String, + allowed: Vec, + canned: DispatchOk, + } + + #[async_trait::async_trait] + impl ToolDispatcher for StubDispatcher { + fn agent_id(&self) -> &str { + &self.agent + } + fn allowed_tools(&self) -> Vec { + self.allowed.clone() + } + async fn call( + &self, + _tool_name: &str, + _args: serde_json::Value, + ) -> Result { + Ok(self.canned.clone()) + } + } + + #[test] + fn built_in_tools_has_anai30_slice() { + let tools = built_in_tools(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + vec!["file_read", "file_list", "agent_list", "channel_send"] + ); + } + + #[test] + fn permitted_tools_intersects_with_dispatcher_allowed() { + let bridge = Bridge::new(Arc::new(StubDispatcher { + agent: "a".into(), + // Dispatcher permits only file_read of the built-in slice; + // agent_send is unknown to the bridge and must be ignored. + allowed: vec!["file_read".into(), "agent_send".into()], + canned: DispatchOk { + content: String::new(), + is_error: false, + }, + })); + let names: Vec = bridge + .permitted_tools() + .into_iter() + .map(|t| t.name.into_owned()) + .collect(); + assert_eq!(names, vec!["file_read".to_string()]); } } diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index dc01997818..9102464ef6 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -1,24 +1,78 @@ //! Stdio entrypoint for the OpenFang MCP bridge. //! -//! In the spike scaffold this binary runs the bridge with no dispatcher -//! attached — it serves only the stub `ping` tool. The real launch path -//! (parent agent spawns this binary as a child, hands it an identity-scoped -//! dispatcher over IPC) is the subject of ANAI-31. -//! -//! Usage (spike validation): +//! ## Topology (ANAI-30 step 3) //! //! ```text -//! npx @modelcontextprotocol/inspector cargo run -p openfang-mcp-bridge +//! daemon +//! └── claude (per-prompt subprocess) +//! └── openfang-mcp-bridge ← this binary +//! ├── stdio ── MCP ──► claude (parent) +//! └── unix sock ─ IPC ─► daemon (BridgeIpcServer) //! ``` +//! +//! The bridge speaks MCP over stdio to its CC parent, and forwards each +//! `tools/call` over a unix-domain socket to the daemon, which actually +//! invokes `tool_runner::execute_tool`. The daemon socket path and per-spawn +//! auth token come in as env vars set by the daemon at CC-spawn time. +//! +//! ## Env vars +//! +//! | Var | Required | Notes | +//! |------------------------------|----------|------------------------------------| +//! | `OPENFANG_BRIDGE_SOCKET` | yes | absolute path to daemon's unix sock | +//! | `OPENFANG_BRIDGE_TOKEN` | yes | per-spawn auth token (any non-empty in ANAI-30) | +//! | `OPENFANG_BRIDGE_AGENT_ID` | yes | parent agent id (stub for ANAI-30; ANAI-31 derives from token) | +//! | `OPENFANG_BRIDGE_ALLOWED` | no | comma-separated tool allowlist; defaults to the four ANAI-30 tools | +//! +//! ## Concurrency +//! +//! The IPC connection is driven by an actor task (see [`spawn_ipc_actor`]) +//! that owns the read+write halves of the socket. `IpcDispatcher::call` +//! sends a `(CallRequest, oneshot::Sender)` over an mpsc channel +//! and awaits the response. Pending requests are correlated by `request_id`. +//! This keeps the wire serial without serializing tool calls at the dispatcher +//! layer — multiple concurrent `tools/call` invocations get distinct ids and +//! are matched up as responses arrive. +//! +//! ## Shutdown +//! +//! When the IPC socket closes (daemon went away, or peer hung up) the actor +//! task exits and the bridge process terminates. CC will be torn down by the +//! daemon shortly after, which also signals our death. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; -use anyhow::Result; -use openfang_mcp_bridge::Bridge; +use anyhow::{Context, Result, anyhow, bail}; +use openfang_mcp_bridge::{ + Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, + protocol::{ + CallRequest, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_ENV_VAR, + TOKEN_ENV_VAR, codec, + }, +}; use rmcp::{ServiceExt, transport::stdio}; +use tokio::io::BufReader; +use tokio::net::UnixStream; +use tokio::sync::{Mutex, mpsc, oneshot}; use tracing_subscriber::EnvFilter; +/// Env var carrying the parent agent id. Stub for ANAI-30; ANAI-31 derives +/// identity from the token so this becomes redundant. +const AGENT_ID_ENV_VAR: &str = "OPENFANG_BRIDGE_AGENT_ID"; + +/// Env var with an optional comma-separated tool allowlist override. Default +/// is the ANAI-30 four-tool slice. +const ALLOWED_ENV_VAR: &str = "OPENFANG_BRIDGE_ALLOWED"; + +/// Default tool allowlist when [`ALLOWED_ENV_VAR`] is unset. Mirrors the +/// daemon's `bridge_ipc::ALLOWED_TOOLS`. +const DEFAULT_ALLOWED: &[&str] = &["file_read", "file_list", "agent_list", "channel_send"]; + #[tokio::main] async fn main() -> Result<()> { - // Tracing goes to stderr — stdout is the MCP transport, do not pollute it. + // Tracing → stderr. Stdout is the MCP transport; do not pollute it. tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env() @@ -28,9 +82,41 @@ async fn main() -> Result<()> { .with_ansi(false) .init(); - tracing::info!("openfang-mcp-bridge starting (spike scaffold, no dispatcher)"); + let socket_path = std::env::var(SOCKET_ENV_VAR) + .with_context(|| format!("missing required env var {SOCKET_ENV_VAR}"))?; + let token = std::env::var(TOKEN_ENV_VAR) + .with_context(|| format!("missing required env var {TOKEN_ENV_VAR}"))?; + let agent_id = std::env::var(AGENT_ID_ENV_VAR) + .with_context(|| format!("missing required env var {AGENT_ID_ENV_VAR}"))?; + + let allowed_tools: Vec = match std::env::var(ALLOWED_ENV_VAR) { + Ok(v) => v + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + Err(_) => DEFAULT_ALLOWED.iter().map(|s| (*s).to_string()).collect(), + }; + + tracing::info!( + socket = %socket_path, + agent = %agent_id, + allowed = ?allowed_tools, + "openfang-mcp-bridge starting" + ); - let service = Bridge::new(None) + // --- Connect + handshake --- + let mut stream = UnixStream::connect(&socket_path) + .await + .with_context(|| format!("connect to daemon socket {socket_path}"))?; + + handshake(&mut stream, &token).await?; + + // --- Spawn IPC actor --- + let dispatcher = spawn_ipc_actor(stream, agent_id.clone(), allowed_tools.clone()); + + // --- Run MCP server over stdio --- + let service = Bridge::new(Arc::new(dispatcher)) .serve(stdio()) .await .inspect_err(|e| tracing::error!(error = ?e, "bridge serve failed"))?; @@ -38,3 +124,311 @@ async fn main() -> Result<()> { service.waiting().await?; Ok(()) } + +/// Send Hello, await HelloAck. Errors on rejection or wire issues. +async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { + let (read_half, mut write_half) = stream.split(); + let mut read_half = BufReader::new(read_half); + + let hello = Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: token.to_string(), + bridge_version: env!("CARGO_PKG_VERSION").to_string(), + }); + codec::write_frame(&mut write_half, &hello) + .await + .context("write Hello")?; + + match codec::read_frame(&mut read_half).await.context("read HelloAck")? { + Frame::HelloAck(HelloAck::Ok { daemon_version }) => { + tracing::info!(daemon_version, "bridge IPC handshake ok"); + Ok(()) + } + Frame::HelloAck(HelloAck::Rejected { reason }) => { + bail!("daemon rejected handshake: {reason}") + } + other => bail!("expected HelloAck, got {other:?}"), + } +} + +/// One pending request: the slot the actor will fill when its response frame +/// arrives over the wire. +type PendingMap = Arc>>>; + +/// Message dispatcher → actor: a tool call to put on the wire, plus a +/// oneshot to fill with the response. +struct IpcRequest { + call: CallRequest, + reply: oneshot::Sender, +} + +/// Bridge-side `ToolDispatcher` impl. Forwards each call to the actor task +/// over an mpsc and awaits the correlated response. +pub struct IpcDispatcher { + agent_id: String, + allowed: Vec, + tx: mpsc::Sender, + next_id: AtomicU64, +} + +#[async_trait::async_trait] +impl ToolDispatcher for IpcDispatcher { + fn agent_id(&self) -> &str { + &self.agent_id + } + + fn allowed_tools(&self) -> Vec { + self.allowed.clone() + } + + async fn call( + &self, + tool_name: &str, + args: serde_json::Value, + ) -> Result { + if !self.allowed.iter().any(|a| a == tool_name) { + return Err(ToolDispatchError::NotPermitted(tool_name.to_string())); + } + + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (reply_tx, reply_rx) = oneshot::channel(); + + let req = IpcRequest { + call: CallRequest { + request_id, + agent_id: self.agent_id.clone(), + tool_name: tool_name.to_string(), + args, + }, + reply: reply_tx, + }; + + self.tx.send(req).await.map_err(|_| { + ToolDispatchError::Execution(anyhow!("bridge IPC actor has shut down")) + })?; + + let result = reply_rx + .await + .map_err(|_| ToolDispatchError::Execution(anyhow!("IPC response dropped")))?; + + match result { + CallResult::Ok { content, is_error } => Ok(DispatchOk { content, is_error }), + CallResult::Error { message } => Err(ToolDispatchError::Execution(anyhow!(message))), + } + } +} + +/// Spawn the IPC actor task that owns the connected stream and pumps +/// requests/responses. +/// +/// Reads and writes live in two sibling tasks sharing a [`PendingMap`]: +/// - **writer task** drains the mpsc, writes each [`CallRequest`] frame +/// - **reader task** reads frames forever, looks up the matching pending +/// oneshot by `request_id`, and fulfills it +/// +/// Either side exiting causes the other to wind down — the channel closes +/// on drop and the stream closes on EOF. +pub fn spawn_ipc_actor( + stream: UnixStream, + agent_id: String, + allowed: Vec, +) -> IpcDispatcher { + let (tx, mut rx) = mpsc::channel::(32); + let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); + + let (read_half, write_half) = stream.into_split(); + let mut read_half = BufReader::new(read_half); + let write_half = Arc::new(Mutex::new(write_half)); + + // Writer task — drains the mpsc, registers pending oneshots, writes frames. + { + let pending = pending.clone(); + let write_half = write_half.clone(); + tokio::spawn(async move { + while let Some(IpcRequest { call, reply }) = rx.recv().await { + { + let mut p = pending.lock().await; + p.insert(call.request_id, reply); + } + let frame = Frame::Call(call); + let mut w = write_half.lock().await; + if let Err(e) = codec::write_frame(&mut *w, &frame).await { + tracing::error!(error = %e, "bridge IPC: write_frame failed; shutting down writer"); + break; + } + } + tracing::debug!("bridge IPC writer task exiting"); + }); + } + + // Reader task — reads response frames, dispatches to pending oneshots. + { + let pending = pending.clone(); + tokio::spawn(async move { + loop { + let frame = match codec::read_frame(&mut read_half).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + tracing::info!("bridge IPC: daemon closed connection"); + break; + } + Err(e) => { + tracing::error!(error = %e, "bridge IPC read_frame failed"); + break; + } + }; + match frame { + Frame::Response(resp) => { + let slot = { + let mut p = pending.lock().await; + p.remove(&resp.request_id) + }; + if let Some(tx) = slot { + let _ = tx.send(resp.result); + } else { + tracing::warn!( + request_id = resp.request_id, + "bridge IPC: response for unknown request_id (dropped)" + ); + } + } + other => { + tracing::warn!(?other, "bridge IPC: unexpected non-Response frame"); + } + } + } + // Drain pending: best-effort, so dispatcher.call doesn't hang + // forever when the daemon goes away mid-flight. + let mut p = pending.lock().await; + for (_, tx) in p.drain() { + let _ = tx.send(CallResult::Error { + message: "bridge IPC connection closed before response".into(), + }); + } + tracing::debug!("bridge IPC reader task exiting"); + // Production: force the process down — without an + // MCP-transport-aware way to signal the rmcp service to stop, + // exiting here is the simplest correct behavior; the parent CC + // will be torn down by the daemon shortly anyway. Skipped under + // `cfg(test)` so unit tests don't tear down the test runner. + #[cfg(not(test))] + std::process::exit(0); + }); + } + + IpcDispatcher { + agent_id, + allowed, + tx, + next_id: AtomicU64::new(1), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openfang_mcp_bridge::protocol::{CallResponse, Hello, HelloAck}; + use tokio::net::UnixListener; + + /// End-to-end: spin up a fake daemon listener, run handshake + + /// spawn_ipc_actor, send two concurrent calls, verify each gets the + /// right correlated response. + #[tokio::test] + async fn ipc_dispatcher_round_trip_and_correlation() { + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + // Fake daemon: accept, handshake-ok, then echo each call as Ok with + // content = tool_name (so the test can verify per-id correlation). + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let (rh, mut wh) = stream.split(); + let mut rh = BufReader::new(rh); + // Handshake. + match codec::read_frame(&mut rh).await.unwrap() { + Frame::Hello(_) => {} + _ => panic!("expected Hello"), + } + codec::write_frame( + &mut wh, + &Frame::HelloAck(HelloAck::Ok { + daemon_version: "test".into(), + }), + ) + .await + .unwrap(); + // Read N calls and reply. + for _ in 0..2 { + let frame = codec::read_frame(&mut rh).await.unwrap(); + let call = match frame { + Frame::Call(c) => c, + _ => panic!("expected Call"), + }; + codec::write_frame( + &mut wh, + &Frame::Response(CallResponse { + request_id: call.request_id, + result: CallResult::Ok { + content: call.tool_name, + is_error: false, + }, + }), + ) + .await + .unwrap(); + } + }); + + let mut client = UnixStream::connect(&sock).await.unwrap(); + + // Inline handshake (matches `handshake()` in main, factored for test). + { + let (rh, mut wh) = client.split(); + let mut rh = BufReader::new(rh); + codec::write_frame( + &mut wh, + &Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: "t".into(), + bridge_version: "test".into(), + }), + ) + .await + .unwrap(); + match codec::read_frame(&mut rh).await.unwrap() { + Frame::HelloAck(HelloAck::Ok { .. }) => {} + other => panic!("bad ack: {other:?}"), + } + } + + let dispatcher = spawn_ipc_actor( + client, + "agent-x".into(), + vec!["file_read".into(), "file_list".into()], + ); + + // Concurrent calls — exercise the correlation map. + let (a, b) = tokio::join!( + dispatcher.call("file_read", serde_json::json!({"path": "a"})), + dispatcher.call("file_list", serde_json::json!({"path": "b"})), + ); + let a = a.expect("file_read dispatch"); + let b = b.expect("file_list dispatch"); + assert_eq!(a.content, "file_read"); + assert_eq!(b.content, "file_list"); + assert!(!a.is_error && !b.is_error); + + // Permission gate. + let denied = dispatcher + .call("shell_exec", serde_json::json!({})) + .await + .expect_err("disallowed tool must error"); + match denied { + ToolDispatchError::NotPermitted(t) => assert_eq!(t, "shell_exec"), + other => panic!("expected NotPermitted, got {other:?}"), + } + + let _ = server.await; + } +} From 6497443abbda9b9bd99b2ad9007d44f7ae543319 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 20:39:49 -0700 Subject: [PATCH 005/105] ANAI-30 step 4: wire CC driver to spawn bridge via --mcp-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end topology now exists at the type level: daemon → claude (per-prompt) → openfang-mcp-bridge → IPC → daemon - Add `caller_agent_id: Option` to CompletionRequest. Plumbed through all construction sites; agent_loop populates it with session.agent_id, everywhere else passes None. - Daemon (`server.rs::run_daemon`): after BridgeIpcServer starts, publish OPENFANG_BRIDGE_SOCKET and OPENFANG_BRIDGE_BIN as process env for subprocess drivers to discover. Bridge bin defaults to a sibling of current_exe; operators can override with OPENFANG_BRIDGE_BIN. Both set with `unsafe` (edition 2024) but only during single-threaded daemon startup, before any subprocess spawns. - BridgeIpcServer gains `socket_path()` accessor. - ClaudeCodeDriver: per-spawn `try_build_bridge_mcp_config`. When caller_agent_id is set AND both discovery env vars are present, generate a UUID token, write `/run/cc-mcp-.json` (0600), and add `--mcp-config --strict-mcp-config` to the claude args. RAII guard removes the file on drop so per-spawn token lifetime is bounded by the CC subprocess. - apply_env_filter extended to strip OPENFANG_BRIDGE_* from CC's child env. Bridge gets these only via the explicit `env` map in the mcp-config — CC inheriting them would risk a stray bridge picking up the daemon socket without a fresh per-spawn token. - Tests: - test_build_bridge_mcp_config_shape — verifies wire shape claude expects: mcpServers.openfang.{command,args,env} with exactly the three discovery vars in env (no extras to leak state). - test_apply_env_filter_strips_bridge_discovery_vars — confirms filter removes all four bridge vars from CC's child env. - test_bridge_mcp_config_drop_removes_file — RAII cleanup invariant. Stub points still flagged: token validated as non-empty (ANAI-31 replaces with daemon-issued per-spawn token table); agent_id taken in-band from CallRequest (ANAI-31 derives from token). 11 CC driver tests pass. bridge_ipc (4) and bridge crate (6) tests unchanged. Workspace check clean. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 8 + crates/openfang-api/src/routes.rs | 1 + crates/openfang-api/src/server.rs | 44 ++- crates/openfang-kernel/src/kernel.rs | 1 + crates/openfang-runtime/src/agent_loop.rs | 2 + crates/openfang-runtime/src/compactor.rs | 2 + .../src/drivers/claude_code.rs | 263 ++++++++++++++++++ .../openfang-runtime/src/drivers/fallback.rs | 1 + crates/openfang-runtime/src/drivers/gemini.rs | 2 + .../openfang-runtime/src/drivers/qwen_code.rs | 1 + crates/openfang-runtime/src/llm_driver.rs | 12 + crates/openfang-runtime/src/routing.rs | 1 + 12 files changed, 337 insertions(+), 1 deletion(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 26cccac98f..8ecda636d0 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -150,6 +150,14 @@ impl BridgeIpcServer { }) } + /// Path to the unix socket the bridge listens on. Used by the daemon + /// to publish `OPENFANG_BRIDGE_SOCKET` for subprocess drivers (Claude + /// Code, etc.) so they can wire CC's `--mcp-config` to point bridges + /// back here. + pub fn socket_path(&self) -> &std::path::Path { + &self.socket_path + } + /// Signal the accept loop to stop and remove the socket file. pub fn shutdown(&self) { self.shutdown.notify_waiters(); diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index cebb1f599a..1a000b5f7d 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7961,6 +7961,7 @@ pub async fn test_provider( temperature: 0.0, system: None, thinking: None, + caller_agent_id: None, }; match driver.complete(test_req).await { Ok(_) => { diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 2f441c2553..12c6d018f6 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -849,7 +849,49 @@ pub async fn run_daemon( // we just log and skip. The handle is held for the lifetime of the // daemon and dropped on shutdown to remove the socket. let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start(kernel.clone()).await { - Ok(h) => Some(h), + Ok(h) => { + // Publish discovery env vars so subprocess drivers (Claude Code, + // future Codex-style) can find the socket and the bridge binary. + // ANAI-30 step 4: the CC driver consults these to wire CC's + // `--mcp-config` so each spawned `claude` invocation gets an + // MCP server pointed at our daemon. + // + // SAFETY: setting process env is `unsafe` on edition 2024 because + // it's not thread-safe under concurrent readers in other threads. + // Here we run before any subprocess driver spawns and before the + // axum server accepts connections, so there are no concurrent + // env readers. + // SAFETY: see comment above — set during single-threaded daemon startup. + unsafe { + std::env::set_var( + openfang_mcp_bridge::protocol::SOCKET_ENV_VAR, + h.socket_path(), + ); + } + // Resolve the bridge binary path: /openfang-mcp-bridge + // unless the operator has overridden it. Operators can set + // OPENFANG_BRIDGE_BIN explicitly (e.g. for non-standard installs + // or development cargo builds where the binary lives elsewhere). + const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; + if std::env::var_os(BRIDGE_BIN_ENV).is_none() { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("openfang-mcp-bridge"); + if candidate.exists() { + // SAFETY: see above. + unsafe { std::env::set_var(BRIDGE_BIN_ENV, candidate); } + } else { + warn!( + expected = %candidate.display(), + "bridge binary not found next to daemon; \ + set OPENFANG_BRIDGE_BIN to enable CC bridge wiring" + ); + } + } + } + } + Some(h) + } Err(e) => { warn!(error = %e, "bridge IPC listener failed to start; MCP bridge unavailable"); None diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c97..3ea2de5ad1 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -2899,6 +2899,7 @@ impl OpenFangKernel { temperature: manifest.model.temperature, system: Some(manifest.model.system_prompt.clone()), thinking: None, + caller_agent_id: None, }; let (complexity, routed_model) = router.select_model(&probe); info!( diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 615fbfdb73..aa7cdb8724 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -535,6 +535,7 @@ pub async fn run_agent_loop( temperature: manifest.model.temperature, system: Some(system_prompt.clone()), thinking: None, + caller_agent_id: Some(agent_id_str.clone()), }; // Notify phase: Thinking @@ -1772,6 +1773,7 @@ pub async fn run_agent_loop_streaming( temperature: manifest.model.temperature, system: Some(system_prompt.clone()), thinking: None, + caller_agent_id: Some(agent_id_str.clone()), }; // Notify phase: on first iteration emit Streaming; on subsequent diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 3602ff244a..f47dde779c 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -469,6 +469,7 @@ async fn summarize_messages( .to_string(), ), thinking: None, + caller_agent_id: None, }; // Retry logic for transient failures @@ -588,6 +589,7 @@ async fn summarize_in_chunks( .to_string(), ), thinking: None, + caller_agent_id: None, }; match driver.complete(merge_request).await { diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 897d12ed4b..409ce606d4 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -13,10 +13,27 @@ use async_trait::async_trait; use dashmap::DashMap; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; +use std::path::PathBuf; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; +/// Env var names published by the daemon for bridge-wiring discovery. +/// Kept as string literals (not imported from `openfang_mcp_bridge`) +/// because the runtime crate intentionally does not depend on the bridge +/// crate — the bridge depends on the runtime's protocol surface, not the +/// other way around. The values must match +/// `openfang_mcp_bridge::protocol::SOCKET_ENV_VAR` and the daemon-side +/// fallback in `openfang-api::server::run_daemon`. +const BRIDGE_SOCKET_ENV: &str = "OPENFANG_BRIDGE_SOCKET"; +const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; +const BRIDGE_TOKEN_ENV: &str = "OPENFANG_BRIDGE_TOKEN"; +const BRIDGE_AGENT_ID_ENV: &str = "OPENFANG_BRIDGE_AGENT_ID"; + +/// MCP server name advertised inside the per-spawn `--mcp-config`. CC will +/// namespace each tool as `mcp____`. +const BRIDGE_MCP_SERVER_NAME: &str = "openfang"; + /// Environment variable names (and suffixes) to strip from the subprocess /// to prevent leaking API keys from other providers. We keep the full env /// intact (so Node.js, NVM, SSL, proxies, etc. all work) and only remove @@ -214,6 +231,16 @@ impl ClaudeCodeDriver { for key in SENSITIVE_ENV_EXACT { cmd.env_remove(key); } + // Strip bridge discovery env from CC's child env. The bridge gets + // these via the per-spawn `--mcp-config` `env` map (set explicitly + // when `try_build_bridge_mcp_config` writes the config); CC itself + // has no use for them and inheriting them would risk a stray bridge + // process picking up the daemon socket without a fresh per-spawn + // token. + cmd.env_remove(BRIDGE_SOCKET_ENV); + cmd.env_remove(BRIDGE_BIN_ENV); + cmd.env_remove(BRIDGE_TOKEN_ENV); + cmd.env_remove(BRIDGE_AGENT_ID_ENV); // Remove any env var with a sensitive suffix, unless it's CLAUDE_* for (key, _) in std::env::vars() { if key.starts_with("CLAUDE_") { @@ -230,6 +257,129 @@ impl ClaudeCodeDriver { } } +/// Per-spawn MCP config handle. Holds the path to a JSON file that CC +/// reads via `--mcp-config ` to discover the OpenFang bridge. +/// +/// The file lives next to the daemon's bridge socket (under `/run/`) +/// for the duration of a single CC invocation and is removed on drop. +/// Per-spawn so each `claude` subprocess gets a fresh auth token; CC's +/// lifetime bounds the file's lifetime, which bounds the token's lifetime. +struct BridgeMcpConfig { + config_path: PathBuf, +} + +impl BridgeMcpConfig { + fn path(&self) -> &std::path::Path { + &self.config_path + } +} + +impl Drop for BridgeMcpConfig { + fn drop(&mut self) { + // Best-effort: if removal fails (e.g. socket dir already gone on + // shutdown) we don't propagate. The file is per-invocation and + // contains only a token + paths; staleness is harmless. + let _ = std::fs::remove_file(&self.config_path); + } +} + +/// Build the `--mcp-config` JSON document from already-resolved inputs. +/// Pure — no env reads, no filesystem writes — so it's tractable to test. +/// The wire shape mirrors what `claude --mcp-config` accepts: a top-level +/// `mcpServers` object keyed by server name, each value carrying `command`, +/// `args`, and `env`. +fn build_bridge_mcp_config_value( + socket: &str, + bridge_bin: &str, + agent_id: &str, + token: &str, +) -> serde_json::Value { + let mut env_map = serde_json::Map::new(); + env_map.insert( + BRIDGE_SOCKET_ENV.into(), + serde_json::Value::String(socket.to_string()), + ); + env_map.insert( + BRIDGE_TOKEN_ENV.into(), + serde_json::Value::String(token.to_string()), + ); + env_map.insert( + BRIDGE_AGENT_ID_ENV.into(), + serde_json::Value::String(agent_id.to_string()), + ); + + let mut server_entry = serde_json::Map::new(); + server_entry.insert( + "command".into(), + serde_json::Value::String(bridge_bin.to_string()), + ); + server_entry.insert("args".into(), serde_json::Value::Array(vec![])); + server_entry.insert("env".into(), serde_json::Value::Object(env_map)); + + let mut servers = serde_json::Map::new(); + servers.insert( + BRIDGE_MCP_SERVER_NAME.into(), + serde_json::Value::Object(server_entry), + ); + + let mut root = serde_json::Map::new(); + root.insert("mcpServers".into(), serde_json::Value::Object(servers)); + serde_json::Value::Object(root) +} + +/// Generate a per-spawn random token. ANAI-30 uses a random UUID; the +/// daemon currently treats any non-empty token as authenticated. ANAI-31 +/// will replace this with a daemon-issued token tied to the caller's +/// identity in an in-memory table. +fn generate_bridge_token() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Build the per-spawn `--mcp-config` JSON for a CC invocation, if the +/// daemon has published bridge wiring discovery and the request carries +/// a caller identity. Returns `None` when bridge wiring is unavailable — +/// CC is then spawned without an OpenFang MCP server, exactly as before +/// step 4. Logs at `info` level on first wire and `debug` per-spawn so +/// operators can see whether a given run was bridge-enabled. +fn try_build_bridge_mcp_config(caller_agent_id: Option<&str>) -> Option { + let agent_id = caller_agent_id?; + let socket = std::env::var(BRIDGE_SOCKET_ENV).ok()?; + let bridge_bin = std::env::var(BRIDGE_BIN_ENV).ok()?; + let token = generate_bridge_token(); + + let cfg = build_bridge_mcp_config_value(&socket, &bridge_bin, agent_id, &token); + + // Place the config next to the socket so cleanup is colocated and the + // bridge socket dir already exists with the right permissions. + let socket_dir = std::path::Path::new(&socket).parent()?.to_path_buf(); + let path = socket_dir.join(format!("cc-mcp-{}.json", uuid::Uuid::new_v4())); + + let serialized = serde_json::to_string(&cfg).ok()?; + if let Err(e) = std::fs::write(&path, serialized) { + warn!(error = %e, path = %path.display(), "failed to write CC mcp-config"); + return None; + } + + // 0600 — file contains a per-spawn auth token. No other uid should read it. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(&path, perms); + } + } + + debug!( + agent_id = %agent_id, + config = %path.display(), + "wired CC --mcp-config for OpenFang bridge" + ); + + Some(BridgeMcpConfig { config_path: path }) +} + /// JSON output from `claude -p --output-format json`. /// /// The CLI may return the response text in different fields depending on @@ -326,6 +476,20 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--model").arg(model); } + // Wire the OpenFang MCP bridge if the daemon has published discovery + // env vars (`OPENFANG_BRIDGE_SOCKET` + `OPENFANG_BRIDGE_BIN`) and + // the request carries a caller identity. The guard lives for the + // remainder of the call; on drop it removes the temp config file. + let _bridge_cfg = + try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + cmd.arg("--mcp-config").arg(cfg.path()); + // `--strict-mcp-config` makes CC ignore any user/global MCP + // config that might otherwise merge in — we want exactly + // the OpenFang bridge for this invocation, nothing else. + cmd.arg("--strict-mcp-config"); + cfg + }); + Self::apply_env_filter(&mut cmd); // Inject HOME so the CLI can find its credentials (~/.claude/) when @@ -522,6 +686,16 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--model").arg(model); } + // Bridge wiring (see `complete()` for full rationale). Guard kept + // alive for the rest of the streaming function so the per-spawn + // config file outlives the CC subprocess. + let _bridge_cfg = + try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + cmd.arg("--mcp-config").arg(cfg.path()); + cmd.arg("--strict-mcp-config"); + cfg + }); + Self::apply_env_filter(&mut cmd); // Same HOME and stdin hygiene as the non-streaming path. @@ -764,6 +938,7 @@ mod tests { temperature: 0.7, system: Some("You are helpful.".to_string()), thinking: None, + caller_agent_id: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -902,6 +1077,94 @@ mod tests { assert_eq!(driver.active_pids()[0], ("test-agent".to_string(), 12345)); } + #[test] + fn test_apply_env_filter_strips_bridge_discovery_vars() { + // Verifies the filter removes the four bridge-discovery vars so a + // CC subprocess can't accidentally inherit them. The bridge gets + // these via `--mcp-config`'s `env` map only. + let mut cmd = tokio::process::Command::new("/bin/true"); + cmd.env(BRIDGE_SOCKET_ENV, "/tmp/should-not-survive.sock"); + cmd.env(BRIDGE_BIN_ENV, "/usr/local/bin/should-not-survive"); + cmd.env(BRIDGE_TOKEN_ENV, "should-not-survive"); + cmd.env(BRIDGE_AGENT_ID_ENV, "should-not-survive"); + + ClaudeCodeDriver::apply_env_filter(&mut cmd); + + // tokio's Command exposes its env via std::process::Command::get_envs() + // through deref. Walk it; any of the four bridge vars present means + // the filter is broken. + let std_cmd = cmd.as_std(); + for (key, value) in std_cmd.get_envs() { + // None means "remove this env var on spawn"; any of our keys + // showing up with Some means the filter missed them. + let key_str = key.to_string_lossy(); + if matches!( + key_str.as_ref(), + BRIDGE_SOCKET_ENV | BRIDGE_BIN_ENV | BRIDGE_TOKEN_ENV | BRIDGE_AGENT_ID_ENV + ) { + assert!( + value.is_none(), + "bridge env var {key_str} survived apply_env_filter as {value:?}" + ); + } + } + } + + #[test] + fn test_build_bridge_mcp_config_shape() { + let cfg = build_bridge_mcp_config_value( + "/home/user/.openfang/run/bridge.sock", + "/usr/local/bin/openfang-mcp-bridge", + "agent-uuid-1234", + "tok-abc", + ); + + // mcpServers.openfang.{command,args,env} all present with the + // shape claude --mcp-config expects. + let server = cfg + .pointer("/mcpServers/openfang") + .expect("openfang server entry missing"); + assert_eq!( + server.pointer("/command").and_then(|v| v.as_str()), + Some("/usr/local/bin/openfang-mcp-bridge") + ); + assert!( + server.pointer("/args").map(|v| v.is_array()).unwrap_or(false), + "args must be a JSON array" + ); + + // env carries exactly the three discovery vars. No more, no less — + // any extras would leak unintended state into the bridge process. + let env = server + .pointer("/env") + .and_then(|v| v.as_object()) + .expect("env object missing"); + assert_eq!(env.len(), 3, "env must contain exactly socket/token/agent_id"); + assert_eq!(env.get(BRIDGE_SOCKET_ENV).and_then(|v| v.as_str()), Some("/home/user/.openfang/run/bridge.sock")); + assert_eq!(env.get(BRIDGE_TOKEN_ENV).and_then(|v| v.as_str()), Some("tok-abc")); + assert_eq!(env.get(BRIDGE_AGENT_ID_ENV).and_then(|v| v.as_str()), Some("agent-uuid-1234")); + } + + #[test] + fn test_bridge_mcp_config_drop_removes_file() { + // BridgeMcpConfig is a per-spawn token holder; on drop, the file + // must vanish so a stale token can't be reused by anything that + // happens to glob `/run/cc-mcp-*.json`. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cc-mcp-test.json"); + std::fs::write(&path, "{}").expect("seed file"); + assert!(path.exists()); + + { + let _guard = BridgeMcpConfig { + config_path: path.clone(), + }; + assert!(path.exists(), "file present while guard held"); + } + + assert!(!path.exists(), "file must be removed when guard drops"); + } + #[test] fn test_sensitive_env_list_coverage() { // Ensure all major provider keys are in the strip list diff --git a/crates/openfang-runtime/src/drivers/fallback.rs b/crates/openfang-runtime/src/drivers/fallback.rs index 63958ce375..1c196b143b 100644 --- a/crates/openfang-runtime/src/drivers/fallback.rs +++ b/crates/openfang-runtime/src/drivers/fallback.rs @@ -161,6 +161,7 @@ mod tests { temperature: 0.0, system: None, thinking: None, + caller_agent_id: None, } } diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index aee4e30c78..49a018d065 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -1340,6 +1340,7 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, }; let tools = convert_tools(&request); @@ -1358,6 +1359,7 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, }; let tools = convert_tools(&request); diff --git a/crates/openfang-runtime/src/drivers/qwen_code.rs b/crates/openfang-runtime/src/drivers/qwen_code.rs index c68329e734..467b233727 100644 --- a/crates/openfang-runtime/src/drivers/qwen_code.rs +++ b/crates/openfang-runtime/src/drivers/qwen_code.rs @@ -464,6 +464,7 @@ mod tests { temperature: 0.7, system: Some("You are helpful.".to_string()), thinking: None, + caller_agent_id: None, }; let prompt = QwenCodeDriver::build_prompt(&request); diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index 500192c839..a0e5c283c1 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -65,6 +65,17 @@ pub struct CompletionRequest { pub system: Option, /// Extended thinking configuration (if supported by the model). pub thinking: Option, + /// Identity of the OpenFang agent issuing this request, if any. + /// + /// Plumbed through so subprocess-style drivers (Claude Code, Qwen Code, + /// future Codex-style) can bind the OpenFang→bridge IPC connection to a + /// caller. Currently consumed by [`crate::drivers::claude_code`] to set + /// `OPENFANG_BRIDGE_AGENT_ID` on the bridge child it spawns via + /// `--mcp-config`. Other drivers ignore it. ANAI-31 will replace the + /// in-band agent_id with a server-derived identity bound to the + /// per-spawn token, but the field stays in place as the integration + /// point. + pub caller_agent_id: Option, } /// A response from an LLM completion. @@ -328,6 +339,7 @@ mod tests { temperature: 0.0, system: None, thinking: None, + caller_agent_id: None, }; let response = driver.stream(request, tx).await.unwrap(); diff --git a/crates/openfang-runtime/src/routing.rs b/crates/openfang-runtime/src/routing.rs index bf8086a0bd..724dae850a 100644 --- a/crates/openfang-runtime/src/routing.rs +++ b/crates/openfang-runtime/src/routing.rs @@ -188,6 +188,7 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, } } From 5d9edb2a41146820c75b6b705e1bb9cf633e33ee Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 21:00:01 -0700 Subject: [PATCH 006/105] ANAI-30 step 4 follow-up: gate bridge mcp-config on OPENFANG_BRIDGE_ENABLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default-off kill switch so we can deploy the bridge code path without inlining it into every CC invocation. When the gate is unset or not in {1, true}, try_build_bridge_mcp_config returns None and CC is spawned exactly as it was pre-step-4 — no --mcp-config, no temp file, no bridge child. Validation flow: deploy with gate off (sanity), launchctl setenv OPENFANG_BRIDGE_ENABLED 1, bounce daemon, observe; if anything regresses, flip back to 0 and bounce for instant recovery. Daemon still starts the IPC listener and publishes BRIDGE_SOCKET/BIN env unconditionally — both are harmless without a bridge child connecting. Pure additive switch; zero behavior change when off. Test exercises the full truth table for bridge_enabled() (unset, truthy variants, falsy/garbage variants) and confirms the gate suppresses config generation regardless of other env. Single test owns the global env var so no serial_test infra needed. Co-Authored-By: Claude Opus 4.7 --- .../src/drivers/claude_code.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 409ce606d4..e045e1653a 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -30,6 +30,26 @@ const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; const BRIDGE_TOKEN_ENV: &str = "OPENFANG_BRIDGE_TOKEN"; const BRIDGE_AGENT_ID_ENV: &str = "OPENFANG_BRIDGE_AGENT_ID"; +/// Master kill-switch for the OpenFang MCP bridge. Default off. When unset +/// or not in {`1`, `true`}, `try_build_bridge_mcp_config` returns `None` and +/// CC is spawned exactly as it was before ANAI-30 step 4 — no `--mcp-config`, +/// no temp file, no bridge child. Flip via `launchctl setenv +/// OPENFANG_BRIDGE_ENABLED 1` (in the daemon's launchd plist for persistence) +/// then bounce the daemon. Lets us deploy the bridge code path without +/// putting it inline with every CC invocation, so a regression doesn't take +/// down model completions until the gate is removed once the bridge is +/// validated end-to-end. +const BRIDGE_ENABLED_ENV: &str = "OPENFANG_BRIDGE_ENABLED"; + +/// Returns `true` iff the bridge gate env var is set to a recognized truthy +/// value. Anything else — unset, empty, `0`, `false`, garbage — is `false`. +fn bridge_enabled() -> bool { + match std::env::var(BRIDGE_ENABLED_ENV) { + Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), + Err(_) => false, + } +} + /// MCP server name advertised inside the per-spawn `--mcp-config`. CC will /// namespace each tool as `mcp____`. const BRIDGE_MCP_SERVER_NAME: &str = "openfang"; @@ -342,6 +362,11 @@ fn generate_bridge_token() -> String { /// step 4. Logs at `info` level on first wire and `debug` per-spawn so /// operators can see whether a given run was bridge-enabled. fn try_build_bridge_mcp_config(caller_agent_id: Option<&str>) -> Option { + // Gate first — cheapest check, and when off we want zero side effects: + // no temp file, no token generation, no log line beyond trace level. + if !bridge_enabled() { + return None; + } let agent_id = caller_agent_id?; let socket = std::env::var(BRIDGE_SOCKET_ENV).ok()?; let bridge_bin = std::env::var(BRIDGE_BIN_ENV).ok()?; @@ -1165,6 +1190,47 @@ mod tests { assert!(!path.exists(), "file must be removed when guard drops"); } + #[test] + fn test_bridge_enabled_gate() { + // Single test exercises the whole truth table for the gate, in + // sequence, because `OPENFANG_BRIDGE_ENABLED` is process-global. + // No other test reads or writes this var, so we don't need + // serial_test infrastructure — just be a good citizen and + // restore the original value on exit. + let original = std::env::var(BRIDGE_ENABLED_ENV).ok(); + + // Unset → off. + std::env::remove_var(BRIDGE_ENABLED_ENV); + assert!(!bridge_enabled(), "unset must read as off"); + + // Truthy values. + for v in ["1", "true", "TRUE", "True"] { + std::env::set_var(BRIDGE_ENABLED_ENV, v); + assert!(bridge_enabled(), "{v} must read as on"); + } + + // Anything else is off — including `2`, empty, garbage. + for v in ["0", "false", "False", "", "yes", "on", "garbage"] { + std::env::set_var(BRIDGE_ENABLED_ENV, v); + assert!(!bridge_enabled(), "{v:?} must read as off"); + } + + // Even with full bridge wiring published, the gate alone suppresses + // config generation. We don't assert positive-path here because + // setting BRIDGE_SOCKET_ENV/BRIDGE_BIN_ENV process-globally would + // race with apply_env_filter tests; the shape test covers the + // construction path. This test owns the gate behavior only. + std::env::remove_var(BRIDGE_ENABLED_ENV); + let cfg = try_build_bridge_mcp_config(Some("agent-x")); + assert!(cfg.is_none(), "gate off → None regardless of other env"); + + // Restore. + match original { + Some(v) => std::env::set_var(BRIDGE_ENABLED_ENV, v), + None => std::env::remove_var(BRIDGE_ENABLED_ENV), + } + } + #[test] fn test_sensitive_env_list_coverage() { // Ensure all major provider keys are in the strip list From f02247a68b18ab946612a490c220423a12352300 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 21:27:14 -0700 Subject: [PATCH 007/105] ANAI-30 diagnostic: --debug + stderr tail logging when bridge wired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge IPC handshake works standalone (bridge binary connects + Hello/HelloAck ok against the live socket), and the daemon-side `wired CC --mcp-config for OpenFang bridge` debug line confirms the flag is being passed to claude. But no `bridge IPC accepted connection` events ever fire — meaning claude is launched with `--mcp-config` but isn't spawning the MCP server subprocess. Without `--debug`, claude swallows MCP launch errors silently. And we drop CC's stderr on success spawns, so any silent rejection is invisible. Add (both spawn paths): - `--debug` flag when bridge config is wired, so MCP errors print to stderr. - Always log a 4 KB tail of CC stderr at info when bridge_wired, regardless of success/failure. Streaming path now drains stderr concurrently to avoid pipe deadlock under chatty --debug output. Existing 12/12 claude_code unit tests still pass; workspace check clean. Diagnostic only — once the cause is identified we'll pare back to bounded on-demand logging. --- .../src/drivers/claude_code.rs | 76 ++++++++++++++++--- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index e045e1653a..270b125f31 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -512,8 +512,15 @@ impl LlmDriver for ClaudeCodeDriver { // config that might otherwise merge in — we want exactly // the OpenFang bridge for this invocation, nothing else. cmd.arg("--strict-mcp-config"); + // ANAI-30 diagnostic: surface MCP server spawn errors on CC's + // stderr. Without `--debug`, CC silently swallows MCP launch + // failures, which made it impossible to tell whether CC was + // spawning the bridge subprocess at all. Safe to keep on for + // bridge-wired spawns — output is bounded and we log a tail. + cmd.arg("--debug"); cfg }); + let bridge_wired = _bridge_cfg.is_some(); Self::apply_env_filter(&mut cmd); @@ -527,7 +534,7 @@ impl LlmDriver for ClaudeCodeDriver { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, "Spawning Claude Code CLI"); + debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, bridge_wired, "Spawning Claude Code CLI"); // Spawn child process instead of cmd.output() so we can track PID and timeout let mut child = cmd.spawn().map_err(|e| { @@ -644,6 +651,26 @@ impl LlmDriver for ClaudeCodeDriver { info!(model = %pid_label, "Claude Code CLI subprocess completed successfully"); + // ANAI-30 diagnostic: when the bridge is wired, always log a tail of + // CC's stderr (with --debug it contains MCP launch/handshake info). + // Bounded to 4KB so we don't blow up logs. + if bridge_wired { + let stderr_text = String::from_utf8_lossy(&stderr_bytes); + let tail: String = stderr_text + .chars() + .rev() + .take(4096) + .collect::() + .chars() + .rev() + .collect(); + info!( + model = %pid_label, + stderr_tail = %tail.trim(), + "CC stderr tail (bridge wired, --debug)" + ); + } + let stdout = String::from_utf8_lossy(&stdout_bytes); // Try JSON parse first @@ -718,8 +745,11 @@ impl LlmDriver for ClaudeCodeDriver { try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); cmd.arg("--strict-mcp-config"); + // ANAI-30 diagnostic: see complete() for rationale. + cmd.arg("--debug"); cfg }); + let bridge_wired = _bridge_cfg.is_some(); Self::apply_env_filter(&mut cmd); @@ -731,7 +761,7 @@ impl LlmDriver for ClaudeCodeDriver { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)"); + debug!(cli = %self.cli_path, bridge_wired, "Spawning Claude Code CLI (streaming)"); let mut child = cmd.spawn().map_err(|e| { LlmError::Http(format!( @@ -753,6 +783,19 @@ impl LlmDriver for ClaudeCodeDriver { LlmError::Http("No stdout from claude CLI".to_string()) })?; + // ANAI-30 diagnostic: drain stderr concurrently with stdout so we + // can log a tail on success, not just on subprocess failure. Without + // concurrent drain, a chatty `--debug` CC could deadlock on its + // stderr pipe once the OS buffer fills (~64 KB). + let child_stderr = child.stderr.take(); + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::new(); + if let Some(mut err) = child_stderr { + let _ = err.read_to_end(&mut buf).await; + } + buf + }); + let reader = tokio::io::BufReader::new(stdout); let mut lines = reader.lines(); @@ -861,16 +904,12 @@ impl LlmDriver for ClaudeCodeDriver { .await .map_err(|e| LlmError::Http(format!("Claude CLI wait failed: {e}")))?; + // Stderr was being drained concurrently — collect it now. + let stderr_bytes = stderr_task.await.unwrap_or_default(); + let stderr_text = String::from_utf8_lossy(&stderr_bytes).trim().to_string(); + if !status.success() { let code = status.code().unwrap_or(1); - // Read stderr for diagnostic info - let stderr_text = if let Some(mut err) = child.stderr.take() { - let mut buf = Vec::new(); - let _ = err.read_to_end(&mut buf).await; - String::from_utf8_lossy(&buf).trim().to_string() - } else { - String::new() - }; warn!( exit_code = code, model = %pid_label, @@ -890,6 +929,23 @@ impl LlmDriver for ClaudeCodeDriver { }); } + // ANAI-30 diagnostic: log CC stderr tail on success when bridge wired. + if bridge_wired { + let tail: String = stderr_text + .chars() + .rev() + .take(4096) + .collect::() + .chars() + .rev() + .collect(); + info!( + model = %pid_label, + stderr_tail = %tail, + "CC stderr tail (streaming, bridge wired, --debug)" + ); + } + let _ = tx .send(StreamEvent::ContentComplete { stop_reason: StopReason::EndTurn, From da758abdf427e4277e8013349da9190724feec2a Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 4 May 2026 21:59:48 -0700 Subject: [PATCH 008/105] ANAI-30 cleanup: bridge IPC INFO logs + gate CC --debug behind env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bridge_ipc: promote handshake/dispatch events to INFO and add an `accepted connection` log on accept. Operators can now observe the full bridge lifecycle from daemon stderr without crawling through ~/.claude/debug/.txt. - claude_code driver: gate --debug + the 4 KB CC-stderr-tail diagnostic behind a new OPENFANG_BRIDGE_DEBUG env var (off by default). With proper INFO logs daemon-side, the noisy --debug output and the per-spawn ~/.claude/debug/ files are no longer load-bearing. - server: validate operator-supplied OPENFANG_BRIDGE_BIN path at boot and log the resolution outcome (override vs. probe). Catches deploy ordering bugs where the env points at a binary that doesn't exist. Stderr is still drained concurrently in the streaming path — required whenever --debug might be on, cheap when it isn't. --- crates/openfang-api/src/bridge_ipc.rs | 16 +++++- crates/openfang-api/src/server.rs | 47 ++++++++++----- .../src/drivers/claude_code.rs | 57 +++++++++++++------ 3 files changed, 88 insertions(+), 32 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 8ecda636d0..f67a957a5e 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -126,6 +126,7 @@ impl BridgeIpcServer { res = listener.accept() => { match res { Ok((stream, _addr)) => { + info!("bridge IPC: accepted connection"); let conn_kernel = _accept_kernel.clone(); tokio::spawn(async move { if let Err(e) = handle_connection(stream, conn_kernel).await { @@ -204,7 +205,7 @@ async fn handle_connection( daemon_version: daemon_version(), }); codec::write_frame(&mut write_half, &ack).await?; - debug!( + info!( bridge_version = %hello.bridge_version, "bridge IPC: handshake complete" ); @@ -228,7 +229,7 @@ async fn handle_connection( } }; - debug!( + info!( request_id = call.request_id, tool = %call.tool_name, agent = %call.agent_id, @@ -236,6 +237,17 @@ async fn handle_connection( ); let result = dispatch_call(&call, &kernel).await; + let result_kind = match &result { + CallResult::Ok { is_error: false, .. } => "ok", + CallResult::Ok { is_error: true, .. } => "tool_error", + CallResult::Error { .. } => "dispatch_error", + }; + info!( + request_id = call.request_id, + tool = %call.tool_name, + outcome = result_kind, + "bridge IPC: call complete" + ); let response = Frame::Response(CallResponse { request_id: call.request_id, result, diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 12c6d018f6..59679cb226 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -873,19 +873,40 @@ pub async fn run_daemon( // OPENFANG_BRIDGE_BIN explicitly (e.g. for non-standard installs // or development cargo builds where the binary lives elsewhere). const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; - if std::env::var_os(BRIDGE_BIN_ENV).is_none() { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let candidate = dir.join("openfang-mcp-bridge"); - if candidate.exists() { - // SAFETY: see above. - unsafe { std::env::set_var(BRIDGE_BIN_ENV, candidate); } - } else { - warn!( - expected = %candidate.display(), - "bridge binary not found next to daemon; \ - set OPENFANG_BRIDGE_BIN to enable CC bridge wiring" - ); + match std::env::var_os(BRIDGE_BIN_ENV) { + Some(path) => { + let p = std::path::PathBuf::from(&path); + if p.exists() { + info!( + bridge_bin = %p.display(), + "bridge binary resolved via OPENFANG_BRIDGE_BIN override" + ); + } else { + warn!( + bridge_bin = %p.display(), + "OPENFANG_BRIDGE_BIN points at a non-existent path; \ + CC bridge wiring will fail until the binary is installed" + ); + } + } + None => { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("openfang-mcp-bridge"); + if candidate.exists() { + // SAFETY: see above. + unsafe { std::env::set_var(BRIDGE_BIN_ENV, &candidate); } + info!( + bridge_bin = %candidate.display(), + "bridge binary resolved via boot probe" + ); + } else { + warn!( + expected = %candidate.display(), + "bridge binary not found next to daemon; \ + set OPENFANG_BRIDGE_BIN to enable CC bridge wiring" + ); + } } } } diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 270b125f31..d0287b3c2a 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -50,6 +50,23 @@ fn bridge_enabled() -> bool { } } +/// Opt-in diagnostic flag for bridge-wired CC spawns. When set, the driver +/// adds `--debug` to claude (which dumps MCP launch + handshake details into +/// `~/.claude/debug/.txt`) and logs a 4 KB tail of CC's stderr at INFO +/// after the subprocess exits. Off by default — `--debug` is noisy and +/// produces a debug file per spawn. Use only when actively debugging the +/// bridge handshake or MCP wiring; the daemon-side `bridge_ipc` INFO logs +/// (`accepted connection`, `handshake complete`, `dispatching call`) cover +/// the normal observability needs. +const BRIDGE_DEBUG_ENV: &str = "OPENFANG_BRIDGE_DEBUG"; + +fn bridge_debug_enabled() -> bool { + match std::env::var(BRIDGE_DEBUG_ENV) { + Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), + Err(_) => false, + } +} + /// MCP server name advertised inside the per-spawn `--mcp-config`. CC will /// namespace each tool as `mcp____`. const BRIDGE_MCP_SERVER_NAME: &str = "openfang"; @@ -512,15 +529,17 @@ impl LlmDriver for ClaudeCodeDriver { // config that might otherwise merge in — we want exactly // the OpenFang bridge for this invocation, nothing else. cmd.arg("--strict-mcp-config"); - // ANAI-30 diagnostic: surface MCP server spawn errors on CC's - // stderr. Without `--debug`, CC silently swallows MCP launch - // failures, which made it impossible to tell whether CC was - // spawning the bridge subprocess at all. Safe to keep on for - // bridge-wired spawns — output is bounded and we log a tail. - cmd.arg("--debug"); + // Optional diagnostic: with `OPENFANG_BRIDGE_DEBUG=1` we add + // `--debug` so CC writes MCP launch + handshake details into + // `~/.claude/debug/.txt`. Off by default — daemon-side + // bridge_ipc INFO logs are the supported observability path. + if bridge_debug_enabled() { + cmd.arg("--debug"); + } cfg }); let bridge_wired = _bridge_cfg.is_some(); + let bridge_debug = bridge_wired && bridge_debug_enabled(); Self::apply_env_filter(&mut cmd); @@ -651,10 +670,11 @@ impl LlmDriver for ClaudeCodeDriver { info!(model = %pid_label, "Claude Code CLI subprocess completed successfully"); - // ANAI-30 diagnostic: when the bridge is wired, always log a tail of + // Optional diagnostic: when bridge debug is enabled, log a tail of // CC's stderr (with --debug it contains MCP launch/handshake info). - // Bounded to 4KB so we don't blow up logs. - if bridge_wired { + // Bounded to 4KB so we don't blow up logs. Off by default — see + // `bridge_debug_enabled()`. + if bridge_debug { let stderr_text = String::from_utf8_lossy(&stderr_bytes); let tail: String = stderr_text .chars() @@ -745,11 +765,14 @@ impl LlmDriver for ClaudeCodeDriver { try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); cmd.arg("--strict-mcp-config"); - // ANAI-30 diagnostic: see complete() for rationale. - cmd.arg("--debug"); + // Optional --debug — see complete() for rationale. + if bridge_debug_enabled() { + cmd.arg("--debug"); + } cfg }); let bridge_wired = _bridge_cfg.is_some(); + let bridge_debug = bridge_wired && bridge_debug_enabled(); Self::apply_env_filter(&mut cmd); @@ -783,10 +806,10 @@ impl LlmDriver for ClaudeCodeDriver { LlmError::Http("No stdout from claude CLI".to_string()) })?; - // ANAI-30 diagnostic: drain stderr concurrently with stdout so we - // can log a tail on success, not just on subprocess failure. Without - // concurrent drain, a chatty `--debug` CC could deadlock on its - // stderr pipe once the OS buffer fills (~64 KB). + // Drain stderr concurrently with stdout. Required whenever `--debug` + // is on (chatty CC can otherwise deadlock on a full stderr pipe once + // the OS buffer fills, ~64 KB). Cheap when --debug is off, so we + // unconditionally drain — keeps the streaming path uniform. let child_stderr = child.stderr.take(); let stderr_task = tokio::spawn(async move { let mut buf = Vec::new(); @@ -929,8 +952,8 @@ impl LlmDriver for ClaudeCodeDriver { }); } - // ANAI-30 diagnostic: log CC stderr tail on success when bridge wired. - if bridge_wired { + // Optional diagnostic: log CC stderr tail when bridge debug is on. + if bridge_debug { let tail: String = stderr_text .chars() .rev() From f1b99a701ef49ae8a2a7c9c36dbb782b6381e824 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 9 May 2026 00:53:33 -0700 Subject: [PATCH 009/105] fix(windows): cfg-gate MCP bridge to unix-only platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP bridge IPC is unix-domain-socket-only by construction (daemon listens on a unix socket; bridge subprocess connects to it). The bridge crate and the daemon-side `bridge_ipc` module unconditionally imported `tokio::net::{UnixStream, UnixListener}`, which broke Windows CI with E0432 unresolved-import errors in `openfang-mcp-bridge::main` and `openfang-api::bridge_ipc`. Gates: - `openfang-mcp-bridge::main` — entire body cfg-gated to `unix`; on non-unix the binary is a no-op stub that prints a clear message and exits non-zero. Tests gated `cfg(all(test, unix))`. - `openfang-api::lib` — `pub mod bridge_ipc` gated to `unix`. - `openfang-api::server::run_daemon` — `BridgeIpcServer::start` call gated to `unix`; non-unix logs a single info line and proceeds without bridge IPC. The CC driver's existing missing-socket fallthrough means CC subprocesses spawn without `--mcp-config` on Windows, matching the bridge-disabled path. No behavioral change on Linux/macOS. Windows users get a daemon that boots without bridge support; MCP-routed tools are unavailable until a Windows-native transport (named pipes / TCP loopback) lands as a follow-up. Verified: cargo check --workspace, cargo check --workspace --tests, cargo test -p openfang-mcp-bridge -p openfang-api --lib, cargo fmt --check, and cargo clippy all clean on macOS. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/lib.rs | 5 ++++ crates/openfang-api/src/server.rs | 14 +++++++++ crates/openfang-mcp-bridge/src/main.rs | 40 +++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/openfang-api/src/lib.rs b/crates/openfang-api/src/lib.rs index a041e8a420..a67245827e 100644 --- a/crates/openfang-api/src/lib.rs +++ b/crates/openfang-api/src/lib.rs @@ -32,6 +32,11 @@ fn hex_val(b: u8) -> Option { } } +// MCP bridge IPC is unix-domain-socket-only. On Windows the daemon runs +// without bridge support; the openfang-mcp-bridge binary itself ships as a +// no-op stub. Proper Windows transport (named pipes / TCP loopback) is +// tracked as a follow-up — see the upstream issue filed alongside this fix. +#[cfg(unix)] pub mod bridge_ipc; pub mod channel_bridge; pub mod middleware; diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 59679cb226..a3e4062268 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -848,6 +848,20 @@ pub async fn run_daemon( // non-fatal — the daemon can still serve HTTP without bridge support; // we just log and skip. The handle is held for the lifetime of the // daemon and dropped on shutdown to remove the socket. + // + // The bridge IPC is unix-domain-socket-only. On Windows the daemon runs + // without bridge support and CC subprocesses spawn without `--mcp-config` + // (the CC driver's gate detects the missing OPENFANG_BRIDGE_SOCKET env + // var and falls through to the bridge-disabled path). Proper Windows + // transport (named pipes / TCP loopback) is a follow-up. + #[cfg(not(unix))] + { + info!( + "MCP bridge: unix-domain-socket-only; not started on this platform. \ + Daemon will run without bridge IPC; CC subprocesses spawn without --mcp-config." + ); + } + #[cfg(unix)] let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start(kernel.clone()).await { Ok(h) => { // Publish discovery env vars so subprocess drivers (Claude Code, diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 9102464ef6..a3727f5f6b 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -40,11 +40,21 @@ //! task exits and the bridge process terminates. CC will be torn down by the //! daemon shortly after, which also signals our death. +// The MCP bridge IPC is unix-domain-socket-only. On non-unix platforms this +// crate ships as a no-op stub binary (see the `#[cfg(not(unix))] fn main` +// at the bottom of this file). Proper Windows transport (named pipes / TCP +// loopback) is a follow-up. + +#[cfg(unix)] use std::collections::HashMap; +#[cfg(unix)] use std::sync::Arc; +#[cfg(unix)] use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] use anyhow::{Context, Result, anyhow, bail}; +#[cfg(unix)] use openfang_mcp_bridge::{ Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, protocol::{ @@ -52,24 +62,31 @@ use openfang_mcp_bridge::{ TOKEN_ENV_VAR, codec, }, }; +#[cfg(unix)] use rmcp::{ServiceExt, transport::stdio}; use tokio::io::BufReader; +#[cfg(unix)] use tokio::net::UnixStream; +#[cfg(unix)] use tokio::sync::{Mutex, mpsc, oneshot}; use tracing_subscriber::EnvFilter; /// Env var carrying the parent agent id. Stub for ANAI-30; ANAI-31 derives /// identity from the token so this becomes redundant. +#[cfg(unix)] const AGENT_ID_ENV_VAR: &str = "OPENFANG_BRIDGE_AGENT_ID"; /// Env var with an optional comma-separated tool allowlist override. Default /// is the ANAI-30 four-tool slice. +#[cfg(unix)] const ALLOWED_ENV_VAR: &str = "OPENFANG_BRIDGE_ALLOWED"; /// Default tool allowlist when [`ALLOWED_ENV_VAR`] is unset. Mirrors the /// daemon's `bridge_ipc::ALLOWED_TOOLS`. +#[cfg(unix)] const DEFAULT_ALLOWED: &[&str] = &["file_read", "file_list", "agent_list", "channel_send"]; +#[cfg(unix)] #[tokio::main] async fn main() -> Result<()> { // Tracing → stderr. Stdout is the MCP transport; do not pollute it. @@ -126,6 +143,7 @@ async fn main() -> Result<()> { } /// Send Hello, await HelloAck. Errors on rejection or wire issues. +#[cfg(unix)] async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { let (read_half, mut write_half) = stream.split(); let mut read_half = BufReader::new(read_half); @@ -153,10 +171,12 @@ async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { /// One pending request: the slot the actor will fill when its response frame /// arrives over the wire. +#[cfg(unix)] type PendingMap = Arc>>>; /// Message dispatcher → actor: a tool call to put on the wire, plus a /// oneshot to fill with the response. +#[cfg(unix)] struct IpcRequest { call: CallRequest, reply: oneshot::Sender, @@ -164,6 +184,7 @@ struct IpcRequest { /// Bridge-side `ToolDispatcher` impl. Forwards each call to the actor task /// over an mpsc and awaits the correlated response. +#[cfg(unix)] pub struct IpcDispatcher { agent_id: String, allowed: Vec, @@ -171,6 +192,7 @@ pub struct IpcDispatcher { next_id: AtomicU64, } +#[cfg(unix)] #[async_trait::async_trait] impl ToolDispatcher for IpcDispatcher { fn agent_id(&self) -> &str { @@ -228,6 +250,7 @@ impl ToolDispatcher for IpcDispatcher { /// /// Either side exiting causes the other to wind down — the channel closes /// on drop and the stream closes on EOF. +#[cfg(unix)] pub fn spawn_ipc_actor( stream: UnixStream, agent_id: String, @@ -324,7 +347,22 @@ pub fn spawn_ipc_actor( } } -#[cfg(test)] +/// Stub entrypoint for non-unix platforms. The bridge requires unix-domain +/// sockets to talk to the daemon; on Windows it ships as this no-op binary +/// so the workspace builds cleanly and operators get a clear runtime error +/// rather than a compile failure. +#[cfg(not(unix))] +fn main() { + eprintln!( + "openfang-mcp-bridge requires unix-domain sockets and is not supported \ + on this platform. Daemon will run without bridge IPC; CC subprocesses \ + spawn without --mcp-config. Track the upstream follow-up issue for \ + Windows transport (named pipes / TCP loopback)." + ); + std::process::exit(1); +} + +#[cfg(all(test, unix))] mod tests { use super::*; use openfang_mcp_bridge::protocol::{CallResponse, Hello, HelloAck}; From 4e60e7db84433e9bb09ceae409807dfc3ad17a75 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 20:06:18 -0700 Subject: [PATCH 010/105] ANAI-31 phase A: BridgeAuthority + TokenIssuer scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the security primitives that will gate bridge IPC handshake: - openfang-types::bridge_auth::Token — 32-byte CSPRNG-generated opaque token. No Debug impl (anti-leak); only an 8-hex-char fingerprint() for logs. Constant-time equality, hex round-trip. 8 unit tests. - openfang-runtime::bridge_auth — TokenIssuer trait (issue/revoke) and SpawnGuard. Guard holds Arc; Drop revokes. Trait lives here so the Claude Code driver (phase B) can hold Arc without a circular dep on openfang-api. 2 unit tests via stub issuer. - openfang-api::bridge_auth::BridgeAuthority — concrete TokenIssuer impl. Mutex> behind Arc::new_cyclic so issued guards carry a Weak back for self-revocation. Inherent resolve() and live_spawn_count() for the IPC dispatcher and Debug impl. Manual Debug redacts to spawn count (Token has none by design). 7 unit tests, including an Arc round-trip that exercises the exact abstraction the driver will hold. Dep direction respected: openfang-api -> openfang-runtime -> openfang-types. No wiring yet; phase B threads Arc into ClaudeCodeDriver, phase C constructs the Arc in the daemon and hands it to both BridgeIpcServer and the driver factory. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 4 +- crates/openfang-api/src/bridge_auth.rs | 236 +++++++++++++++++++++ crates/openfang-api/src/lib.rs | 2 + crates/openfang-runtime/src/bridge_auth.rs | 169 +++++++++++++++ crates/openfang-runtime/src/lib.rs | 1 + crates/openfang-types/Cargo.toml | 2 + crates/openfang-types/src/bridge_auth.rs | 185 ++++++++++++++++ crates/openfang-types/src/lib.rs | 1 + 8 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 crates/openfang-api/src/bridge_auth.rs create mode 100644 crates/openfang-runtime/src/bridge_auth.rs create mode 100644 crates/openfang-types/src/bridge_auth.rs diff --git a/Cargo.lock b/Cargo.lock index b287d62f4e..4947320326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "openfang-mcp-bridge" -version = "0.6.4" +version = "0.6.9" dependencies = [ "anyhow", "async-trait", @@ -4312,9 +4312,11 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", "uuid", + "zeroize", ] [[package]] diff --git a/crates/openfang-api/src/bridge_auth.rs b/crates/openfang-api/src/bridge_auth.rs new file mode 100644 index 0000000000..719bdd2f7b --- /dev/null +++ b/crates/openfang-api/src/bridge_auth.rs @@ -0,0 +1,236 @@ +//! Per-spawn bridge authorization. +//! +//! The MCP bridge subprocess presents a token on each IPC call; the daemon +//! resolves that token to the `AgentId` it was issued for. This module owns +//! the spawn table — the source of truth for "which bridge process is acting +//! for which agent." +//! +//! ## Layering +//! +//! The runtime-facing abstraction (`TokenIssuer` trait, `SpawnGuard` struct) +//! lives in [`openfang_runtime::bridge_auth`] so the `claude-code` driver +//! can issue tokens without depending on this crate. `BridgeAuthority` is +//! the concrete impl — daemon-owned, holds the spawn table, performs +//! resolution. +//! +//! ## Lifetime model +//! +//! Each call to [`TokenIssuer::issue`] returns a [`SpawnGuard`]. The guard +//! holds the token and an `Arc` back-reference; dropping +//! the guard calls [`TokenIssuer::revoke`], which evicts the entry from the +//! spawn table (and the held [`Token`] zeroizes via its `ZeroizeOnDrop` +//! impl). Wire the guard into `BridgeMcpConfig` so its `Drop` runs when +//! the CC subprocess terminates. +//! +//! ## Identity is resolved, not asserted +//! +//! Bridge IPC requests carry only the token. The daemon never trusts an +//! `agent_id` claim from the bridge — it looks up the agent_id keyed by +//! token. This is the core fix for ANAI-31. +//! +//! ## Concurrency +//! +//! The spawn table is guarded by `std::sync::Mutex`. Critical sections are +//! `HashMap` insert/remove/lookup — sub-microsecond — so async contention is +//! a non-issue. We *never* hold the lock across `.await`; `clippy:: +//! await_holding_lock` (default-warn for `std::sync::Mutex`) enforces this +//! at lint time, which `tokio::sync::Mutex` would not. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; + +use openfang_runtime::bridge_auth::{SpawnGuard, TokenIssuer}; +use openfang_types::agent::AgentId; +use openfang_types::bridge_auth::Token; + +/// Authority that issues per-spawn tokens and resolves them back to agent IDs. +/// +/// Held as `Arc` by the daemon; cloned into the spawn path +/// for each CC subprocess launch. Construct with [`BridgeAuthority::new`], +/// which uses `Arc::new_cyclic` to stash a `Weak` so that `&self` +/// trait methods can produce an `Arc` for `SpawnGuard`. +pub struct BridgeAuthority { + spawns: Mutex>, + /// Self-reference for handing out `Arc` to guards. + /// Populated via `Arc::new_cyclic`. Upgraded inside [`Self::issue`]. + weak_self: Weak, +} + +// Manual Debug — Token deliberately has no Debug to prevent accidental +// secret logging. Redact to live-spawn count only. +impl std::fmt::Debug for BridgeAuthority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let live = self.spawns.lock().map(|g| g.len()).unwrap_or(0); + f.debug_struct("BridgeAuthority") + .field("live_spawns", &live) + .finish() + } +} + +impl BridgeAuthority { + /// Construct an empty authority. Returns `Arc` because the + /// authority must live behind an `Arc` — guards need to hold a strong + /// reference back to it to evict on drop. + pub fn new() -> Arc { + Arc::new_cyclic(|weak_self| Self { + spawns: Mutex::new(HashMap::new()), + weak_self: weak_self.clone(), + }) + } + + /// Resolve a presented token to its bound agent. Returns `None` if the + /// token is unknown or its spawn has terminated. + /// + /// Equality on `Token` is constant-time (`subtle::ConstantTimeEq`), so + /// the per-slot comparison does not leak timing. `HashMap` probing is + /// not constant-time, but the keys are 256-bit CSPRNG values an attacker + /// cannot craft collisions for. + pub fn resolve(&self, token: &Token) -> Option { + let guard = self + .spawns + .lock() + .expect("BridgeAuthority spawn table mutex poisoned"); + guard.get(token).copied() + } + + /// Number of live spawns. Diagnostic / test only. + #[doc(hidden)] + pub fn live_spawn_count(&self) -> usize { + self.spawns + .lock() + .expect("BridgeAuthority spawn table mutex poisoned") + .len() + } + + /// Upgrade the stashed `Weak` into the `Arc` that + /// each [`SpawnGuard`] holds. Panics if the authority has been dropped + /// while a method on it is still running — impossible in practice, since + /// the caller holds a `&self` borrow that pins the `Arc`. + fn issuer_arc(&self) -> Arc { + let strong: Arc = self + .weak_self + .upgrade() + .expect("BridgeAuthority dropped while its own method is running"); + strong as Arc + } +} + +impl TokenIssuer for BridgeAuthority { + fn issue(&self, agent_id: AgentId) -> SpawnGuard { + // The Hash + Eq impls on Token operate on the full 32 random bytes, + // so duplicate-key collision is effectively impossible (2^-256). + // Defensively: if a duplicate ever occurs we'd overwrite the prior + // binding (and the prior guard's Drop would then evict *our* new + // entry on its scope exit). To avoid that subtle aliasing bug, + // regenerate on collision. + let token = { + let mut guard = self + .spawns + .lock() + .expect("BridgeAuthority spawn table mutex poisoned"); + loop { + let candidate = Token::generate(); + if !guard.contains_key(&candidate) { + guard.insert(candidate.clone(), agent_id); + break candidate; + } + } + }; + SpawnGuard::new(self.issuer_arc(), token) + } + + fn revoke(&self, token: &Token) { + // If the mutex is poisoned during shutdown, eviction is best-effort. + // The `Token` field itself still zeroizes via `ZeroizeOnDrop`. + if let Ok(mut spawns) = self.spawns.lock() { + spawns.remove(token); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent() -> AgentId { + AgentId::new() + } + + #[test] + fn issue_registers_a_spawn() { + let authority = BridgeAuthority::new(); + let a = agent(); + let guard = authority.issue(a); + assert_eq!(authority.live_spawn_count(), 1); + assert_eq!(authority.resolve(guard.token()), Some(a)); + } + + #[test] + fn drop_evicts_the_spawn() { + let authority = BridgeAuthority::new(); + let a = agent(); + let token_copy; + { + let guard = authority.issue(a); + token_copy = guard.token().clone(); + assert_eq!(authority.live_spawn_count(), 1); + } + assert_eq!(authority.live_spawn_count(), 0); + assert_eq!(authority.resolve(&token_copy), None); + } + + #[test] + fn distinct_spawns_get_distinct_tokens() { + let authority = BridgeAuthority::new(); + let g1 = authority.issue(agent()); + let g2 = authority.issue(agent()); + assert!(g1.token() != g2.token()); + assert_eq!(authority.live_spawn_count(), 2); + } + + #[test] + fn same_agent_two_concurrent_spawns_resolve_independently() { + // Same agent, two simultaneous bridge processes — each binds its own + // entry; resolving either token returns the same agent_id. + let authority = BridgeAuthority::new(); + let a = agent(); + let g1 = authority.issue(a); + let g2 = authority.issue(a); + assert_eq!(authority.resolve(g1.token()), Some(a)); + assert_eq!(authority.resolve(g2.token()), Some(a)); + assert_eq!(authority.live_spawn_count(), 2); + drop(g1); + assert_eq!(authority.live_spawn_count(), 1); + assert_eq!(authority.resolve(g2.token()), Some(a)); + } + + #[test] + fn unknown_token_resolves_to_none() { + let authority = BridgeAuthority::new(); + let stranger = Token::generate(); + assert_eq!(authority.resolve(&stranger), None); + } + + #[test] + fn guard_fingerprint_matches_token_fingerprint() { + let authority = BridgeAuthority::new(); + let guard = authority.issue(agent()); + assert_eq!(guard.fingerprint(), guard.token().fingerprint()); + } + + #[test] + fn authority_implements_token_issuer_via_arc_dyn() { + // Confirm the abstraction the driver will hold actually works: + // construct `Arc` and exercise issue/revoke + // through it without naming `BridgeAuthority`. + let authority = BridgeAuthority::new(); + let issuer: Arc = authority.clone(); + let a = agent(); + { + let guard = issuer.issue(a); + assert_eq!(authority.resolve(guard.token()), Some(a)); + assert_eq!(authority.live_spawn_count(), 1); + } + assert_eq!(authority.live_spawn_count(), 0); + } +} diff --git a/crates/openfang-api/src/lib.rs b/crates/openfang-api/src/lib.rs index a67245827e..676330d7f9 100644 --- a/crates/openfang-api/src/lib.rs +++ b/crates/openfang-api/src/lib.rs @@ -37,6 +37,8 @@ fn hex_val(b: u8) -> Option { // no-op stub. Proper Windows transport (named pipes / TCP loopback) is // tracked as a follow-up — see the upstream issue filed alongside this fix. #[cfg(unix)] +pub mod bridge_auth; +#[cfg(unix)] pub mod bridge_ipc; pub mod channel_bridge; pub mod middleware; diff --git a/crates/openfang-runtime/src/bridge_auth.rs b/crates/openfang-runtime/src/bridge_auth.rs new file mode 100644 index 0000000000..0c1c2e17d3 --- /dev/null +++ b/crates/openfang-runtime/src/bridge_auth.rs @@ -0,0 +1,169 @@ +//! Bridge token issuance — runtime-side abstraction. +//! +//! This module defines the boundary the `claude-code` driver uses to obtain +//! a per-spawn bridge token without depending on the daemon crate. The +//! concrete authority (`openfang_api::bridge_auth::BridgeAuthority`) lives +//! in `openfang-api`, which already depends on `openfang-runtime`. Routing +//! the abstraction through this crate preserves the dep direction: +//! +//! ```text +//! openfang-api ───depends on──▶ openfang-runtime ───depends on──▶ openfang-types +//! (impl TokenIssuer) (trait + SpawnGuard) (Token) +//! ``` +//! +//! ## Lifetime model +//! +//! [`TokenIssuer::issue`] returns a [`SpawnGuard`]. The guard carries the +//! token plus an `Arc` back-reference to its issuer; on +//! drop it calls [`TokenIssuer::revoke`], which evicts the entry from the +//! issuer's spawn table. The `Token`'s `ZeroizeOnDrop` impl then clears its +//! bytes during the guard's own drop. +//! +//! Wire the guard into `BridgeMcpConfig` so its `Drop` runs exactly when +//! the `claude` subprocess terminates. +//! +//! ## Identity is resolved, not asserted +//! +//! Bridge IPC requests carry only the token. The daemon never trusts an +//! `agent_id` claim from the bridge — it looks up the agent_id keyed by +//! token. This module defines the trait surface; resolution lives on the +//! concrete authority. + +use std::sync::Arc; + +use openfang_types::agent::AgentId; +use openfang_types::bridge_auth::Token; + +/// Issuer of per-spawn bridge tokens. +/// +/// Held by the driver as `Arc` so the runtime crate does +/// not have to depend on `openfang-api`. The concrete implementation +/// (`openfang_api::bridge_auth::BridgeAuthority`) owns the spawn table and +/// the resolution path. +/// +/// The trait is intentionally minimal — issuance + revocation only. +/// Resolution (`token → agent_id`) is daemon-internal and lives on the +/// concrete authority, since runtime callers never need it. +pub trait TokenIssuer: Send + Sync + 'static { + /// Reserve a fresh token bound to `agent_id`. The returned [`SpawnGuard`] + /// evicts the spawn-table entry on drop. + fn issue(&self, agent_id: AgentId) -> SpawnGuard; + + /// Evict a previously-issued token from the spawn table. + /// + /// Called by [`SpawnGuard`]'s `Drop` impl. Not intended for direct use — + /// always go through the guard so eviction and `Token` zeroization + /// happen together. + #[doc(hidden)] + fn revoke(&self, token: &Token); +} + +/// RAII handle for a reserved spawn slot. Drop evicts the entry from the +/// issuer's spawn table and zeroizes the held [`Token`]. +/// +/// The guard exposes the token by reference for emission to the subprocess +/// (e.g. into `BridgeMcpConfig`); it deliberately does not implement +/// `Clone` — there is exactly one live guard per spawn. +pub struct SpawnGuard { + issuer: Arc, + token: Token, +} + +impl SpawnGuard { + /// Construct a guard. Called by [`TokenIssuer`] implementations after + /// they register a fresh token in their spawn table. + pub fn new(issuer: Arc, token: Token) -> Self { + Self { issuer, token } + } + + /// Access the spawn's token for transport to the bridge subprocess + /// (typically via env var). Callers should hex-encode immediately and + /// drop the reference. + pub fn token(&self) -> &Token { + &self.token + } + + /// Diagnostic identifier safe to log (first 8 hex chars of the token). + pub fn fingerprint(&self) -> String { + self.token.fingerprint() + } +} + +impl std::fmt::Debug for SpawnGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpawnGuard") + .field("fingerprint", &self.fingerprint()) + .finish() + } +} + +impl Drop for SpawnGuard { + fn drop(&mut self) { + self.issuer.revoke(&self.token); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Minimal in-memory issuer used to exercise the trait surface without + /// depending on `openfang-api`. The real impl is `BridgeAuthority`. + #[derive(Default)] + struct StubIssuer { + inner: Mutex, + } + + #[derive(Default)] + struct StubInner { + live: Vec<(Token, AgentId)>, + weak: Option>, + } + + impl StubIssuer { + fn new() -> Arc { + let arc = Arc::new(Self::default()); + arc.inner.lock().unwrap().weak = Some(Arc::downgrade(&arc)); + arc + } + } + + impl TokenIssuer for StubIssuer { + fn issue(&self, agent_id: AgentId) -> SpawnGuard { + let token = Token::generate(); + let mut inner = self.inner.lock().unwrap(); + inner.live.push((token.clone(), agent_id)); + let me: Arc = inner + .weak + .as_ref() + .expect("weak self stash") + .upgrade() + .expect("issuer still alive"); + SpawnGuard::new(me, token) + } + + fn revoke(&self, token: &Token) { + let mut inner = self.inner.lock().unwrap(); + inner.live.retain(|(t, _)| t != token); + } + } + + #[test] + fn issue_then_drop_evicts() { + let issuer = StubIssuer::new(); + { + let _g = issuer.issue(AgentId::new()); + assert_eq!(issuer.inner.lock().unwrap().live.len(), 1); + } + assert_eq!(issuer.inner.lock().unwrap().live.len(), 0); + } + + #[test] + fn guard_exposes_fingerprint_and_token() { + let issuer = StubIssuer::new(); + let g = issuer.issue(AgentId::new()); + assert_eq!(g.fingerprint(), g.token().fingerprint()); + assert_eq!(g.token().to_hex().len(), 64); + } +} diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index bde54ab199..731ccfa5b8 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -13,6 +13,7 @@ pub mod agent_loop; pub mod apply_patch; pub mod audit; pub mod auth_cooldown; +pub mod bridge_auth; pub mod browser; pub mod command_lane; pub mod compactor; diff --git a/crates/openfang-types/Cargo.toml b/crates/openfang-types/Cargo.toml index 0c4ba71290..697b8e6c06 100644 --- a/crates/openfang-types/Cargo.toml +++ b/crates/openfang-types/Cargo.toml @@ -18,6 +18,8 @@ ed25519-dalek = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } rand = { workspace = true } +subtle = { workspace = true } +zeroize = { workspace = true } bitflags = "2" [dev-dependencies] diff --git a/crates/openfang-types/src/bridge_auth.rs b/crates/openfang-types/src/bridge_auth.rs new file mode 100644 index 0000000000..783074845c --- /dev/null +++ b/crates/openfang-types/src/bridge_auth.rs @@ -0,0 +1,185 @@ +//! Per-spawn auth primitives for the MCP bridge IPC handshake. +//! +//! The [`Token`] type carries 32 bytes of CSPRNG-generated secret material +//! used to authenticate a bridge subprocess to the daemon. Tokens are minted +//! by the daemon at CC-spawn time, handed to the subprocess via a 0600 +//! per-spawn MCP config file, and presented back on the IPC Hello. +//! +//! Security properties: +//! - **CSPRNG-sourced** (`OsRng`), 256 bits — infeasible to guess. +//! - **Constant-time equality** via [`subtle::ConstantTimeEq`] — no timing +//! oracle on token comparison. +//! - **Zeroized on drop** via [`zeroize::ZeroizeOnDrop`] — secret material +//! does not linger in freed memory. +//! - **No `Debug`/`Display`** — prevents accidental logging of the secret. +//! Use [`Token::fingerprint`] for log correlation (first 32 bits only). +//! +//! Identity is *resolved*, not *asserted*: the daemon looks up the agent_id +//! bound to a presented token in its spawn table. The token is the only +//! trusted claim; any `agent_id` env var or wire-asserted string is for +//! diagnostics. +//! +//! See `projects/openfang-fork/plans/bridge-tool-surface-v2-plan.md` for +//! the broader design. + +use rand::RngCore; +use subtle::ConstantTimeEq; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Length of the token in bytes (256 bits of CSPRNG material). +pub const TOKEN_LEN: usize = 32; + +/// Length of the hex-encoded token on the wire. +pub const TOKEN_HEX_LEN: usize = TOKEN_LEN * 2; + +/// A per-spawn authentication token for the bridge IPC handshake. +/// +/// 32 bytes of CSPRNG material. Constant-time equality. Zeroized on drop. +/// Hex-encoded only at the wire boundary; in-process always handles the +/// raw byte array. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Token([u8; TOKEN_LEN]); + +impl Token { + /// Generate a fresh token from the OS CSPRNG. + pub fn generate() -> Self { + let mut bytes = [0u8; TOKEN_LEN]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + Self(bytes) + } + + /// Encode for transport. The returned `String` is *not* zeroized — + /// callers must treat it as secret until it reaches its destination + /// (env var of a child process, IPC frame, etc.). + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + + /// Decode from wire form. Rejects malformed or wrong-length input. + pub fn from_hex(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| TokenParseError::Malformed)?; + let arr: [u8; TOKEN_LEN] = bytes + .try_into() + .map_err(|_| TokenParseError::WrongLength)?; + Ok(Self(arr)) + } + + /// Short fingerprint for log correlation. First 32 bits only — + /// safe to log, useless to an attacker. + pub fn fingerprint(&self) -> String { + hex::encode(&self.0[..4]) + } + + /// Construct from raw bytes. For tests and trusted internal callers + /// only — production paths should use [`Token::generate`] or + /// [`Token::from_hex`]. + #[doc(hidden)] + pub fn from_bytes(bytes: [u8; TOKEN_LEN]) -> Self { + Self(bytes) + } +} + +// Constant-time equality. Required for any comparison of the secret. +impl PartialEq for Token { + fn eq(&self, other: &Self) -> bool { + self.0.ct_eq(&other.0).into() + } +} +impl Eq for Token {} + +// Hash over the full 32 bytes for use as a HashMap key. HashMap probing +// itself is not constant-time, but keys are uniformly random 256-bit +// values: an attacker cannot craft collisions, and slot-level equality +// goes through `ct_eq` above. Acceptable for the spawn-table use case. +impl std::hash::Hash for Token { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +/// Errors decoding a [`Token`] from its hex wire form. +#[derive(Debug, thiserror::Error)] +pub enum TokenParseError { + #[error("token is not valid hex")] + Malformed, + #[error("token must decode to exactly 32 bytes")] + WrongLength, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_produces_unique_tokens() { + let a = Token::generate(); + let b = Token::generate(); + // Vanishingly unlikely to collide; if this ever flakes, fix the CSPRNG. + // Note: Token deliberately has no Debug impl, so we use `!=` rather than assert_ne!. + assert!(a != b, "two generated tokens collided"); + } + + #[test] + fn hex_round_trip() { + let t = Token::generate(); + let s = t.to_hex(); + assert_eq!(s.len(), TOKEN_HEX_LEN); + let parsed = Token::from_hex(&s).expect("round-trip should succeed"); + assert!(t == parsed, "hex round-trip changed the token"); + } + + #[test] + fn from_hex_rejects_malformed() { + assert!(matches!( + Token::from_hex("not hex at all!!"), + Err(TokenParseError::Malformed) + )); + } + + #[test] + fn from_hex_rejects_wrong_length() { + // Valid hex, wrong byte length. + let short = "deadbeef"; + assert!(matches!( + Token::from_hex(short), + Err(TokenParseError::WrongLength) + )); + let long = "ab".repeat(64); // 64 bytes + assert!(matches!( + Token::from_hex(&long), + Err(TokenParseError::WrongLength) + )); + } + + #[test] + fn equality_is_reflexive_and_symmetric() { + let t = Token::generate(); + let clone = t.clone(); + assert!(t == clone); + assert!(clone == t); + } + + #[test] + fn inequality_for_distinct_bytes() { + let a = Token::from_bytes([0u8; TOKEN_LEN]); + let mut b_bytes = [0u8; TOKEN_LEN]; + b_bytes[31] = 1; // differ only in the last byte + let b = Token::from_bytes(b_bytes); + assert!(a != b); + } + + #[test] + fn fingerprint_is_eight_hex_chars() { + let t = Token::from_bytes([0xab; TOKEN_LEN]); + assert_eq!(t.fingerprint(), "abababab"); + } + + #[test] + fn token_is_usable_as_hashmap_key() { + use std::collections::HashMap; + let t = Token::generate(); + let mut map: HashMap = HashMap::new(); + map.insert(t.clone(), "agent-1"); + assert_eq!(map.get(&t), Some(&"agent-1")); + } +} diff --git a/crates/openfang-types/src/lib.rs b/crates/openfang-types/src/lib.rs index de9df5975d..228c5723a1 100644 --- a/crates/openfang-types/src/lib.rs +++ b/crates/openfang-types/src/lib.rs @@ -5,6 +5,7 @@ pub mod agent; pub mod approval; +pub mod bridge_auth; pub mod capability; pub mod commands; pub mod comms; From c922f982c5798ec0e57b93cda540326ae501b606 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 20:06:29 -0700 Subject: [PATCH 011/105] ANAI-31 phase B: thread Arc into ClaudeCodeDriver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the spawn side of the bridge handshake. The driver can now mint authenticated bridge tokens via the daemon-side authority, with the legacy UUID path preserved so dev builds without an issuer don't regress. - ClaudeCodeDriver gains token_issuer: Option>. Builder with_token_issuer(...) for phase C wiring; new()/with_timeout() default to None. - BridgeMcpConfig gains _guard: Option. Lifetime-only field (underscore-prefixed) — when the spawned process exits and the config drops, SpawnGuard::drop revokes the token from the authority's spawn table. - try_build_bridge_mcp_config is now a &self method with two branches: * issuer present -> parse caller_agent_id as AgentId, refuse to wire if it doesn't parse (no anonymous tokens), then issuer.issue(...) and emit guard.token().to_hex() (64-char hex) on OPENFANG_BRIDGE_TOKEN. Debug log carries token fingerprint. * issuer absent -> legacy UUID path (renamed generate_legacy_bridge_token) — preserves current ANAI-30 behavior where any non-empty token is treated as authenticated. - Both call sites in complete() and stream_complete() updated to call through self. Env var name OPENFANG_BRIDGE_TOKEN unchanged. - Two test fixtures updated to construct via the driver and to set _guard: None on synthetic BridgeMcpConfig values. claude_code tests: 21/21 green. Phase C will construct Arc in openfang-api::server and hand it to both BridgeIpcServer::start and the driver factory. Co-Authored-By: Claude Opus 4.7 --- .../src/drivers/claude_code.rs | 190 +++++++++++++----- 1 file changed, 138 insertions(+), 52 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index d0287b3c2a..5ffde33c4d 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -8,12 +8,15 @@ //! Tracks active subprocess PIDs and enforces message timeouts to prevent //! hung CLI processes from blocking agents indefinitely. +use crate::bridge_auth::{SpawnGuard, TokenIssuer}; use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; use async_trait::async_trait; use dashmap::DashMap; +use openfang_types::agent::AgentId; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; @@ -115,6 +118,21 @@ pub struct ClaudeCodeDriver { active_pids: Arc>, /// Message timeout in seconds. CLI subprocesses that exceed this are killed. message_timeout_secs: u64, + /// Optional daemon-side token issuer for per-spawn bridge auth (ANAI-31). + /// + /// When `Some`, `try_build_bridge_mcp_config` requests a fresh + /// `SpawnGuard` from the issuer and emits the guard's token over + /// `OPENFANG_BRIDGE_TOKEN`. The guard is stashed in the returned + /// `BridgeMcpConfig` so its `Drop` (which evicts the token from the + /// authority's spawn table) fires when the per-spawn config drops — + /// which itself outlives the `claude` subprocess. + /// + /// When `None`, the driver falls back to the legacy ANAI-30 UUID path: + /// a random token is generated and emitted, but the daemon does not + /// know about it. Useful for dev builds wired without the bridge + /// daemon and for tests; production daemon wiring will always populate + /// this field via [`Self::with_token_issuer`] in Phase C. + token_issuer: Option>, } impl ClaudeCodeDriver { @@ -139,9 +157,20 @@ impl ClaudeCodeDriver { skip_permissions, active_pids: Arc::new(DashMap::new()), message_timeout_secs: DEFAULT_MESSAGE_TIMEOUT_SECS, + token_issuer: None, } } + /// Attach a per-spawn bridge `TokenIssuer`. Builder-style so the wiring + /// site in `crates/openfang-runtime/src/drivers/mod.rs` (Phase C) can + /// install the daemon's `Arc` after construction + /// without breaking the existing constructor signatures or the four + /// other driver-build call sites. + pub fn with_token_issuer(mut self, issuer: Arc) -> Self { + self.token_issuer = Some(issuer); + self + } + /// Create a new Claude Code driver with a custom timeout. pub fn with_timeout( cli_path: Option, @@ -303,6 +332,12 @@ impl ClaudeCodeDriver { /// lifetime bounds the file's lifetime, which bounds the token's lifetime. struct BridgeMcpConfig { config_path: PathBuf, + /// Per-spawn token guard. `Drop` evicts the token from the daemon's + /// `BridgeAuthority` spawn table and zeroizes the in-memory `Token` + /// bytes. `None` on the legacy ANAI-30 path (random UUID, no daemon + /// registration). Underscore-prefixed in field name semantically — + /// existence-only; we never read it. + _guard: Option, } impl BridgeMcpConfig { @@ -364,62 +399,109 @@ fn build_bridge_mcp_config_value( serde_json::Value::Object(root) } -/// Generate a per-spawn random token. ANAI-30 uses a random UUID; the -/// daemon currently treats any non-empty token as authenticated. ANAI-31 -/// will replace this with a daemon-issued token tied to the caller's -/// identity in an in-memory table. -fn generate_bridge_token() -> String { +/// Legacy ANAI-30 fallback: per-spawn random UUID token. +/// +/// Only used when the driver has no `token_issuer` attached (dev builds +/// without daemon bridge wiring, plus the test suite). The daemon-issued +/// path goes through `TokenIssuer::issue` and emits a fresh +/// `openfang_types::bridge_auth::Token` instead. Production wiring +/// installs an issuer in Phase C — at that point this fallback no longer +/// fires in daemon builds. +fn generate_legacy_bridge_token() -> String { uuid::Uuid::new_v4().to_string() } -/// Build the per-spawn `--mcp-config` JSON for a CC invocation, if the -/// daemon has published bridge wiring discovery and the request carries -/// a caller identity. Returns `None` when bridge wiring is unavailable — -/// CC is then spawned without an OpenFang MCP server, exactly as before -/// step 4. Logs at `info` level on first wire and `debug` per-spawn so -/// operators can see whether a given run was bridge-enabled. -fn try_build_bridge_mcp_config(caller_agent_id: Option<&str>) -> Option { - // Gate first — cheapest check, and when off we want zero side effects: - // no temp file, no token generation, no log line beyond trace level. - if !bridge_enabled() { - return None; - } - let agent_id = caller_agent_id?; - let socket = std::env::var(BRIDGE_SOCKET_ENV).ok()?; - let bridge_bin = std::env::var(BRIDGE_BIN_ENV).ok()?; - let token = generate_bridge_token(); - - let cfg = build_bridge_mcp_config_value(&socket, &bridge_bin, agent_id, &token); - - // Place the config next to the socket so cleanup is colocated and the - // bridge socket dir already exists with the right permissions. - let socket_dir = std::path::Path::new(&socket).parent()?.to_path_buf(); - let path = socket_dir.join(format!("cc-mcp-{}.json", uuid::Uuid::new_v4())); - - let serialized = serde_json::to_string(&cfg).ok()?; - if let Err(e) = std::fs::write(&path, serialized) { - warn!(error = %e, path = %path.display(), "failed to write CC mcp-config"); - return None; - } +impl ClaudeCodeDriver { + /// Build the per-spawn `--mcp-config` JSON for a CC invocation, if the + /// daemon has published bridge wiring discovery and the request carries + /// a caller identity. Returns `None` when bridge wiring is unavailable — + /// CC is then spawned without an OpenFang MCP server, exactly as before + /// step 4. Logs at `info` level on first wire and `debug` per-spawn so + /// operators can see whether a given run was bridge-enabled. + /// + /// Token sourcing: + /// - If `self.token_issuer` is `Some`, the token is daemon-issued via + /// `TokenIssuer::issue`. The returned [`SpawnGuard`] is stashed in + /// the returned `BridgeMcpConfig` so its `Drop` evicts the entry + /// from the daemon's spawn table when the config drops. + /// - If `self.token_issuer` is `None` (dev builds / tests), the token + /// is a random UUID. The daemon, in that build, treats any + /// non-empty token as authenticated — this is the ANAI-30 surface + /// that ANAI-31 replaces in production. + fn try_build_bridge_mcp_config( + &self, + caller_agent_id: Option<&str>, + ) -> Option { + // Gate first — cheapest check, and when off we want zero side effects: + // no temp file, no token generation, no log line beyond trace level. + if !bridge_enabled() { + return None; + } + let agent_id_str = caller_agent_id?; + let socket = std::env::var(BRIDGE_SOCKET_ENV).ok()?; + let bridge_bin = std::env::var(BRIDGE_BIN_ENV).ok()?; + + // Daemon-issued token path (production): ask the authority for a + // fresh spawn slot. If the caller's agent_id string fails to parse + // as a UUID, refuse to wire the bridge — that's a programmer error + // upstream and we shouldn't silently fall through to a random + // token that the daemon can't tie back to anything. + let (token_hex, guard) = match self.token_issuer.as_ref() { + Some(issuer) => { + let parsed = match AgentId::from_str(agent_id_str) { + Ok(id) => id, + Err(e) => { + warn!( + error = %e, + agent_id = %agent_id_str, + "bridge token issuer present but caller_agent_id is not a valid UUID; \ + refusing to wire bridge (programmer error upstream)" + ); + return None; + } + }; + let g = issuer.issue(parsed); + (g.token().to_hex(), Some(g)) + } + None => (generate_legacy_bridge_token(), None), + }; - // 0600 — file contains a per-spawn auth token. No other uid should read it. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(&path) { - let mut perms = meta.permissions(); - perms.set_mode(0o600); - let _ = std::fs::set_permissions(&path, perms); + let cfg = build_bridge_mcp_config_value(&socket, &bridge_bin, agent_id_str, &token_hex); + + // Place the config next to the socket so cleanup is colocated and the + // bridge socket dir already exists with the right permissions. + let socket_dir = std::path::Path::new(&socket).parent()?.to_path_buf(); + let path = socket_dir.join(format!("cc-mcp-{}.json", uuid::Uuid::new_v4())); + + let serialized = serde_json::to_string(&cfg).ok()?; + if let Err(e) = std::fs::write(&path, serialized) { + warn!(error = %e, path = %path.display(), "failed to write CC mcp-config"); + return None; } - } - debug!( - agent_id = %agent_id, - config = %path.display(), - "wired CC --mcp-config for OpenFang bridge" - ); + // 0600 — file contains a per-spawn auth token. No other uid should read it. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(&path, perms); + } + } - Some(BridgeMcpConfig { config_path: path }) + debug!( + agent_id = %agent_id_str, + config = %path.display(), + fingerprint = %guard.as_ref().map(|g| g.fingerprint()).unwrap_or_else(|| "".into()), + "wired CC --mcp-config for OpenFang bridge" + ); + + Some(BridgeMcpConfig { + config_path: path, + _guard: guard, + }) + } } /// JSON output from `claude -p --output-format json`. @@ -523,7 +605,7 @@ impl LlmDriver for ClaudeCodeDriver { // the request carries a caller identity. The guard lives for the // remainder of the call; on drop it removes the temp config file. let _bridge_cfg = - try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + self.try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); // `--strict-mcp-config` makes CC ignore any user/global MCP // config that might otherwise merge in — we want exactly @@ -762,7 +844,7 @@ impl LlmDriver for ClaudeCodeDriver { // alive for the rest of the streaming function so the per-spawn // config file outlives the CC subprocess. let _bridge_cfg = - try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + self.try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); cmd.arg("--strict-mcp-config"); // Optional --debug — see complete() for rationale. @@ -1079,6 +1161,7 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -1112,6 +1195,7 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -1262,6 +1346,7 @@ mod tests { { let _guard = BridgeMcpConfig { config_path: path.clone(), + _guard: None, }; assert!(path.exists(), "file present while guard held"); } @@ -1300,7 +1385,8 @@ mod tests { // race with apply_env_filter tests; the shape test covers the // construction path. This test owns the gate behavior only. std::env::remove_var(BRIDGE_ENABLED_ENV); - let cfg = try_build_bridge_mcp_config(Some("agent-x")); + let driver = ClaudeCodeDriver::new(None, true); + let cfg = driver.try_build_bridge_mcp_config(Some("agent-x")); assert!(cfg.is_none(), "gate off → None regardless of other env"); // Restore. From 7385e812b1f5162201fd3de6e20c167d4c85e6ee Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 20:32:22 -0700 Subject: [PATCH 012/105] feat(kernel): thread Option> through create_driver Phase C1 of bridge auth wiring. create_driver now accepts an optional TokenIssuer; the Claude Code driver consumes it via with_token_issuer. OpenFangKernel gains a token_issuer slot + setter/getter; resolve_driver threads the issuer through to fresh driver builds. Boot-time and one-shot probe sites keep the legacy UUID path (None). Agent-loop fallback sites are marked for C2. --- crates/openfang-api/src/routes.rs | 5 +- crates/openfang-kernel/src/kernel.rs | 46 +++++++++++++--- crates/openfang-runtime/src/agent_loop.rs | 12 ++++- crates/openfang-runtime/src/drivers/mod.rs | 61 ++++++++++++++-------- 4 files changed, 93 insertions(+), 31 deletions(-) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 1a000b5f7d..4e487c4f37 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7950,7 +7950,10 @@ pub async fn test_provider( subprocess_timeout_secs: None, }; - match openfang_runtime::drivers::create_driver(&driver_config) { + // Phase C1: model-test endpoint runs a one-shot probe and does not spawn an + // agent loop, so the bridge token issuer is not needed here. Pass `None` to + // exercise the legacy UUID path; no MCP bridge config is built for this call. + match openfang_runtime::drivers::create_driver(&driver_config, None) { Ok(driver) => { // Send a minimal completion request to test connectivity let test_req = openfang_runtime::llm_driver::CompletionRequest { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 3ea2de5ad1..7781da40d5 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -18,6 +18,7 @@ use openfang_runtime::agent_loop::{ run_agent_loop, run_agent_loop_streaming, strip_provider_prefix, AgentLoopResult, }; use openfang_runtime::audit::AuditLog; +use openfang_runtime::bridge_auth::TokenIssuer; use openfang_runtime::drivers; use openfang_runtime::kernel_handle::{self, KernelHandle}; use openfang_runtime::llm_driver::{ @@ -180,6 +181,13 @@ pub struct OpenFangKernel { agent_msg_locks: dashmap::DashMap>>, /// Weak self-reference for trigger dispatch (set after Arc wrapping). self_handle: OnceLock>, + /// Bridge token issuer — set by the daemon (`openfang-api::server.rs`) after + /// the kernel is wrapped in `Arc`. Threaded into `drivers::create_driver` so + /// the Claude Code driver mints per-spawn, short-lived bridge tokens + /// registered with the IPC dispatcher. `None` until `set_token_issuer` runs; + /// `None` keeps the legacy ANAI-30 UUID path (dev builds, non-unix targets, + /// tests that build a kernel without the bridge daemon). + token_issuer: std::sync::RwLock>>, } /// Bounded in-memory delivery receipt tracker. @@ -699,7 +707,11 @@ impl OpenFangKernel { }; // Primary driver failure is non-fatal: the dashboard should remain accessible // even if the LLM provider is misconfigured. Users can fix config via dashboard. - let primary_result = drivers::create_driver(&driver_config); + // Boot path runs before `set_token_issuer` (called from + // `openfang-api::server.rs` after `Arc::new(kernel)`), so the issuer is + // necessarily `None` here. Boot-time drivers therefore use the legacy + // ANAI-30 UUID path; resolve_driver below picks up the issuer post-boot. + let primary_result = drivers::create_driver(&driver_config, None); let mut driver_chain: Vec> = Vec::new(); match &primary_result { @@ -723,7 +735,7 @@ impl OpenFangKernel { // is replacing the *provider*, not the timeout policy. subprocess_timeout_secs: config.default_model.subprocess_timeout_secs, }; - match drivers::create_driver(&auto_config) { + match drivers::create_driver(&auto_config, None) { Ok(d) => { info!( provider = %provider, @@ -772,7 +784,7 @@ impl OpenFangKernel { skip_permissions: true, subprocess_timeout_secs: fb.subprocess_timeout_secs, }; - match drivers::create_driver(&fb_config) { + match drivers::create_driver(&fb_config, None) { Ok(d) => { info!( provider = %fb.provider, @@ -1220,6 +1232,7 @@ impl OpenFangKernel { fallback_providers_override: std::sync::RwLock::new(None), agent_msg_locks: dashmap::DashMap::new(), self_handle: OnceLock::new(), + token_issuer: std::sync::RwLock::new(None), }; // Wire HAND.toml load events into the Merkle audit chain so reload @@ -4072,6 +4085,27 @@ impl OpenFangKernel { let _ = self.self_handle.set(Arc::downgrade(self)); } + /// Install the daemon's bridge token issuer. Called once at boot by + /// `openfang-api::server.rs` after constructing the `Arc`. + /// Idempotent at the field level — last writer wins, but no production + /// caller writes more than once. + pub fn set_token_issuer(&self, issuer: Arc) { + if let Ok(mut slot) = self.token_issuer.write() { + *slot = Some(issuer); + } + } + + /// Snapshot of the current bridge token issuer, if any. Cheap clone of an + /// `Arc`. Consumed by `drivers::create_driver` to hand the issuer to the + /// Claude Code driver, and (Phase C2) by `KernelHandle::token_issuer` so + /// the agent loop's fallback paths can mint hardened tokens too. + pub fn token_issuer(&self) -> Option> { + self.token_issuer + .read() + .ok() + .and_then(|slot| slot.clone()) + } + // ─── Agent Binding management ────────────────────────────────────── /// List all agent bindings. @@ -5656,7 +5690,7 @@ impl OpenFangKernel { subprocess_timeout_secs: primary_timeout, }; - match drivers::create_driver(&driver_config) { + match drivers::create_driver(&driver_config, self.token_issuer()) { Ok(d) => d, Err(e) => { // If fresh driver creation fails (e.g. key not yet set for this @@ -5740,7 +5774,7 @@ impl OpenFangKernel { None }, }; - match drivers::create_driver(&config) { + match drivers::create_driver(&config, self.token_issuer()) { Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))), Err(e) => { warn!("Fallback driver '{}' failed to init: {e}", fb_provider); @@ -5775,7 +5809,7 @@ impl OpenFangKernel { skip_permissions: true, subprocess_timeout_secs: fb.subprocess_timeout_secs, }; - match drivers::create_driver(&fb_config) { + match drivers::create_driver(&fb_config, self.token_issuer()) { Ok(d) => { chain.push((d, strip_provider_prefix(&fb.model, &fb.provider))); } diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index aa7cdb8724..1b814b44ec 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -1269,7 +1269,11 @@ async fn call_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - let fb_driver = match crate::drivers::create_driver(&fb_config) { + // Phase C1: fallback driver does not yet receive the + // daemon's bridge `TokenIssuer`. C2 will plumb it via a + // new `KernelHandle::token_issuer()` accessor so fallback + // sessions get the hardened bridge auth. + let fb_driver = match crate::drivers::create_driver(&fb_config, None) { Ok(d) => d, Err(driver_err) => { warn!( @@ -1453,7 +1457,11 @@ async fn stream_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - let fb_driver = match crate::drivers::create_driver(&fb_config) { + // Phase C1: fallback driver does not yet receive the + // daemon's bridge `TokenIssuer`. C2 will plumb it via a + // new `KernelHandle::token_issuer()` accessor so fallback + // sessions get the hardened bridge auth. + let fb_driver = match crate::drivers::create_driver(&fb_config, None) { Ok(d) => d, Err(driver_err) => { warn!( diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index ca1e0701c7..9f239db656 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -14,6 +14,7 @@ pub mod openai; pub mod qwen_code; pub mod vertex; +use crate::bridge_auth::TokenIssuer; use crate::llm_driver::{DriverConfig, LlmDriver, LlmError}; use openfang_types::model_catalog::{ AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, @@ -330,7 +331,18 @@ fn provider_defaults(provider: &str) -> Option { /// - `replicate` — Replicate /// - `chutes` — Chutes.ai (serverless open-source model inference) /// - Any custom provider with `base_url` set uses OpenAI-compatible format -pub fn create_driver(config: &DriverConfig) -> Result, LlmError> { +/// +/// `token_issuer` is the daemon's bridge-token issuer (`Arc` in +/// production). When `Some`, the Claude Code driver uses it to mint per-spawn, +/// short-lived bridge tokens registered with the IPC dispatcher. When `None`, +/// the driver falls back to the legacy ANAI-30 UUID path — the daemon treats +/// any non-empty token as authenticated. The non-claude-code branches ignore +/// the issuer today; future subprocess drivers (qwen-code, etc.) will opt in +/// individually. +pub fn create_driver( + config: &DriverConfig, + token_issuer: Option>, +) -> Result, LlmError> { let provider = config.provider.as_str(); // Anthropic uses a different API format — special case @@ -404,12 +416,17 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr .ok() .and_then(|s| s.parse::().ok()) .or(config.subprocess_timeout_secs); - return Ok(Arc::new(match timeout { + let driver = match timeout { Some(secs) => { claude_code::ClaudeCodeDriver::with_timeout(cli_path, config.skip_permissions, secs) } None => claude_code::ClaudeCodeDriver::new(cli_path, config.skip_permissions), - })); + }; + let driver = match token_issuer { + Some(issuer) => driver.with_token_issuer(issuer), + None => driver, + }; + return Ok(Arc::new(driver)); } // Qwen Code CLI — subprocess-based, uses Qwen OAuth (free tier) @@ -780,7 +797,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_ok()); } @@ -793,7 +810,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_err()); } @@ -914,7 +931,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "Novita provider with env var should succeed" @@ -932,7 +949,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_err()); } @@ -949,7 +966,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "NVIDIA provider with env var should succeed" @@ -968,7 +985,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_err()); } @@ -985,7 +1002,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let result = create_driver(&config); + let result = create_driver(&config, None); assert!(result.is_err()); let err = result.err().unwrap().to_string(); assert!( @@ -1013,7 +1030,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_ok()); } @@ -1041,7 +1058,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_ok(), "Azure driver with key + URL should succeed"); } @@ -1054,7 +1071,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let result = create_driver(&config); + let result = create_driver(&config, None); assert!(result.is_err(), "Azure driver without key should error"); let err = result.err().unwrap().to_string(); assert!( @@ -1073,7 +1090,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let result = create_driver(&config); + let result = create_driver(&config, None); assert!(result.is_err(), "Azure driver without URL should error"); let err = result.err().unwrap().to_string(); assert!( @@ -1092,7 +1109,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "azure-openai alias should create driver successfully" @@ -1118,7 +1135,7 @@ mod tests { subprocess_timeout_secs: None, }; // Should succeed because api_key is provided - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "Bedrock with explicit api_key should construct successfully" @@ -1136,7 +1153,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_ok(), "claude-code driver should construct"); } @@ -1151,7 +1168,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: Some(480), }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "claude-code driver should construct with custom timeout" @@ -1171,7 +1188,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: Some(120), }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS"); assert!( driver.is_ok(), @@ -1190,7 +1207,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: Some(420), }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); std::env::remove_var("OPENFANG_SUBPROCESS_TIMEOUT_SECS"); assert!( driver.is_ok(), @@ -1280,7 +1297,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!( driver.is_ok(), "ollama with OLLAMA_HOST set and no API key should construct: {:?}", @@ -1304,7 +1321,7 @@ mod tests { skip_permissions: true, subprocess_timeout_secs: None, }; - let driver = create_driver(&config); + let driver = create_driver(&config, None); assert!(driver.is_ok(), "lmstudio default should construct"); } } From 792db903a5c921f83469e260f81f4e9d75664812 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 20:46:05 -0700 Subject: [PATCH 013/105] feat(kernel): plumb bridge TokenIssuer through KernelHandle to agent loop Phase C2 of bridge auth wiring. KernelHandle gains a default-None token_issuer() accessor; OpenFangKernel overrides it to expose the daemon's BridgeAuthority. The agent loop reads the issuer via KernelHandle and threads it into call_with_retry / stream_with_retry, which forward it to create_driver on the ModelNotFound fallback path so fallback drivers also get hardened bridge tokens. server.rs constructs the BridgeAuthority at daemon startup (unix-gated) and hands it to the kernel before background agents start. --- crates/openfang-api/src/server.rs | 17 ++++++++++++ crates/openfang-kernel/src/kernel.rs | 4 +++ crates/openfang-runtime/src/agent_loop.rs | 27 ++++++++++++-------- crates/openfang-runtime/src/kernel_handle.rs | 13 ++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a3e4062268..a8a257ee54 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -809,6 +809,23 @@ pub async fn run_daemon( let kernel = Arc::new(kernel); kernel.set_self_handle(); + + // Phase C2: construct the bridge `TokenIssuer` (an `Arc`) + // and hand it to the kernel before background agents start. From here on, + // `resolve_driver` and agent-loop fallback paths thread the issuer into + // every new Claude Code driver, swapping the legacy UUID env-var for the + // daemon-issued, single-use, fingerprint-tracked bridge token. + // + // Unix-only: `BridgeAuthority` lives in `bridge_auth` which is gated to + // unix targets along with the rest of the bridge IPC plumbing. On + // non-unix builds the kernel keeps its `token_issuer` slot empty and + // drivers fall back to the legacy UUID path. + #[cfg(unix)] + { + let bridge_authority = crate::bridge_auth::BridgeAuthority::new(); + kernel.set_token_issuer(bridge_authority); + } + kernel.start_background_agents(); // Config file hot-reload watcher (polls every 30 seconds) diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 7781da40d5..b3d04d9142 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -7199,6 +7199,10 @@ async fn cron_fan_out_targets( #[async_trait] impl KernelHandle for OpenFangKernel { + fn token_issuer(&self) -> Option> { + OpenFangKernel::token_issuer(self) + } + async fn spawn_agent( &self, manifest_toml: &str, diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 1b814b44ec..b4318efecb 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -4,6 +4,7 @@ //! calling the LLM, executing tool calls, and saving the conversation. use crate::auth_cooldown::{CooldownVerdict, ProviderCooldown}; +use crate::bridge_auth::TokenIssuer; use crate::context_budget::{apply_context_guard, truncate_tool_result_dynamic, ContextBudget}; use crate::context_overflow::{recover_from_overflow, RecoveryStage}; use crate::embedding::EmbeddingDriver; @@ -557,6 +558,7 @@ pub async fn run_agent_loop( Some(provider_name), None, &manifest.fallback_models, + kernel.as_ref().and_then(|k| k.token_issuer()), ) .await?; @@ -1159,6 +1161,7 @@ async fn call_with_retry( provider: Option<&str>, cooldown: Option<&ProviderCooldown>, fallback_models: &[FallbackModel], + token_issuer: Option>, ) -> OpenFangResult { // Check circuit breaker before calling if let (Some(provider), Some(cooldown)) = (provider, cooldown) { @@ -1269,11 +1272,12 @@ async fn call_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - // Phase C1: fallback driver does not yet receive the - // daemon's bridge `TokenIssuer`. C2 will plumb it via a - // new `KernelHandle::token_issuer()` accessor so fallback - // sessions get the hardened bridge auth. - let fb_driver = match crate::drivers::create_driver(&fb_config, None) { + // Fallback driver inherits the daemon's bridge + // `TokenIssuer` (plumbed in from the caller via the + // `token_issuer` arg, which the agent loop sources + // from `KernelHandle::token_issuer()`) so these + // sessions stay on the hardened bridge auth path. + let fb_driver = match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { Ok(d) => d, Err(driver_err) => { warn!( @@ -1348,6 +1352,7 @@ async fn stream_with_retry( provider: Option<&str>, cooldown: Option<&ProviderCooldown>, fallback_models: &[FallbackModel], + token_issuer: Option>, ) -> OpenFangResult { // Check circuit breaker before calling if let (Some(provider), Some(cooldown)) = (provider, cooldown) { @@ -1457,11 +1462,12 @@ async fn stream_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - // Phase C1: fallback driver does not yet receive the - // daemon's bridge `TokenIssuer`. C2 will plumb it via a - // new `KernelHandle::token_issuer()` accessor so fallback - // sessions get the hardened bridge auth. - let fb_driver = match crate::drivers::create_driver(&fb_config, None) { + // Fallback driver inherits the daemon's bridge + // `TokenIssuer` (plumbed in from the caller via the + // `token_issuer` arg, which the agent loop sources + // from `KernelHandle::token_issuer()`) so these + // sessions stay on the hardened bridge auth path. + let fb_driver = match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { Ok(d) => d, Err(driver_err) => { warn!( @@ -1810,6 +1816,7 @@ pub async fn run_agent_loop_streaming( Some(provider_name), None, &manifest.fallback_models, + kernel.as_ref().and_then(|k| k.token_issuer()), ) .await?; diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index ec57efe1f9..4224109c54 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -6,6 +6,9 @@ //! it into the agent loop. use async_trait::async_trait; +use std::sync::Arc; + +use crate::bridge_auth::TokenIssuer; /// Agent info returned by list and discovery operations. #[derive(Debug, Clone)] @@ -253,6 +256,16 @@ pub trait KernelHandle: Send + Sync { let _ = agent_id; } + /// Return the daemon's bridge `TokenIssuer`, if one has been wired. + /// + /// The agent loop calls this when constructing fallback drivers so they + /// can participate in the hardened bridge handshake. The default returns + /// `None`, keeping mock/test impls (e.g. `FakeKernelHandle`) on the legacy + /// UUID path; `OpenFangKernel` overrides it to expose its authority. + fn token_issuer(&self) -> Option> { + None + } + /// Spawn an agent with capability inheritance enforcement. /// `parent_caps` are the parent's granted capabilities. The kernel MUST verify /// that every capability in the child manifest is covered by `parent_caps`. From 34e67080611aaa90e18102e9946d57dd3812e186 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 21:21:49 -0700 Subject: [PATCH 014/105] feat(bridge): authenticate Hello via BridgeAuthority, bind AgentId per connection Phase D of bridge auth wiring. validate_hello is renamed to authenticate_hello and now resolves the presented token through the daemon's BridgeAuthority. Well-formed hex tokens that the authority issued resolve to an AgentId that overrides the bridge's claimed agent_id on every subsequent CallRequest (with a warn! on mismatch). Well-formed hex tokens the authority never issued are rejected. Non-hex tokens fall through to the ANAI-30 legacy path for back-compat with spawn sites that haven't yet been wired through the TokenIssuer (boot- time create_driver in particular). Handshake log line now includes the token fingerprint and authenticated agent id for audit correlation. BridgeIpcServer::start gains the authority argument; server.rs hoists the authority out of the C2 cfg block so it can be threaded in. --- crates/openfang-api/src/bridge_ipc.rs | 261 ++++++++++++++++++++++---- crates/openfang-api/src/server.rs | 16 +- 2 files changed, 237 insertions(+), 40 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index f67a957a5e..b18113cf91 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -37,11 +37,14 @@ //! capability gating (replacing the static [`ALLOWED_TOOLS`] allowlist //! with `agent.toml` lookups) lands in the same ticket. +use crate::bridge_auth::BridgeAuthority; use openfang_kernel::OpenFangKernel; use openfang_mcp_bridge::protocol::{ CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_RELATIVE_PATH, codec, }; +use openfang_types::agent::AgentId; +use openfang_types::bridge_auth::Token; use std::path::PathBuf; use std::sync::Arc; use tokio::net::{UnixListener, UnixStream}; @@ -87,7 +90,14 @@ pub struct BridgeIpcServer { impl BridgeIpcServer { /// Start the IPC listener. Returns once the socket is bound; the accept /// loop runs in a detached tokio task until shutdown is signaled. - pub async fn start(kernel: Arc) -> std::io::Result { + /// + /// `authority` is the daemon's [`BridgeAuthority`], cloned into each + /// accepted connection so the handshake can resolve presented tokens to + /// the [`AgentId`] they were issued for. See [`authenticate_hello`]. + pub async fn start( + kernel: Arc, + authority: Arc, + ) -> std::io::Result { let socket_path = socket_path(&kernel.config.home_dir)?; // Remove any stale socket from a prior unclean shutdown. UnixListener @@ -115,6 +125,7 @@ impl BridgeIpcServer { let shutdown = Arc::new(Notify::new()); let accept_shutdown = shutdown.clone(); let _accept_kernel = kernel.clone(); + let accept_authority = authority.clone(); tokio::spawn(async move { loop { @@ -128,8 +139,9 @@ impl BridgeIpcServer { Ok((stream, _addr)) => { info!("bridge IPC: accepted connection"); let conn_kernel = _accept_kernel.clone(); + let conn_authority = accept_authority.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, conn_kernel).await { + if let Err(e) = handle_connection(stream, conn_kernel, conn_authority).await { debug!(error = %e, "bridge IPC connection ended with error"); } }); @@ -174,11 +186,37 @@ impl Drop for BridgeIpcServer { } } +/// Resolved identity for a connected bridge after a successful handshake. +/// +/// - `agent_id == Some(_)` is the **hardened path**: the bridge presented a +/// well-formed token that the [`BridgeAuthority`] resolved to a live spawn. +/// `dispatch_call` will substitute this id for the (untrusted) +/// `CallRequest::agent_id` field on every subsequent call on the +/// connection. +/// - `agent_id == None` is the **legacy path**: the token is non-empty but +/// not in 64-hex form, so it can't be a daemon-issued token. We accept it +/// for back-compat with drivers built before [`TokenIssuer`] wiring +/// reached every spawn site (the daemon's boot-time `create_driver` +/// calls in `boot_with_config` still emit legacy UUIDs because they run +/// before `set_token_issuer`). In this mode the bridge's claimed +/// `agent_id` is taken at face value — same trust model as ANAI-30. The +/// legacy lane is closed in the next phase by fixing boot ordering and +/// then making `authenticate_hello` strict. +/// +/// `token_fingerprint` is the first 32 bits of the resolved token, suitable +/// for log correlation. `None` on the legacy path. +#[derive(Debug)] +struct HandshakeIdentity { + agent_id: Option, + token_fingerprint: Option, +} + /// Handle a single bridge connection: Hello/HelloAck handshake, then a loop /// of CallRequest → CallResponse frames until the peer closes. async fn handle_connection( mut stream: UnixStream, kernel: Arc, + authority: Arc, ) -> std::io::Result<()> { let (read_half, mut write_half) = stream.split(); let mut read_half = tokio::io::BufReader::new(read_half); @@ -192,21 +230,34 @@ async fn handle_connection( } }; - if let Err(reason) = validate_hello(&hello) { - let ack = Frame::HelloAck(HelloAck::Rejected { - reason: reason.clone(), - }); - let _ = codec::write_frame(&mut write_half, &ack).await; - warn!(reason, "bridge IPC: rejected handshake"); - return Ok(()); - } + let identity = match authenticate_hello(&hello, &authority) { + Ok(id) => id, + Err(reason) => { + let ack = Frame::HelloAck(HelloAck::Rejected { + reason: reason.clone(), + }); + let _ = codec::write_frame(&mut write_half, &ack).await; + warn!(reason, "bridge IPC: rejected handshake"); + return Ok(()); + } + }; let ack = Frame::HelloAck(HelloAck::Ok { daemon_version: daemon_version(), }); codec::write_frame(&mut write_half, &ack).await?; + let authed_display = identity + .agent_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "".to_string()); + let fingerprint_display = identity + .token_fingerprint + .clone() + .unwrap_or_else(|| "".to_string()); info!( bridge_version = %hello.bridge_version, + token_fingerprint = %fingerprint_display, + authenticated_agent = %authed_display, "bridge IPC: handshake complete" ); @@ -236,7 +287,7 @@ async fn handle_connection( "bridge IPC: dispatching call" ); - let result = dispatch_call(&call, &kernel).await; + let result = dispatch_call(&call, &kernel, identity.agent_id.as_ref()).await; let result_kind = match &result { CallResult::Ok { is_error: false, .. } => "ok", CallResult::Ok { is_error: true, .. } => "tool_error", @@ -270,7 +321,11 @@ async fn handle_connection( /// `is_error` propagated. A tool that ran but returned an error to /// the model is `Ok { is_error: true }`, **not** `Error` — the latter /// means the bridge couldn't even attempt dispatch. -async fn dispatch_call(call: &CallRequest, kernel: &Arc) -> CallResult { +async fn dispatch_call( + call: &CallRequest, + kernel: &Arc, + authenticated_agent_id: Option<&AgentId>, +) -> CallResult { if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { return CallResult::Error { message: format!( @@ -301,16 +356,42 @@ async fn dispatch_call(call: &CallRequest, kernel: &Arc) -> Call let allowed_tools_owned: Vec = ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); + // Identity selection: + // - Hardened path (`authenticated_agent_id == Some`): use the agent_id + // the [`BridgeAuthority`] resolved from the handshake token. The + // bridge's claimed `CallRequest::agent_id` is *ignored* for + // authorization; we only `warn!` if it disagrees with the resolved + // identity, since the disagreement is either a bug in the bridge or + // a spoofing attempt. + // - Legacy path (`authenticated_agent_id == None`): no daemon-issued + // token was presented, so we fall back to the ANAI-30 behavior of + // trusting the bridge's claimed agent_id. This lane closes in the + // next phase once every spawn site issues a real token. + let resolved_agent_id_string: String = match authenticated_agent_id { + Some(authed) => { + let authed_str = authed.to_string(); + if authed_str != call.agent_id { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + claimed = %call.agent_id, + authenticated = %authed_str, + "bridge IPC: claimed agent_id disagrees with authenticated identity; \ + using authenticated identity" + ); + } + authed_str + } + None => call.agent_id.clone(), + }; + let result = openfang_runtime::tool_runner::execute_tool( &format!("bridge-{}", call.request_id), &call.tool_name, &call.args, Some(&kernel_handle), Some(&allowed_tools_owned), - // Identity stub for ANAI-30: trust the bridge's claimed agent_id. - // ANAI-31 replaces this with token-derived identity bound at - // daemon-spawn time. - Some(call.agent_id.as_str()), + Some(resolved_agent_id_string.as_str()), Some(&skill_snapshot), Some(&kernel.mcp_connections), Some(&kernel.web_ctx), @@ -339,27 +420,69 @@ async fn dispatch_call(call: &CallRequest, kernel: &Arc) -> Call } } -/// Validate the bridge's Hello. Returns Err with a human-readable reason on -/// rejection. **Stub for ANAI-30**: we accept any non-empty token. ANAI-31 -/// will replace this with a per-spawn token table populated when the daemon -/// spawns the parent CC subprocess. -fn validate_hello(hello: &Hello) -> Result<(), String> { +/// Authenticate the bridge's Hello against the daemon's [`BridgeAuthority`]. +/// +/// Decision tree: +/// - Version mismatch → `Err` (existing rejection). +/// - Empty/whitespace token → `Err` (existing rejection). +/// - Token parses as 64-hex (`Token::from_hex`) → resolve via authority: +/// - `Some(agent_id)` → `Ok(HandshakeIdentity { agent_id: Some(_), ... })` +/// — hardened path; this is a daemon-issued, live token. +/// - `None` → `Err` — well-formed hex that the authority never issued. +/// This is the attacker / replay / stale-token rejection. +/// - Token is non-empty but not 64-hex → `Ok(HandshakeIdentity { agent_id: +/// None, .. })` — legacy back-compat lane. Logs `debug!` so the operator +/// can see how many legacy handshakes are still happening. +fn authenticate_hello( + hello: &Hello, + authority: &BridgeAuthority, +) -> Result { if hello.protocol_version != PROTOCOL_VERSION { return Err(format!( "protocol version mismatch: bridge={} daemon={}", hello.protocol_version, PROTOCOL_VERSION )); } - if hello.token.trim().is_empty() { + let presented = hello.token.trim(); + if presented.is_empty() { return Err("empty auth token".to_string()); } - Ok(()) + + match Token::from_hex(presented) { + Ok(token) => { + let fingerprint = token.fingerprint(); + match authority.resolve(&token) { + Some(agent_id) => Ok(HandshakeIdentity { + agent_id: Some(agent_id), + token_fingerprint: Some(fingerprint), + }), + None => Err(format!( + "unknown bridge token (fingerprint={fingerprint}); \ + the daemon never issued this token or its spawn has terminated" + )), + } + } + Err(_) => { + // Non-hex tokens are still accepted for back-compat with drivers + // built before TokenIssuer wiring reached every spawn site. The + // bridge's claimed agent_id is taken at face value in this mode. + debug!( + "bridge IPC: legacy-format auth token (not 64-hex); \ + falling back to self-claimed agent_id" + ); + Ok(HandshakeIdentity { + agent_id: None, + token_fingerprint: None, + }) + } + } } #[cfg(test)] mod tests { use super::*; use openfang_mcp_bridge::protocol::{CallRequest, CallResult}; + use openfang_runtime::bridge_auth::TokenIssuer; use tokio::io::BufReader; use tokio::net::UnixStream as ClientStream; @@ -388,19 +511,30 @@ mod tests { let sock = tmp.path().join("bridge.sock"); let listener = UnixListener::bind(&sock).unwrap(); + // Spin up a real authority and issue a token for an agent. The twin + // resolves the handshake token through it, exercising the hardened + // auth path end-to-end (handshake → resolve → AgentId binding). + let authority = BridgeAuthority::new(); + let agent_id = AgentId::new(); + let guard = authority.issue(agent_id); + let presented_token = guard.token().to_hex(); + + let server_authority = authority.clone(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); - handle_connection_test_twin(stream).await.unwrap(); + handle_connection_test_twin(stream, server_authority) + .await + .unwrap(); }); let mut client = ClientStream::connect(&sock).await.unwrap(); let (cr, mut cw) = client.split(); let mut cr = BufReader::new(cr); - // Handshake. + // Handshake — real hex token resolves to `agent_id` via authority. let hello = Frame::Hello(Hello { protocol_version: PROTOCOL_VERSION, - token: "stub-token".into(), + token: presented_token, bridge_version: "test".into(), }); codec::write_frame(&mut cw, &hello).await.unwrap(); @@ -460,6 +594,11 @@ mod tests { drop(client); server.await.unwrap(); + + // Token guard outlives the twin's reads. Drop now so the spawn + // table empties before we exit the test (sanity check on lifetimes). + drop(guard); + assert_eq!(authority.live_spawn_count(), 0); } /// Test-only twin of [`handle_connection`]. @@ -468,7 +607,10 @@ mod tests { /// request loop + allowlist gate) but stubs the runtime dispatch /// because we can't synthesize an `OpenFangKernel` in unit tests. /// If the production handler's wire shape diverges, update this twin. - async fn handle_connection_test_twin(mut stream: UnixStream) -> std::io::Result<()> { + async fn handle_connection_test_twin( + mut stream: UnixStream, + authority: Arc, + ) -> std::io::Result<()> { let (read_half, mut write_half) = stream.split(); let mut read_half = BufReader::new(read_half); @@ -476,7 +618,7 @@ mod tests { Frame::Hello(h) => h, _ => return Ok(()), }; - if let Err(reason) = validate_hello(&hello) { + if let Err(reason) = authenticate_hello(&hello, &authority) { let _ = codec::write_frame( &mut write_half, &Frame::HelloAck(HelloAck::Rejected { reason }), @@ -532,32 +674,81 @@ mod tests { } #[test] - fn validate_hello_rejects_version_mismatch() { + fn authenticate_hello_rejects_version_mismatch() { + let authority = BridgeAuthority::new(); let h = Hello { protocol_version: 999, token: "x".into(), bridge_version: "t".into(), }; - assert!(validate_hello(&h).is_err()); + assert!(authenticate_hello(&h, &authority).is_err()); } #[test] - fn validate_hello_rejects_empty_token() { + fn authenticate_hello_rejects_empty_token() { + let authority = BridgeAuthority::new(); let h = Hello { protocol_version: PROTOCOL_VERSION, token: "".into(), bridge_version: "t".into(), }; - assert!(validate_hello(&h).is_err()); + assert!(authenticate_hello(&h, &authority).is_err()); + } + + #[test] + fn authenticate_hello_resolves_authority_token() { + // Hardened path: hex-encoded daemon-issued token → AgentId. + let authority = BridgeAuthority::new(); + let agent_id = AgentId::new(); + let guard = authority.issue(agent_id); + let h = Hello { + protocol_version: PROTOCOL_VERSION, + token: guard.token().to_hex(), + bridge_version: "t".into(), + }; + let identity = + authenticate_hello(&h, &authority).expect("hardened path should succeed"); + assert_eq!(identity.agent_id, Some(agent_id)); + assert_eq!(identity.token_fingerprint, Some(guard.fingerprint())); + } + + #[test] + fn authenticate_hello_rejects_unknown_hex_token() { + // Well-formed 64-hex token the authority never issued — attacker / + // replay / stale-spawn case. Must be rejected outright; no legacy + // fallback for well-formed-but-unknown tokens. + let authority = BridgeAuthority::new(); + let stranger = Token::generate(); + let h = Hello { + protocol_version: PROTOCOL_VERSION, + token: stranger.to_hex(), + bridge_version: "t".into(), + }; + let err = authenticate_hello(&h, &authority).expect_err("unknown hex must reject"); + assert!( + err.contains("unknown bridge token"), + "expected unknown-token rejection, got: {err}" + ); } #[test] - fn validate_hello_accepts_nonempty_token() { + fn authenticate_hello_accepts_legacy_non_hex_token() { + // Back-compat lane: a non-hex non-empty token (e.g. legacy UUID) + // resolves to `agent_id: None`, signaling the dispatcher to fall + // back to the bridge's self-claimed agent_id. Closes when every + // spawn site issues real tokens. + let authority = BridgeAuthority::new(); let h = Hello { protocol_version: PROTOCOL_VERSION, - token: "tok".into(), + token: "550e8400-e29b-41d4-a716-446655440000".into(), bridge_version: "t".into(), }; - assert!(validate_hello(&h).is_ok()); + let identity = + authenticate_hello(&h, &authority).expect("legacy path should succeed"); + assert!(identity.agent_id.is_none(), "legacy path must not bind agent_id"); + assert!( + identity.token_fingerprint.is_none(), + "legacy path has no fingerprint" + ); } } diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a8a257ee54..6c98aff464 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -821,10 +821,11 @@ pub async fn run_daemon( // non-unix builds the kernel keeps its `token_issuer` slot empty and // drivers fall back to the legacy UUID path. #[cfg(unix)] - { - let bridge_authority = crate::bridge_auth::BridgeAuthority::new(); - kernel.set_token_issuer(bridge_authority); - } + let bridge_authority = { + let authority = crate::bridge_auth::BridgeAuthority::new(); + kernel.set_token_issuer(authority.clone()); + authority + }; kernel.start_background_agents(); @@ -879,7 +880,12 @@ pub async fn run_daemon( ); } #[cfg(unix)] - let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start(kernel.clone()).await { + let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start( + kernel.clone(), + bridge_authority.clone(), + ) + .await + { Ok(h) => { // Publish discovery env vars so subprocess drivers (Claude Code, // future Codex-style) can find the socket and the bridge binary. From d73cd4dc8f4f0953729d85307dfcad2dcf9aa488 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 15:29:23 -0700 Subject: [PATCH 015/105] feat(kernel): construct TokenIssuer before boot to harden autostart agents Phase E of the bridge tool surface v2 work closes a boot-time loophole left by phases C1/C2/D: agents brought up during kernel boot (autostart + persisted) were instantiated before the post-boot `set_token_issuer` call, so their drivers were baked with legacy-UUID identity even though every later code path was already on the hardened BridgeAuthority/TokenIssuer track. Changes: - kernel: add `boot_with_config_and_issuer(config, Option>)`; the existing `boot_with_config` becomes a thin wrapper passing `None`. The issuer is stashed into the kernel's `token_issuer` slot before the boot-time driver chain is built, so the three in-boot `create_driver` sites see the hardened path. Adds two unit tests covering both entry points. - cli/main: construct `BridgeAuthority` before kernel boot and pass `Some(issuer)` into the new entrypoint; thread the same `Arc` into `run_daemon`. - server::run_daemon: gain `#[cfg(unix)] bridge_authority` param; remove the now-redundant post-boot `set_token_issuer` call. - bridge_auth: add `as_token_issuer()` ergonomic helper. - bridge_ipc: comment refresh - the legacy lane is now reserved for non-unix / non-daemon callers only. Tests: 307/307 green, including the two new boot_with_config tests. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_auth.rs | 9 ++ crates/openfang-api/src/bridge_ipc.rs | 15 +-- crates/openfang-api/src/server.rs | 23 ++-- crates/openfang-cli/src/main.rs | 28 ++++- crates/openfang-kernel/src/kernel.rs | 139 ++++++++++++++++++++++--- 5 files changed, 176 insertions(+), 38 deletions(-) diff --git a/crates/openfang-api/src/bridge_auth.rs b/crates/openfang-api/src/bridge_auth.rs index 719bdd2f7b..8c153381e4 100644 --- a/crates/openfang-api/src/bridge_auth.rs +++ b/crates/openfang-api/src/bridge_auth.rs @@ -102,6 +102,15 @@ impl BridgeAuthority { .len() } + /// Cast an `Arc` into the trait-object form + /// `Arc`. Ergonomic helper for callers in + /// `openfang-cli` / `openfang-api::server` that need to hand the issuer + /// to `OpenFangKernel::boot_with_config_and_issuer` without naming the + /// `TokenIssuer` trait themselves. ANAI-31 phase E. + pub fn as_token_issuer(self: &Arc) -> Arc { + self.clone() as Arc + } + /// Upgrade the stashed `Weak` into the `Arc` that /// each [`SpawnGuard`] holds. Panics if the authority has been dropped /// while a method on it is still running — impossible in practice, since diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index b18113cf91..adedfe6f73 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -195,13 +195,14 @@ impl Drop for BridgeIpcServer { /// connection. /// - `agent_id == None` is the **legacy path**: the token is non-empty but /// not in 64-hex form, so it can't be a daemon-issued token. We accept it -/// for back-compat with drivers built before [`TokenIssuer`] wiring -/// reached every spawn site (the daemon's boot-time `create_driver` -/// calls in `boot_with_config` still emit legacy UUIDs because they run -/// before `set_token_issuer`). In this mode the bridge's claimed -/// `agent_id` is taken at face value — same trust model as ANAI-30. The -/// legacy lane is closed in the next phase by fixing boot ordering and -/// then making `authenticate_hello` strict. +/// for back-compat with non-unix builds and any caller that constructs a +/// kernel via `boot_with_config` (no issuer) — tests, desktop embeds, CLI +/// one-shots. Phase E closed the daemon-side boot ordering loophole, so on +/// a unix daemon every spawn site now sees an issuer and well-formed hex +/// tokens are the norm. In this legacy mode the bridge's claimed `agent_id` +/// is taken at face value — same trust model as ANAI-30. A future strict +/// mode can reject this arm outright once all supported deployments are on +/// the hardened path. /// /// `token_fingerprint` is the first 32 bits of the resolved token, suitable /// for log correlation. `None` on the legacy path. diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 6c98aff464..d20a6525a0 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -804,28 +804,25 @@ pub async fn run_daemon( kernel: OpenFangKernel, listen_addr: &str, daemon_info_path: Option<&Path>, + #[cfg(unix)] bridge_authority: Arc, ) -> Result<(), Box> { let addr: SocketAddr = listen_addr.parse()?; let kernel = Arc::new(kernel); kernel.set_self_handle(); - // Phase C2: construct the bridge `TokenIssuer` (an `Arc`) - // and hand it to the kernel before background agents start. From here on, - // `resolve_driver` and agent-loop fallback paths thread the issuer into - // every new Claude Code driver, swapping the legacy UUID env-var for the - // daemon-issued, single-use, fingerprint-tracked bridge token. + // Phase E: the daemon constructs `BridgeAuthority` *before* booting the + // kernel and threads it into `boot_with_config_and_issuer`, so the + // kernel's `token_issuer` slot is populated atomically with the boot + // driver chain. The old C2 wiring (build kernel → wrap in Arc → + // `set_token_issuer`) left a window in which boot-time `create_driver` + // calls used `None`, baking long-lived legacy-UUID drivers into + // autostart/persisted agents. That window is now closed. // // Unix-only: `BridgeAuthority` lives in `bridge_auth` which is gated to // unix targets along with the rest of the bridge IPC plumbing. On - // non-unix builds the kernel keeps its `token_issuer` slot empty and - // drivers fall back to the legacy UUID path. - #[cfg(unix)] - let bridge_authority = { - let authority = crate::bridge_auth::BridgeAuthority::new(); - kernel.set_token_issuer(authority.clone()); - authority - }; + // non-unix builds no authority is constructed and the kernel keeps its + // `token_issuer` slot empty. kernel.start_background_agents(); diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 62e728ba97..fb0ab967c9 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1566,7 +1566,23 @@ fn cmd_start(config: Option, yolo: bool) { kernel_config.approval.auto_approve = true; kernel_config.approval.apply_shorthands(); } - let kernel = match OpenFangKernel::boot_with_config(kernel_config) { + // Phase E: construct the bridge token issuer (unix only) *before* + // booting the kernel so boot-time `create_driver` calls in + // `boot_with_config_and_issuer` see the live authority. Threaded + // straight through to `run_daemon` afterwards, where it plugs into + // the IPC handshake validator. + #[cfg(unix)] + let bridge_authority = openfang_api::bridge_auth::BridgeAuthority::new(); + + #[cfg(unix)] + let kernel_issuer = Some(bridge_authority.as_token_issuer()); + #[cfg(not(unix))] + let kernel_issuer = None; + + let kernel = match OpenFangKernel::boot_with_config_and_issuer( + kernel_config, + kernel_issuer, + ) { Ok(k) => k, Err(e) => { boot_kernel_error(&e); @@ -1602,8 +1618,14 @@ fn cmd_start(config: Option, yolo: bool) { ui::hint("Press Ctrl+C to stop the daemon"); ui::blank(); - if let Err(e) = - openfang_api::server::run_daemon(kernel, &listen_addr, Some(&daemon_info_path)).await + if let Err(e) = openfang_api::server::run_daemon( + kernel, + &listen_addr, + Some(&daemon_info_path), + #[cfg(unix)] + bridge_authority, + ) + .await { ui::error(&format!("Daemon error: {e}")); std::process::exit(1); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index b3d04d9142..928d7cc006 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -181,12 +181,13 @@ pub struct OpenFangKernel { agent_msg_locks: dashmap::DashMap>>, /// Weak self-reference for trigger dispatch (set after Arc wrapping). self_handle: OnceLock>, - /// Bridge token issuer — set by the daemon (`openfang-api::server.rs`) after - /// the kernel is wrapped in `Arc`. Threaded into `drivers::create_driver` so - /// the Claude Code driver mints per-spawn, short-lived bridge tokens - /// registered with the IPC dispatcher. `None` until `set_token_issuer` runs; - /// `None` keeps the legacy ANAI-30 UUID path (dev builds, non-unix targets, - /// tests that build a kernel without the bridge daemon). + /// Bridge token issuer — populated by the daemon at boot via + /// `boot_with_config_and_issuer` (ANAI-31 phase E). Threaded into + /// `drivers::create_driver` so the Claude Code driver mints per-spawn, + /// short-lived bridge tokens registered with the IPC dispatcher. `None` + /// for non-daemon callers and non-unix targets, which keeps the legacy + /// ANAI-30 UUID path. The `set_token_issuer` setter is retained for + /// late-install scenarios but is unused by the live daemon path. token_issuer: std::sync::RwLock>>, } @@ -595,7 +596,30 @@ impl OpenFangKernel { } /// Boot the kernel with an explicit configuration. - pub fn boot_with_config(mut config: KernelConfig) -> KernelResult { + /// + /// Equivalent to [`boot_with_config_and_issuer`] with `token_issuer = None`. + /// Retained for non-daemon callers (tests, CLI one-shots, desktop embeds) + /// that do not construct a bridge `TokenIssuer`. The daemon path + /// (`openfang-cli::main` → `run_daemon`) calls the issuer-aware entrypoint + /// directly so boot-time drivers are wired through the hardened token + /// path. See ANAI-31 phase E. + pub fn boot_with_config(config: KernelConfig) -> KernelResult { + Self::boot_with_config_and_issuer(config, None) + } + + /// Boot the kernel with an explicit configuration and an optional + /// bridge token issuer. + /// + /// Daemon entrypoint. The issuer (an `Arc` on unix; `None` + /// elsewhere) is populated into `self.token_issuer` **before** the boot + /// driver chain is constructed, so the three boot-time `create_driver` + /// sites can mint hardened bridge tokens for the Claude Code driver + /// instead of falling back to the legacy ANAI-30 UUID path. Closes the + /// boot-time loophole that survived phases C1/C2/D. + pub fn boot_with_config_and_issuer( + mut config: KernelConfig, + token_issuer: Option>, + ) -> KernelResult { if rustls::crypto::ring::default_provider() .install_default() .is_err() @@ -707,11 +731,11 @@ impl OpenFangKernel { }; // Primary driver failure is non-fatal: the dashboard should remain accessible // even if the LLM provider is misconfigured. Users can fix config via dashboard. - // Boot path runs before `set_token_issuer` (called from - // `openfang-api::server.rs` after `Arc::new(kernel)`), so the issuer is - // necessarily `None` here. Boot-time drivers therefore use the legacy - // ANAI-30 UUID path; resolve_driver below picks up the issuer post-boot. - let primary_result = drivers::create_driver(&driver_config, None); + // Phase E: the daemon entrypoint hands us the `BridgeAuthority` here so + // boot-time drivers get the same hardened token path as post-boot + // resolves. Non-daemon callers (tests, desktop embeds) pass `None` and + // boot-time drivers stay on the legacy UUID lane. + let primary_result = drivers::create_driver(&driver_config, token_issuer.clone()); let mut driver_chain: Vec> = Vec::new(); match &primary_result { @@ -735,7 +759,7 @@ impl OpenFangKernel { // is replacing the *provider*, not the timeout policy. subprocess_timeout_secs: config.default_model.subprocess_timeout_secs, }; - match drivers::create_driver(&auto_config, None) { + match drivers::create_driver(&auto_config, token_issuer.clone()) { Ok(d) => { info!( provider = %provider, @@ -784,7 +808,7 @@ impl OpenFangKernel { skip_permissions: true, subprocess_timeout_secs: fb.subprocess_timeout_secs, }; - match drivers::create_driver(&fb_config, None) { + match drivers::create_driver(&fb_config, token_issuer.clone()) { Ok(d) => { info!( provider = %fb.provider, @@ -1232,7 +1256,11 @@ impl OpenFangKernel { fallback_providers_override: std::sync::RwLock::new(None), agent_msg_locks: dashmap::DashMap::new(), self_handle: OnceLock::new(), - token_issuer: std::sync::RwLock::new(None), + // Phase E: the daemon hands us its `BridgeAuthority` at boot so + // post-boot `resolve_driver` and agent-loop fallback paths see the + // issuer immediately, without depending on a later + // `set_token_issuer` call. Non-daemon callers pass `None`. + token_issuer: std::sync::RwLock::new(token_issuer), }; // Wire HAND.toml load events into the Merkle audit chain so reload @@ -9451,4 +9479,85 @@ system_prompt = "You are a test agent." .expect("read pre-existing"); assert_eq!(contents, "hello", "must not overwrite user files"); } + + /// Phase E: `boot_with_config_and_issuer` populates the kernel's + /// `token_issuer` slot *before* the boot driver chain is built, so + /// post-boot accessors (and any boot-time `create_driver` call) see the + /// issuer immediately. Without this wiring, autostart/persisted agents + /// whose drivers were built at boot would forever emit legacy UUID + /// tokens rather than authority-issued hardened tokens. + #[test] + fn boot_with_config_and_issuer_populates_token_issuer_slot() { + use openfang_runtime::bridge_auth::{SpawnGuard, TokenIssuer}; + use openfang_types::agent::AgentId; + use openfang_types::bridge_auth::Token; + + struct FakeIssuer; + impl TokenIssuer for FakeIssuer { + fn issue(&self, _agent_id: AgentId) -> SpawnGuard { + // Unused in this test — default config uses a non-claude + // provider so `create_driver` never reaches the issuer path. + // If it ever did, `SpawnGuard::new` is still a safe + // construction; the panic below would surface a misuse. + unreachable!("FakeIssuer::issue should not be called during boot") + } + fn revoke(&self, _token: &Token) { + // No-op — the fake holds no spawn table. + } + } + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-kernel-phase-e-test"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + ..KernelConfig::default() + }; + + let issuer: Arc = Arc::new(FakeIssuer); + let kernel = + OpenFangKernel::boot_with_config_and_issuer(config, Some(issuer.clone())) + .expect("kernel boots with issuer"); + + assert!( + kernel.token_issuer().is_some(), + "boot_with_config_and_issuer must populate the token_issuer slot before boot completes" + ); + + // Same `Arc` identity round-trip — proves we stored the exact issuer + // the daemon handed us, not a fresh one constructed elsewhere. + let stored = kernel.token_issuer().unwrap(); + assert!( + Arc::ptr_eq(&stored, &issuer), + "kernel must store the daemon-provided issuer, not a substitute" + ); + + kernel.shutdown(); + } + + /// Phase E back-compat: the wrapper `boot_with_config` still works for + /// non-daemon callers (tests, CLI one-shots, desktop embeds) and leaves + /// the `token_issuer` slot empty. + #[test] + fn boot_with_config_leaves_token_issuer_unset() { + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-kernel-phase-e-noissuer"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + ..KernelConfig::default() + }; + + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + assert!( + kernel.token_issuer().is_none(), + "boot_with_config (no issuer) must leave the token_issuer slot empty" + ); + + kernel.shutdown(); + } } From a2c7b784142b348598f5bdb41b2d27a424521a6d Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 16:35:53 -0700 Subject: [PATCH 016/105] =?UTF-8?q?feat(bridge):=20per-agent=20allowlist?= =?UTF-8?q?=20via=20CompletionRequest=20=E2=86=92=20OPENFANG=5FBRIDGE=5FAL?= =?UTF-8?q?LOWED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge subprocess has been deafer than the rest of the system: every CC spawn fell back to the static four-tool DEFAULT_ALLOWED set inside `openfang-mcp-bridge`, regardless of what the calling agent's `agent.toml` actually permits. Capability/RBAC decisions made by the kernel and agent loop didn't reach the bridge. This commit plumbs the existing source of truth — `agent.toml` → kernel-resolved `available_tools` → agent loop — into the bridge's env via `OPENFANG_BRIDGE_ALLOWED`: - Add `allowed_tools: Option>` to `CompletionRequest`. - Populate at both agent_loop call sites from `available_tools`. - Other 9 construction sites (compactor, routing tests, driver tests, HTTP probe, kernel router probe, fallback test helper) pass `None` — they don't drive bridge subprocesses or don't have an agent context. - Thread the list through `try_build_bridge_mcp_config` → `build_bridge_mcp_config_value`, which now emits `OPENFANG_BRIDGE_ALLOWED` in the per-spawn `--mcp-config` env map. - Strip `OPENFANG_BRIDGE_ALLOWED` in `apply_env_filter` so a CC child never inherits a stale allowlist. - `None` → env absent → bridge falls back to its hard-coded default, matching today's behavior. Empty list is meaningful: it emits the env var as the empty string so the bridge produces an explicit zero-tool surface instead of silently defaulting. Tool surface change: zero. This is pure plumbing; the bridge still only knows about the ANAI-30 four-tool slice. Adding `agent_send` to that slice ships in the next commit. Tests: - New `test_build_bridge_mcp_config_emits_allowed_tools` asserts the comma-separated env var lands in the config. - New `test_build_bridge_mcp_config_emits_empty_allowed_tools_explicitly` pins the empty-list-vs-absent distinction. - Existing `test_build_bridge_mcp_config_shape` updated to assert `OPENFANG_BRIDGE_ALLOWED` is *absent* when no list is supplied. - Existing env-filter test extended to assert the new var is stripped. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/routes.rs | 1 + crates/openfang-kernel/src/kernel.rs | 1 + crates/openfang-runtime/src/agent_loop.rs | 20 +++ crates/openfang-runtime/src/compactor.rs | 2 + .../src/drivers/claude_code.rs | 117 ++++++++++++++++-- .../openfang-runtime/src/drivers/fallback.rs | 1 + crates/openfang-runtime/src/drivers/gemini.rs | 2 + .../openfang-runtime/src/drivers/qwen_code.rs | 1 + crates/openfang-runtime/src/llm_driver.rs | 20 +++ crates/openfang-runtime/src/routing.rs | 1 + 10 files changed, 157 insertions(+), 9 deletions(-) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 4e487c4f37..38fa8ae772 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7965,6 +7965,7 @@ pub async fn test_provider( system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; match driver.complete(test_req).await { Ok(_) => { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 928d7cc006..14e26c7d50 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -2941,6 +2941,7 @@ impl OpenFangKernel { system: Some(manifest.model.system_prompt.clone()), thinking: None, caller_agent_id: None, + allowed_tools: None, }; let (complexity, routed_model) = router.select_model(&probe); info!( diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index b4318efecb..4cb7d3f4ac 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -537,6 +537,16 @@ pub async fn run_agent_loop( system: Some(system_prompt.clone()), thinking: None, caller_agent_id: Some(agent_id_str.clone()), + // Per-agent allowlist for the bridge subprocess: the names of + // every tool this agent is currently permitted to invoke, + // derived from the kernel-resolved `available_tools` (sourced + // from `agent.toml`'s `[capabilities]`/`[exec_policy]`). + // Subprocess drivers thread this into `OPENFANG_BRIDGE_ALLOWED` + // so the bridge's advertised surface matches the agent's + // permissions instead of falling back to the static default. + allowed_tools: Some( + available_tools.iter().map(|t| t.name.clone()).collect(), + ), }; // Notify phase: Thinking @@ -1788,6 +1798,16 @@ pub async fn run_agent_loop_streaming( system: Some(system_prompt.clone()), thinking: None, caller_agent_id: Some(agent_id_str.clone()), + // Per-agent allowlist for the bridge subprocess: the names of + // every tool this agent is currently permitted to invoke, + // derived from the kernel-resolved `available_tools` (sourced + // from `agent.toml`'s `[capabilities]`/`[exec_policy]`). + // Subprocess drivers thread this into `OPENFANG_BRIDGE_ALLOWED` + // so the bridge's advertised surface matches the agent's + // permissions instead of falling back to the static default. + allowed_tools: Some( + available_tools.iter().map(|t| t.name.clone()).collect(), + ), }; // Notify phase: on first iteration emit Streaming; on subsequent diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index f47dde779c..bda7b1edc8 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -470,6 +470,7 @@ async fn summarize_messages( ), thinking: None, caller_agent_id: None, + allowed_tools: None, }; // Retry logic for transient failures @@ -590,6 +591,7 @@ async fn summarize_in_chunks( ), thinking: None, caller_agent_id: None, + allowed_tools: None, }; match driver.complete(merge_request).await { diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 5ffde33c4d..e5c9fa80fc 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -32,6 +32,11 @@ const BRIDGE_SOCKET_ENV: &str = "OPENFANG_BRIDGE_SOCKET"; const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; const BRIDGE_TOKEN_ENV: &str = "OPENFANG_BRIDGE_TOKEN"; const BRIDGE_AGENT_ID_ENV: &str = "OPENFANG_BRIDGE_AGENT_ID"; +/// Optional comma-separated tool allowlist sent to the bridge subprocess. +/// Sourced per-spawn from the calling agent's `available_tools` via +/// `CompletionRequest::allowed_tools`. Absent (env not set) → bridge +/// falls back to its hard-coded `DEFAULT_ALLOWED`. +const BRIDGE_ALLOWED_ENV: &str = "OPENFANG_BRIDGE_ALLOWED"; /// Master kill-switch for the OpenFang MCP bridge. Default off. When unset /// or not in {`1`, `true`}, `try_build_bridge_mcp_config` returns `None` and @@ -307,6 +312,7 @@ impl ClaudeCodeDriver { cmd.env_remove(BRIDGE_BIN_ENV); cmd.env_remove(BRIDGE_TOKEN_ENV); cmd.env_remove(BRIDGE_AGENT_ID_ENV); + cmd.env_remove(BRIDGE_ALLOWED_ENV); // Remove any env var with a sensitive suffix, unless it's CLAUDE_* for (key, _) in std::env::vars() { if key.starts_with("CLAUDE_") { @@ -365,6 +371,7 @@ fn build_bridge_mcp_config_value( bridge_bin: &str, agent_id: &str, token: &str, + allowed_tools: Option<&[String]>, ) -> serde_json::Value { let mut env_map = serde_json::Map::new(); env_map.insert( @@ -379,6 +386,25 @@ fn build_bridge_mcp_config_value( BRIDGE_AGENT_ID_ENV.into(), serde_json::Value::String(agent_id.to_string()), ); + // Per-agent allowlist override for the bridge subprocess. When the + // caller supplies a tool list (sourced from `agent.toml` → + // `available_tools` → `CompletionRequest::allowed_tools`), emit it + // as a comma-separated `OPENFANG_BRIDGE_ALLOWED` env entry; the + // bridge then narrows its advertised + dispatchable surface to the + // intersection with `built_in_tools()`. Absent (None), the bridge + // falls back to its hard-coded `DEFAULT_ALLOWED`. + // + // Empty list is meaningful: it means "no bridge tools permitted." + // We still emit the env var (as the empty string) so the bridge's + // empty-after-trim handling produces an explicit zero-tool surface + // instead of silently falling back to the default. + if let Some(tools) = allowed_tools { + let joined = tools.join(","); + env_map.insert( + BRIDGE_ALLOWED_ENV.into(), + serde_json::Value::String(joined), + ); + } let mut server_entry = serde_json::Map::new(); server_entry.insert( @@ -431,6 +457,7 @@ impl ClaudeCodeDriver { fn try_build_bridge_mcp_config( &self, caller_agent_id: Option<&str>, + allowed_tools: Option<&[String]>, ) -> Option { // Gate first — cheapest check, and when off we want zero side effects: // no temp file, no token generation, no log line beyond trace level. @@ -466,7 +493,13 @@ impl ClaudeCodeDriver { None => (generate_legacy_bridge_token(), None), }; - let cfg = build_bridge_mcp_config_value(&socket, &bridge_bin, agent_id_str, &token_hex); + let cfg = build_bridge_mcp_config_value( + &socket, + &bridge_bin, + agent_id_str, + &token_hex, + allowed_tools, + ); // Place the config next to the socket so cleanup is colocated and the // bridge socket dir already exists with the right permissions. @@ -605,7 +638,10 @@ impl LlmDriver for ClaudeCodeDriver { // the request carries a caller identity. The guard lives for the // remainder of the call; on drop it removes the temp config file. let _bridge_cfg = - self.try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + self.try_build_bridge_mcp_config( + request.caller_agent_id.as_deref(), + request.allowed_tools.as_deref(), + ).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); // `--strict-mcp-config` makes CC ignore any user/global MCP // config that might otherwise merge in — we want exactly @@ -844,7 +880,10 @@ impl LlmDriver for ClaudeCodeDriver { // alive for the rest of the streaming function so the per-spawn // config file outlives the CC subprocess. let _bridge_cfg = - self.try_build_bridge_mcp_config(request.caller_agent_id.as_deref()).map(|cfg| { + self.try_build_bridge_mcp_config( + request.caller_agent_id.as_deref(), + request.allowed_tools.as_deref(), + ).map(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); cmd.arg("--strict-mcp-config"); // Optional --debug — see complete() for rationale. @@ -1125,6 +1164,7 @@ mod tests { system: Some("You are helpful.".to_string()), thinking: None, caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -1162,6 +1202,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -1196,6 +1237,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -1267,7 +1309,7 @@ mod tests { #[test] fn test_apply_env_filter_strips_bridge_discovery_vars() { - // Verifies the filter removes the four bridge-discovery vars so a + // Verifies the filter removes every bridge-discovery var so a // CC subprocess can't accidentally inherit them. The bridge gets // these via `--mcp-config`'s `env` map only. let mut cmd = tokio::process::Command::new("/bin/true"); @@ -1275,11 +1317,12 @@ mod tests { cmd.env(BRIDGE_BIN_ENV, "/usr/local/bin/should-not-survive"); cmd.env(BRIDGE_TOKEN_ENV, "should-not-survive"); cmd.env(BRIDGE_AGENT_ID_ENV, "should-not-survive"); + cmd.env(BRIDGE_ALLOWED_ENV, "file_read,agent_send"); ClaudeCodeDriver::apply_env_filter(&mut cmd); // tokio's Command exposes its env via std::process::Command::get_envs() - // through deref. Walk it; any of the four bridge vars present means + // through deref. Walk it; any of the bridge vars present means // the filter is broken. let std_cmd = cmd.as_std(); for (key, value) in std_cmd.get_envs() { @@ -1288,7 +1331,11 @@ mod tests { let key_str = key.to_string_lossy(); if matches!( key_str.as_ref(), - BRIDGE_SOCKET_ENV | BRIDGE_BIN_ENV | BRIDGE_TOKEN_ENV | BRIDGE_AGENT_ID_ENV + BRIDGE_SOCKET_ENV + | BRIDGE_BIN_ENV + | BRIDGE_TOKEN_ENV + | BRIDGE_AGENT_ID_ENV + | BRIDGE_ALLOWED_ENV ) { assert!( value.is_none(), @@ -1305,6 +1352,7 @@ mod tests { "/usr/local/bin/openfang-mcp-bridge", "agent-uuid-1234", "tok-abc", + None, ); // mcpServers.openfang.{command,args,env} all present with the @@ -1321,8 +1369,9 @@ mod tests { "args must be a JSON array" ); - // env carries exactly the three discovery vars. No more, no less — - // any extras would leak unintended state into the bridge process. + // env carries exactly the three discovery vars when no per-agent + // allowlist is supplied. No more, no less — any extras would leak + // unintended state into the bridge process. let env = server .pointer("/env") .and_then(|v| v.as_object()) @@ -1331,6 +1380,56 @@ mod tests { assert_eq!(env.get(BRIDGE_SOCKET_ENV).and_then(|v| v.as_str()), Some("/home/user/.openfang/run/bridge.sock")); assert_eq!(env.get(BRIDGE_TOKEN_ENV).and_then(|v| v.as_str()), Some("tok-abc")); assert_eq!(env.get(BRIDGE_AGENT_ID_ENV).and_then(|v| v.as_str()), Some("agent-uuid-1234")); + assert!( + env.get(BRIDGE_ALLOWED_ENV).is_none(), + "no allowlist supplied → OPENFANG_BRIDGE_ALLOWED must be absent so the bridge \ + falls back to its hard-coded DEFAULT_ALLOWED" + ); + } + + #[test] + fn test_build_bridge_mcp_config_emits_allowed_tools() { + // When the caller supplies a per-agent allowlist, it lands in the + // env map as a comma-separated OPENFANG_BRIDGE_ALLOWED entry. This + // is the channel that lets the bridge's tool surface track each + // agent's `agent.toml` capabilities instead of the static default. + let cfg = build_bridge_mcp_config_value( + "/sock", + "/bin", + "agent-uuid", + "tok", + Some(&[ + "file_read".to_string(), + "agent_send".to_string(), + "channel_send".to_string(), + ]), + ); + let env = cfg + .pointer("/mcpServers/openfang/env") + .and_then(|v| v.as_object()) + .expect("env object missing"); + assert_eq!(env.len(), 4, "env must add OPENFANG_BRIDGE_ALLOWED"); + assert_eq!( + env.get(BRIDGE_ALLOWED_ENV).and_then(|v| v.as_str()), + Some("file_read,agent_send,channel_send"), + ); + } + + #[test] + fn test_build_bridge_mcp_config_emits_empty_allowed_tools_explicitly() { + // Empty list means "no bridge tools permitted" — emit the env var + // as the empty string so the bridge's parser produces a zero-tool + // surface instead of silently falling back to DEFAULT_ALLOWED. + let cfg = build_bridge_mcp_config_value("/sock", "/bin", "agent-uuid", "tok", Some(&[])); + let env = cfg + .pointer("/mcpServers/openfang/env") + .and_then(|v| v.as_object()) + .expect("env object missing"); + assert_eq!( + env.get(BRIDGE_ALLOWED_ENV).and_then(|v| v.as_str()), + Some(""), + "empty allowlist must still emit the env var (empty string), not be absent" + ); } #[test] @@ -1386,7 +1485,7 @@ mod tests { // construction path. This test owns the gate behavior only. std::env::remove_var(BRIDGE_ENABLED_ENV); let driver = ClaudeCodeDriver::new(None, true); - let cfg = driver.try_build_bridge_mcp_config(Some("agent-x")); + let cfg = driver.try_build_bridge_mcp_config(Some("agent-x"), None); assert!(cfg.is_none(), "gate off → None regardless of other env"); // Restore. diff --git a/crates/openfang-runtime/src/drivers/fallback.rs b/crates/openfang-runtime/src/drivers/fallback.rs index 1c196b143b..74d140579d 100644 --- a/crates/openfang-runtime/src/drivers/fallback.rs +++ b/crates/openfang-runtime/src/drivers/fallback.rs @@ -162,6 +162,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, } } diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index 49a018d065..f09d1ec044 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -1341,6 +1341,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; let tools = convert_tools(&request); @@ -1360,6 +1361,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; let tools = convert_tools(&request); diff --git a/crates/openfang-runtime/src/drivers/qwen_code.rs b/crates/openfang-runtime/src/drivers/qwen_code.rs index 467b233727..276b20a55c 100644 --- a/crates/openfang-runtime/src/drivers/qwen_code.rs +++ b/crates/openfang-runtime/src/drivers/qwen_code.rs @@ -465,6 +465,7 @@ mod tests { system: Some("You are helpful.".to_string()), thinking: None, caller_agent_id: None, + allowed_tools: None, }; let prompt = QwenCodeDriver::build_prompt(&request); diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index a0e5c283c1..cb8cd9cf32 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -76,6 +76,25 @@ pub struct CompletionRequest { /// per-spawn token, but the field stays in place as the integration /// point. pub caller_agent_id: Option, + /// Per-agent tool allowlist for the bridge subprocess, if any. + /// + /// Sourced from the agent's resolved `available_tools` (which in turn + /// derive from `agent.toml`'s `[capabilities].tools` and the kernel's + /// capability gating). When `Some`, subprocess-style drivers that wire + /// the OpenFang MCP bridge emit this list as `OPENFANG_BRIDGE_ALLOWED` + /// on the bridge child's environment, narrowing the bridge's + /// advertised + dispatchable tool surface to the intersection of + /// (a) what `built_in_tools()` knows about and (b) what *this agent* + /// is permitted to call. + /// + /// When `None`, the bridge falls back to its built-in `DEFAULT_ALLOWED` + /// slice — the legacy ANAI-30 behavior. Non-subprocess drivers ignore + /// this field entirely; the bridge is not in their request path. + /// + /// The source of truth remains `agent.toml`. This field is the + /// transport plumb that makes the bridge honor what the rest of the + /// system already decided. + pub allowed_tools: Option>, } /// A response from an LLM completion. @@ -340,6 +359,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, }; let response = driver.stream(request, tx).await.unwrap(); diff --git a/crates/openfang-runtime/src/routing.rs b/crates/openfang-runtime/src/routing.rs index 724dae850a..e098002ef4 100644 --- a/crates/openfang-runtime/src/routing.rs +++ b/crates/openfang-runtime/src/routing.rs @@ -189,6 +189,7 @@ mod tests { system: None, thinking: None, caller_agent_id: None, + allowed_tools: None, } } From 3193c940a4f19da8c4750433cad03e557622c8cc Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 16:38:21 -0700 Subject: [PATCH 017/105] feat(bridge): expose agent_send tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First tool added to the bridge surface past the original ANAI-30 slice. Now that the previous commit makes the bridge honor per-agent capability gates (via OPENFANG_BRIDGE_ALLOWED sourced from each agent's agent.toml), expanding the bridge's *capability* set is safe: agents whose toml does not list agent_send will not see or be able to dispatch it. Schema mirrors `openfang_runtime::tool_runner::builtin_tool_definitions` → agent_send entry. Kept in sync by hand; the bridge crate is runtime-free by design and can't import the source. - `built_in_tools()` in openfang-mcp-bridge gains the agent_send Tool. - `DEFAULT_ALLOWED` in openfang-mcp-bridge/src/main.rs gains agent_send so the legacy/dev path (env var unset) still advertises every tool the bridge can dispatch. Production daemon spawns always set the env. - `bridge_ipc::ALLOWED_TOOLS` (daemon-side ceiling) gains agent_send so the dispatch_call gate doesn't reject it. - `built_in_tools_has_anai30_slice` → `built_in_tools_surface`: now asserts the five-tool set and includes a drift-warning comment. - `permitted_tools_intersects_with_dispatcher_allowed` swaps `agent_send` for `shell_exec` as its "unknown to the bridge" example, since agent_send is no longer unknown. - Bridge `instructions` string updated: drops the ANAI-30 enumeration, explains the gating model instead. Tool surface effect: agents whose agent.toml grants `agent_send` (which already includes the coder agents that have been using it via the legacy path) can now call it through MCP. Agents that don't list it remain unable to. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 22 ++++++++----- crates/openfang-mcp-bridge/src/lib.rs | 43 ++++++++++++++++++++++---- crates/openfang-mcp-bridge/src/main.rs | 12 +++++-- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index adedfe6f73..48786e2dae 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -51,19 +51,27 @@ use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Notify; use tracing::{debug, error, info, warn}; -/// Tools the bridge IPC server is willing to dispatch in the ANAI-30 -/// validation slice. Anything outside this set is rejected at the -/// protocol layer (i.e. it never reaches `execute_tool`). ANAI-31 will -/// replace this static allowlist with per-agent capability lookups -/// driven by `agent.toml`. +/// Tools the bridge IPC server is willing to dispatch. Anything outside +/// this set is rejected at the protocol layer (i.e. it never reaches +/// `execute_tool`). This is the daemon-side ceiling on the bridge surface +/// — the bridge subprocess's `built_in_tools` and per-spawn +/// `OPENFANG_BRIDGE_ALLOWED` are the layers that narrow it further per +/// agent (sourced from `agent.toml`). /// -/// The chosen four exercise the full diversity of tool dependencies: +/// Coverage exercises the full diversity of tool dependencies: /// - `file_read` / `file_list` — workspace-scoped, no kernel needed /// - `agent_list` — requires [`KernelHandle::list_agents`] /// - `channel_send` — requires [`KernelHandle::send_channel_message`], /// one of the OpenFang-only capabilities a CC subprocess wouldn't /// otherwise have -pub const ALLOWED_TOOLS: &[&str] = &["file_read", "file_list", "agent_list", "channel_send"]; +/// - `agent_send` — inter-agent messaging via the kernel +pub const ALLOWED_TOOLS: &[&str] = &[ + "file_read", + "file_list", + "agent_list", + "channel_send", + "agent_send", +]; /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index ce2ae05d34..f7022156af 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -115,11 +115,15 @@ pub enum ToolDispatchError { /// runtime-free by design (see crate-level docs). If the runtime's schemas /// drift, update both sides. /// -/// The set is intentionally limited to the ANAI-30 validation slice: +/// Current surface: /// - `file_read`, `file_list` — workspace-scoped, no kernel dependency /// - `agent_list` — exercises `KernelHandle::list_agents` /// - `channel_send` — exercises `KernelHandle::send_channel_message`, /// one of the OpenFang-only capabilities a bare CC subprocess lacks +/// - `agent_send` — inter-agent messaging; first tool added past the +/// ANAI-30 slice. Per-agent gating via `OPENFANG_BRIDGE_ALLOWED` +/// (sourced from each agent's `agent.toml` capabilities) decides +/// whether any given bridge instance actually advertises it. pub fn built_in_tools() -> Vec { use serde_json::json; @@ -178,6 +182,24 @@ pub fn built_in_tools() -> Vec { "required": ["channel", "recipient"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `agent_send`. Kept in + // sync with that schema by hand; the bridge crate is runtime-free + // by design and can't import the source. Per-agent gating via + // `OPENFANG_BRIDGE_ALLOWED` decides whether this tool is actually + // advertised + dispatchable for any given bridge instance. + Tool::new( + "agent_send", + "Send a message to another agent and receive their response. \ + Accepts UUID or agent name. Use agent_find first to discover agents.", + obj(json!({ + "type": "object", + "properties": { + "agent_id": { "type": "string", "description": "The target agent's UUID or name" }, + "message": { "type": "string", "description": "The message to send to the agent" } + }, + "required": ["agent_id", "message"] + })), + ), ] } @@ -217,7 +239,8 @@ impl ServerHandler for Bridge { .with_instructions( "OpenFang MCP bridge. Exposes OpenFang's tool surface to MCP clients, \ scoped to a single parent agent's identity and capabilities. \ - ANAI-30 surface: file_read, file_list, agent_list, channel_send." + Per-agent gating via OPENFANG_BRIDGE_ALLOWED narrows the advertised \ + set to the calling agent's agent.toml capabilities." .to_string(), ) } @@ -305,12 +328,20 @@ mod tests { } #[test] - fn built_in_tools_has_anai30_slice() { + fn built_in_tools_surface() { let tools = built_in_tools(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); assert_eq!( names, - vec!["file_read", "file_list", "agent_list", "channel_send"] + vec![ + "file_read", + "file_list", + "agent_list", + "channel_send", + "agent_send", + ], + "surface drift — update both this test and the runtime tool_runner \ + schema when adding or removing built-in bridge tools" ); } @@ -319,8 +350,8 @@ mod tests { let bridge = Bridge::new(Arc::new(StubDispatcher { agent: "a".into(), // Dispatcher permits only file_read of the built-in slice; - // agent_send is unknown to the bridge and must be ignored. - allowed: vec!["file_read".into(), "agent_send".into()], + // shell_exec is unknown to the bridge and must be ignored. + allowed: vec!["file_read".into(), "shell_exec".into()], canned: DispatchOk { content: String::new(), is_error: false, diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index a3727f5f6b..b6ae8eda32 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -82,9 +82,17 @@ const AGENT_ID_ENV_VAR: &str = "OPENFANG_BRIDGE_AGENT_ID"; const ALLOWED_ENV_VAR: &str = "OPENFANG_BRIDGE_ALLOWED"; /// Default tool allowlist when [`ALLOWED_ENV_VAR`] is unset. Mirrors the -/// daemon's `bridge_ipc::ALLOWED_TOOLS`. +/// daemon's `bridge_ipc::ALLOWED_TOOLS`. Tracks the bridge's `built_in_tools` +/// surface so a bridge spawned without per-agent gating (legacy/dev path) +/// still advertises everything it's capable of dispatching. #[cfg(unix)] -const DEFAULT_ALLOWED: &[&str] = &["file_read", "file_list", "agent_list", "channel_send"]; +const DEFAULT_ALLOWED: &[&str] = &[ + "file_read", + "file_list", + "agent_list", + "channel_send", + "agent_send", +]; #[cfg(unix)] #[tokio::main] From ee7edd592ef89811608ec2639cf6e0d7da0deb7d Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 19:15:43 -0700 Subject: [PATCH 018/105] fix(bridge): sandbox filesystem tools to agent workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge IPC path (`bridge_ipc::dispatch_call`) and HTTP `/mcp` endpoint both passed `workspace_root: None` to `execute_tool`, which caused `resolve_file_path` to fall through to a `..`-only validation check. Absolute paths bypassed it entirely and bare-relative paths resolved against the daemon CWD (`~/.openfang`), allowing any agent advertised `mcp__openfang__file_read`/`file_list` to read every sibling workspace plus `secrets.env` and GCP service-account JSON sitting at the openfang root. Fix: - bridge_ipc: look up workspace via authenticated `AgentId` → registry manifest. Refuse FS tools when no workspace is registered rather than silently falling back to an unscoped view. - routes (`POST /mcp`): same gate; HTTP has no ambient agent identity, so callers must pass `_agent_id` in arguments to scope FS calls. Kernel-level tools (agent_list, channel_send, etc.) keep working without `_agent_id` since they don't touch the filesystem. - Both paths now thread `workspace_root` through to `execute_tool`, so `workspace_sandbox::resolve_sandbox_path` actually runs — canonicalize- then-prefix-check blocks absolute escapes and symlink traversal; the existing `..` denial covers relative traversal. Smoke (post-rebuild, from `coder-learn-rust` with file_read/list): - `secrets.env` (bare relative) → ENOENT inside sandboxed workspace - `/Users/rlyeh/.openfang/secrets.env` → "Access denied: resolves outside workspace" - `../assistant/IDENTITY.md` → "Path traversal denied" - `IDENTITY.md` → success (workspace-relative legitimate read works) - `file_list .` → returns the agent's own workspace contents only; zero openfang-root entries Deferred: defensive log on disallowed-tool invocation at execution time (thread C), and removing `tool_runner`'s `workspace_root: None` legacy fallback (used only by tests). Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 57 +++++++++++++++++++++++++-- crates/openfang-api/src/routes.rs | 45 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 48786e2dae..018e409175 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -45,7 +45,7 @@ use openfang_mcp_bridge::protocol::{ }; use openfang_types::agent::AgentId; use openfang_types::bridge_auth::Token; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Notify; @@ -73,6 +73,20 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "agent_send", ]; +/// Subset of [`ALLOWED_TOOLS`] that operates on the agent's workspace +/// filesystem. These tools MUST be invoked with a sandbox-scoping +/// `workspace_root` — see the sandbox check in [`dispatch_call`]. +/// +/// History: prior to D-fix, the bridge passed `workspace_root: None` to +/// `execute_tool`, which fell through `resolve_file_path`'s "legacy" +/// branch and resolved paths against the daemon CWD (`~/.openfang`). That +/// let any agent with `file_read`/`file_list` advertised on its surface +/// read every sibling workspace plus `secrets.env` and the GCP service- +/// account JSON sitting at the openfang root. The fix below scopes every +/// FS call to the *authenticated* agent's workspace and refuses the call +/// outright when no workspace is registered. +const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list"]; + /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { env!("CARGO_PKG_VERSION").to_string() @@ -394,6 +408,43 @@ async fn dispatch_call( None => call.agent_id.clone(), }; + // Workspace lookup for sandbox scoping. Authenticated identity wins + // (it's what the daemon issued the token for); the legacy lane parses + // the bridge's self-claimed `call.agent_id` only when no token-bound + // identity is available. Either way, we resolve to a real `AgentId` + // before touching the registry — string identifiers never feed the + // workspace lookup. + let resolved_agent_id_for_lookup: Option = match authenticated_agent_id { + Some(authed) => Some(*authed), + None => call.agent_id.parse::().ok(), + }; + let workspace_path: Option = resolved_agent_id_for_lookup + .and_then(|aid| kernel.registry.get(aid)) + .and_then(|entry| entry.manifest.workspace.clone()); + + // Fail-closed for filesystem tools without a workspace. The runtime's + // `resolve_file_path` falls through to a path-traversal-only check + // when `workspace_root` is `None`, which is insufficient — absolute + // paths bypass it and relative paths resolve against the daemon CWD. + // Refusing the call here keeps the leak closed even if some future + // call path forgets to pass workspace_root. + if FS_SANDBOXED_TOOLS.contains(&call.tool_name.as_str()) && workspace_path.is_none() { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + "bridge IPC: refusing filesystem tool — no workspace registered for agent" + ); + return CallResult::Error { + message: format!( + "tool '{}' requires an agent workspace, but no workspace is registered \ + for agent '{}' — refusing to fall back to an unscoped filesystem view", + call.tool_name, resolved_agent_id_string + ), + }; + } + let workspace_root_arg: Option<&Path> = workspace_path.as_deref(); + let result = openfang_runtime::tool_runner::execute_tool( &format!("bridge-{}", call.request_id), &call.tool_name, @@ -405,8 +456,8 @@ async fn dispatch_call( Some(&kernel.mcp_connections), Some(&kernel.web_ctx), Some(&kernel.browser_ctx), - None, // allowed_env_vars — unused by the four allowlisted tools - None, // workspace_root — file_read/file_list use input-relative paths + None, // allowed_env_vars — unused by the five allowlisted tools + workspace_root_arg, // scoped to the authenticated agent's workspace; gated above Some(&kernel.media_engine), None, // exec_policy — shell tools not in allowlist if kernel.config.tts.enabled { diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 38fa8ae772..fb1d015975 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7056,6 +7056,46 @@ pub async fn mcp_http( })); } + // ── Workspace sandbox for filesystem tools ─────────────────────── + // + // Mirrors the bridge IPC fix: filesystem-touching tools must be + // scoped to a real agent workspace. The HTTP endpoint has no + // ambient agent identity (it sits behind dashboard auth, not + // bridge-token auth), so the caller must opt in by supplying + // `_agent_id` in `arguments`. We look up the workspace and pass + // it to `execute_tool`; if the resolved agent has no workspace + // or the caller didn't supply an id, we refuse the FS call + // rather than fall through to the unscoped legacy path. Kernel- + // level tools (agent_list, channel_send, etc.) continue to work + // without an `_agent_id` since they don't touch the filesystem. + const FS_SANDBOXED_TOOLS: &[&str] = &[ + "file_read", + "file_list", + "file_write", + "create_directory", + ]; + let agent_id_opt: Option = arguments + .get("_agent_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()); + let workspace_path: Option = agent_id_opt + .and_then(|aid| state.kernel.registry.get(aid)) + .and_then(|entry| entry.manifest.workspace.clone()); + if FS_SANDBOXED_TOOLS.contains(&tool_name) && workspace_path.is_none() { + return Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned(), + "error": { + "code": -32602, + "message": format!( + "tool '{tool_name}' requires an agent workspace; \ + pass `_agent_id` in arguments to scope the call. \ + Refusing to dispatch against an unscoped filesystem." + ) + } + })); + } + // Snapshot skill registry before async call (RwLockReadGuard is !Send) let skill_snapshot = state .kernel @@ -7067,19 +7107,20 @@ pub async fn mcp_http( // Execute the tool via the kernel's tool runner let kernel_handle: Arc = state.kernel.clone() as Arc; + let agent_id_string: Option = agent_id_opt.as_ref().map(|a| a.to_string()); let result = openfang_runtime::tool_runner::execute_tool( "mcp-http", tool_name, &arguments, Some(&kernel_handle), None, - None, + agent_id_string.as_deref(), Some(&skill_snapshot), Some(&state.kernel.mcp_connections), Some(&state.kernel.web_ctx), Some(&state.kernel.browser_ctx), None, - None, + workspace_path.as_deref(), Some(&state.kernel.media_engine), None, // exec_policy if state.kernel.config.tts.enabled { From 34c63df9cc8b09ba44a222afab00d346e3c14cf3 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 19:48:10 -0700 Subject: [PATCH 019/105] feat(bridge): execute-time per-agent permission gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth against a misbehaving subprocess crafting JSON-RPC calls to tools outside its advertised set. Restructures dispatch_call into three ordered gates: 1. Static bridge allowlist (unchanged) — reject unknown tools pre-identity. 2. Identity resolution (hardened) — authenticated AgentId wins; legacy lane requires claimed string to parse as AgentId AND have a registry entry. Closes the "trust the claimed string" loophole (ANAI-30 follow-up). 3. Per-agent permission gate (new) — calls the canonical resolver kernel.available_tools_with_registry + AgentMode::filter_tools, the exact pair agent_loop uses to build OPENFANG_BRIDGE_ALLOWED at spawn. Advertise-time and execute-time gates cannot drift. 4. Workspace sandbox (D-fix, unchanged) — FS tools require a workspace. Snapshot construction now matches agent_loop's bundled→global→workspace layering, so workspace skill overrides are visible bridge-side. Rejections log warn! with request_id, tool, agent, mode, permitted_count. Caller sees a generic "tool 'X' not permitted for this agent". Promotes available_tools_with_registry from fn to pub fn. Verified end-to-end via raw IPC frames: rejection on agent_list for coder-learn-rust (Gate 2), positive path on file_read, identity gate rejection for unregistered UUID and unparseable legacy claim, and Gate 1 rejection for shell_exec outside bridge surface. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 167 ++++++++++++++++++-------- crates/openfang-kernel/src/kernel.rs | 2 +- 2 files changed, 121 insertions(+), 48 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 018e409175..092e831056 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -349,6 +349,10 @@ async fn dispatch_call( kernel: &Arc, authenticated_agent_id: Option<&AgentId>, ) -> CallResult { + // --- Gate 1: static bridge-surface allowlist ---------------------------- + // The hard ceiling on what the bridge will ever dispatch. Independent of + // any agent's per-agent surface; an unknown tool never reaches identity + // resolution. if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { return CallResult::Error { message: format!( @@ -358,39 +362,14 @@ async fn dispatch_call( }; } - // Snapshot the skill registry before crossing the await — its read - // guard is !Send and execute_tool spans `.await` points internally. - let skill_snapshot = kernel - .skill_registry - .read() - .unwrap_or_else(|e| e.into_inner()) - .snapshot(); - - // Build the kernel handle. Cloning the Arc is cheap; the cast to - // `dyn KernelHandle` is the same upcast the HTTP /mcp endpoint - // performs. - let kernel_handle: Arc = - kernel.clone() as Arc; - - // execute_tool also enforces an allowlist via its `allowed_tools` - // parameter; passing our four-tool set makes the runtime's check - // belt-and-suspenders with ours. If the two ever drift, the runtime's - // is authoritative — it sits closer to the actual tool implementations. - let allowed_tools_owned: Vec = - ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); - - // Identity selection: - // - Hardened path (`authenticated_agent_id == Some`): use the agent_id - // the [`BridgeAuthority`] resolved from the handshake token. The - // bridge's claimed `CallRequest::agent_id` is *ignored* for - // authorization; we only `warn!` if it disagrees with the resolved - // identity, since the disagreement is either a bug in the bridge or - // a spoofing attempt. - // - Legacy path (`authenticated_agent_id == None`): no daemon-issued - // token was presented, so we fall back to the ANAI-30 behavior of - // trusting the bridge's claimed agent_id. This lane closes in the - // next phase once every spawn site issues a real token. - let resolved_agent_id_string: String = match authenticated_agent_id { + // --- Identity resolution (fail-closed) --------------------------------- + // Hardened path: handshake-bound AgentId from BridgeAuthority. Legacy + // path: parse the bridge's self-claimed `call.agent_id`. Either way we + // require a *registered* AgentId before proceeding — string identifiers + // and unknown agents never feed authorization. Closes the ANAI-30 + // "trust the claimed string" loophole: a parseable but unregistered id + // (random UUID-shaped value) now rejects instead of falling through. + let resolved_agent_id: AgentId = match authenticated_agent_id { Some(authed) => { let authed_str = authed.to_string(); if authed_str != call.agent_id { @@ -403,25 +382,119 @@ async fn dispatch_call( using authenticated identity" ); } - authed_str + *authed + } + None => match call.agent_id.parse::() { + Ok(aid) => aid, + Err(_) => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + claimed = %call.agent_id, + "bridge IPC: rejecting call — legacy lane and claimed agent_id \ + does not parse as AgentId" + ); + return CallResult::Error { + message: "unresolvable agent identity for bridge call".to_string(), + }; + } + }, + }; + let resolved_agent_id_string = resolved_agent_id.to_string(); + + // Registry entry is the source of truth for capabilities and workspace. + // Missing entry → fail closed (spoofed AgentId, dead spawn, etc.). + let entry = match kernel.registry.get(resolved_agent_id) { + Some(e) => e, + None => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + "bridge IPC: rejecting call — no registry entry for resolved agent" + ); + return CallResult::Error { + message: format!( + "agent '{resolved_agent_id_string}' has no registry entry; refusing call" + ), + }; } - None => call.agent_id.clone(), }; - // Workspace lookup for sandbox scoping. Authenticated identity wins - // (it's what the daemon issued the token for); the legacy lane parses - // the bridge's self-claimed `call.agent_id` only when no token-bound - // identity is available. Either way, we resolve to a real `AgentId` - // before touching the registry — string identifiers never feed the - // workspace lookup. - let resolved_agent_id_for_lookup: Option = match authenticated_agent_id { - Some(authed) => Some(*authed), - None => call.agent_id.parse::().ok(), + // --- Workspace-aware skill snapshot ------------------------------------ + // Mirrors the agent_loop pattern (kernel.rs:2192-2210): bundled + global + // + workspace skills, in that override order. We reuse this snapshot for + // BOTH the per-agent permission gate below AND `execute_tool` later — so + // the permission decision and the runtime see the same tool universe. + let workspace_path: Option = entry.manifest.workspace.clone(); + let skill_snapshot = { + let mut snapshot = kernel + .skill_registry + .read() + .unwrap_or_else(|e| e.into_inner()) + .snapshot(); + if let Some(ref workspace) = workspace_path { + let ws_skills = workspace.join("skills"); + if ws_skills.exists() { + if let Err(e) = snapshot.load_workspace_skills(&ws_skills) { + warn!( + agent = %resolved_agent_id_string, + error = %e, + "bridge IPC: failed to load workspace skills for permission gate" + ); + } + } + } + snapshot + }; + + // --- Gate 2: per-agent execute-time permission gate (ANAI C) ----------- + // Belt-and-suspenders with the advertise-time `OPENFANG_BRIDGE_ALLOWED` + // env var the bridge subprocess was spawned with. Uses the same kernel + // resolver agent_loop uses to build the env (kernel.rs:2214) against + // the same registry entry — so the two gates can't drift. Any tool not + // in this agent's resolved surface (capabilities.tools narrowed by + // profile, allowlist, blocklist, skills, mcp_servers, mode filter) is + // rejected here with a logged trace, even if it survived gate 1. + // + // Runs *before* the workspace sandbox gate so a denied call never + // touches the filesystem lookup. + let permitted: Vec = { + let resolved = + kernel.available_tools_with_registry(resolved_agent_id, Some(&skill_snapshot)); + entry.mode.filter_tools(resolved) }; - let workspace_path: Option = resolved_agent_id_for_lookup - .and_then(|aid| kernel.registry.get(aid)) - .and_then(|entry| entry.manifest.workspace.clone()); + if !permitted.iter().any(|t| t.name == call.tool_name) { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + mode = ?entry.mode, + permitted_count = permitted.len(), + "bridge IPC: rejecting tool not in agent's permitted set" + ); + return CallResult::Error { + message: format!( + "tool '{}' not permitted for this agent", + call.tool_name + ), + }; + } + + // Build the kernel handle. Cloning the Arc is cheap; the cast to + // `dyn KernelHandle` is the same upcast the HTTP /mcp endpoint + // performs. + let kernel_handle: Arc = + kernel.clone() as Arc; + + // execute_tool also enforces an allowlist via its `allowed_tools` + // parameter; passing our four-tool set makes the runtime's check + // belt-and-suspenders with ours. If the two ever drift, the runtime's + // is authoritative — it sits closer to the actual tool implementations. + let allowed_tools_owned: Vec = + ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); + // --- Gate 3: workspace sandbox for filesystem tools (D-fix) ------------ // Fail-closed for filesystem tools without a workspace. The runtime's // `resolve_file_path` falls through to a path-traversal-only check // when `workspace_root` is `None`, which is insufficient — absolute diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 14e26c7d50..b14a291851 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -6209,7 +6209,7 @@ impl OpenFangKernel { /// snapshot (which already includes global + workspace skills with correct /// override priority). When `None`, falls back to `self.skill_registry` /// (global-only, for diagnostic/non-agent callers). - fn available_tools_with_registry( + pub fn available_tools_with_registry( &self, agent_id: AgentId, skill_snapshot: Option<&openfang_skills::registry::SkillRegistry>, From 908c39cc0ee4e1162864535bd6d05ce8b627ba7d Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 21:14:58 -0700 Subject: [PATCH 020/105] feat(bridge): expose file_write and web_fetch tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add file_write and web_fetch to the bridge tool surface so CC-backed agents reach parity with API-backed agents for FS-write and HTTP-out. Both inherit the existing four-gate pipeline (static allowlist, identity, per-agent permission via available_tools_with_registry, FS sandbox). - openfang-mcp-bridge: hand-mirrored schemas in built_in_tools(); drift catcher test updated. Both added to DEFAULT_ALLOWED. - openfang-api/bridge_ipc: both added to ALLOWED_TOOLS. file_write added to FS_SANDBOXED_TOOLS; web_fetch deliberately not (no FS touch — SSRF guardrails live in the runtime impl, including the external-content delimiter wrapper). Smoke (raw-IPC harness): permitted positive + unpermitted negative for each tool, all four pass with matching daemon warn lines. --- crates/openfang-api/src/bridge_ipc.rs | 4 ++- crates/openfang-mcp-bridge/src/lib.rs | 35 ++++++++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 092e831056..91ec970ba7 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -68,6 +68,8 @@ use tracing::{debug, error, info, warn}; pub const ALLOWED_TOOLS: &[&str] = &[ "file_read", "file_list", + "file_write", + "web_fetch", "agent_list", "channel_send", "agent_send", @@ -85,7 +87,7 @@ pub const ALLOWED_TOOLS: &[&str] = &[ /// account JSON sitting at the openfang root. The fix below scopes every /// FS call to the *authenticated* agent's workspace and refuses the call /// outright when no workspace is registered. -const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list"]; +const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list", "file_write"]; /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index f7022156af..050ddc3db9 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -157,6 +157,39 @@ pub fn built_in_tools() -> Vec { "required": ["path"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `file_write`. Workspace- + // scoped via the daemon-side `FS_SANDBOXED_TOOLS` gate in `bridge_ipc`. + Tool::new( + "file_write", + "Write content to a file. Paths are relative to the agent workspace.", + obj(json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "The file path to write to" }, + "content": { "type": "string", "description": "The content to write" } + }, + "required": ["path", "content"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `web_fetch`. No FS touch, + // so it does not appear in `FS_SANDBOXED_TOOLS`; SSRF protection lives + // in the runtime implementation. + Tool::new( + "web_fetch", + "Fetch a URL with SSRF protection. Supports GET/POST/PUT/PATCH/DELETE. \ + For GET, HTML is converted to Markdown. For other methods, returns raw \ + response body.", + obj(json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "The URL to fetch (http/https only)" }, + "method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" }, + "headers": { "type": "object", "description": "Custom HTTP headers as key-value pairs" }, + "body": { "type": "string", "description": "Request body for POST/PUT/PATCH" } + }, + "required": ["url"] + })), + ), Tool::new( "agent_list", "List all currently running agents with their IDs, names, states, and models.", @@ -336,6 +369,8 @@ mod tests { vec![ "file_read", "file_list", + "file_write", + "web_fetch", "agent_list", "channel_send", "agent_send", diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index b6ae8eda32..f2d6ccff98 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -89,6 +89,8 @@ const ALLOWED_ENV_VAR: &str = "OPENFANG_BRIDGE_ALLOWED"; const DEFAULT_ALLOWED: &[&str] = &[ "file_read", "file_list", + "file_write", + "web_fetch", "agent_list", "channel_send", "agent_send", From d804074dbf967efbaa311b63fee21af9636789d5 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 21:36:33 -0700 Subject: [PATCH 021/105] feat(bridge): expose agent_spawn, agent_kill, agent_activate, agent_find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the four remaining agent-lifecycle tools to the MCP bridge surface, bringing parity with OpenFang's native kernel tool set for agent control. - openfang-mcp-bridge/lib.rs: register all four in built_in_tools(); drift-catcher test bumped to 11 tools. - openfang-mcp-bridge/main.rs: add to DEFAULT_ALLOWED. - openfang-api/bridge_ipc.rs: add to ALLOWED_TOOLS. Not added to FS_SANDBOXED_TOOLS (kernel-only, no filesystem touch). Gate behavior is inherited from existing plumbing: - Gate 1 (static allowlist): all four now pass. - Gate 2 (per-agent capability via agent.toml): enforced through the canonical resolver, no extra hardcoded checks. - Gate 3 (FS sandbox): skipped by design. Smoke: 8/8 scenarios green (positives via coder-openfang against a throwaway coder-smoketest agent; negatives via code-reviewer hit Gate 2 cleanly with permitted_count=5 matching her agent.toml). Full spawn → activate → kill lifecycle verified end-to-end through the bridge. Smoketest workspace + registry entry cleaned up. --- crates/openfang-api/src/bridge_ipc.rs | 4 ++ crates/openfang-mcp-bridge/src/lib.rs | 67 ++++++++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 4 ++ 3 files changed, 75 insertions(+) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 91ec970ba7..17c79cc8b8 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -73,6 +73,10 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "agent_list", "channel_send", "agent_send", + "agent_spawn", + "agent_kill", + "agent_activate", + "agent_find", ]; /// Subset of [`ALLOWED_TOOLS`] that operates on the agent's workspace diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 050ddc3db9..f0415e2914 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -233,6 +233,69 @@ pub fn built_in_tools() -> Vec { "required": ["agent_id", "message"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `agent_spawn`. High- + // capability tool (creates new agents). Gated per-agent via + // agent.toml; daemon-side Gate 2 enforces. + Tool::new( + "agent_spawn", + "Spawn a new agent from a TOML manifest. Returns the new agent's ID and name.", + obj(json!({ + "type": "object", + "properties": { + "manifest_toml": { + "type": "string", + "description": "The agent manifest in TOML format (must include name, module, [model], and [capabilities])" + } + }, + "required": ["manifest_toml"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `agent_kill`. High- + // capability tool (terminates another agent). Gated per-agent. + Tool::new( + "agent_kill", + "Kill (terminate) another agent by its ID.", + obj(json!({ + "type": "object", + "properties": { + "agent_id": { "type": "string", "description": "The agent's UUID to kill" } + }, + "required": ["agent_id"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `agent_activate`. Wakes + // a Suspended/Crashed/Created agent. Terminated agents cannot be + // revived. + Tool::new( + "agent_activate", + "Activate (wake up) an inactive agent so it can receive messages \ + and process events. Use this when agent_list shows an agent in a \ + Suspended, Crashed, or Created state and you want to delegate work \ + to it via agent_send. Terminated agents cannot be revived.", + obj(json!({ + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The target agent's UUID or human-readable name" + } + }, + "required": ["agent_id"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `agent_find`. Read-only + // discovery; pairs with agent_send. + Tool::new( + "agent_find", + "Discover agents by name, tag, tool, or description. Use to find specialists before delegating work.", + obj(json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query (matches agent name, tags, tools, description)" } + }, + "required": ["query"] + })), + ), ] } @@ -374,6 +437,10 @@ mod tests { "agent_list", "channel_send", "agent_send", + "agent_spawn", + "agent_kill", + "agent_activate", + "agent_find", ], "surface drift — update both this test and the runtime tool_runner \ schema when adding or removing built-in bridge tools" diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index f2d6ccff98..6ac0a3d70d 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -94,6 +94,10 @@ const DEFAULT_ALLOWED: &[&str] = &[ "agent_list", "channel_send", "agent_send", + "agent_spawn", + "agent_kill", + "agent_activate", + "agent_find", ]; #[cfg(unix)] From b367476db21cc687d1f6fea53f2ce1de69b9eec8 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 22:47:15 -0700 Subject: [PATCH 022/105] feat(bridge): expose memory_store and memory_recall tools Brings the MCP bridge surface to 13 tools, leaving only shell_exec pending the Claude Code Bash-disable spike. Memory tools are gated per-agent via agent.toml; not sandboxed at the FS layer since memory writes route through kernel-managed workspace memory. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 2 ++ crates/openfang-mcp-bridge/src/lib.rs | 29 ++++++++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 2 ++ 3 files changed, 33 insertions(+) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 17c79cc8b8..e6218c14e7 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -75,6 +75,8 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "agent_send", "agent_spawn", "agent_kill", + "memory_store", + "memory_recall", "agent_activate", "agent_find", ]; diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index f0415e2914..6e26ceace3 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -283,6 +283,33 @@ pub fn built_in_tools() -> Vec { "required": ["agent_id"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `memory_store`. Kernel- + // managed shared memory; no FS sandbox needed (kernel scopes writes). + Tool::new( + "memory_store", + "Store a value in shared memory accessible by all agents. Use for cross-agent coordination and data sharing.", + obj(json!({ + "type": "object", + "properties": { + "key": { "type": "string", "description": "The storage key" }, + "value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" } + }, + "required": ["key", "value"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `memory_recall`. Read-only + // companion to memory_store. + Tool::new( + "memory_recall", + "Recall a value from shared memory by key.", + obj(json!({ + "type": "object", + "properties": { + "key": { "type": "string", "description": "The storage key to recall" } + }, + "required": ["key"] + })), + ), // Mirrors `openfang_runtime::tool_runner` → `agent_find`. Read-only // discovery; pairs with agent_send. Tool::new( @@ -440,6 +467,8 @@ mod tests { "agent_spawn", "agent_kill", "agent_activate", + "memory_store", + "memory_recall", "agent_find", ], "surface drift — update both this test and the runtime tool_runner \ diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 6ac0a3d70d..a28ede46ed 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -96,6 +96,8 @@ const DEFAULT_ALLOWED: &[&str] = &[ "agent_send", "agent_spawn", "agent_kill", + "memory_store", + "memory_recall", "agent_activate", "agent_find", ]; From b324daf15b0a6725e87a0c7605e2942b7cc43875 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Thu, 14 May 2026 23:42:35 -0700 Subject: [PATCH 023/105] feat(driver): deny CC native FS/shell/web tools via per-spawn --settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Material change: OpenFang now authoritatively blocks Claude Code's native filesystem, shell, and web tool surface for every CC subprocess it spawns, by injecting a per-spawn settings.json (written under the bridge socket dir, removed on drop) and passing it via `claude --settings `. Why this works alongside `--dangerously-skip-permissions`: per Anthropic's settings documentation, `permissions.deny` rules are enforced even when the YOLO flag is set — that flag only bypasses allow/ask prompts, not security-critical denies. The daemon keeps non-interactive operation (no TTY to answer prompts) while still authoritatively blocking the native surface. Deny set: Bash, Read, Write, Edit, MultiEdit, NotebookEdit, WebFetch, WebSearch, Glob, Grep. The MCP namespace (`mcp__openfang__*`) lives in a separate pattern space and is untouched — that's the point: replace the bypassable native surface with the gated, RBAC-checked bridge surface. TodoWrite and Task (subagent) are deliberately left alone: agent-internal control flow with no escape to the host. Gated on `bridge_enabled()` so the deny set co-travels with the bridge wiring — either both or neither. Without the bridge wired the agent has no `mcp__openfang__*` fallback, so blanket-denying native tools would yield a useless agent. Also retroactively earns the docstring claim in `llm_driver.rs::skip_permissions`: before this commit the claim that "OpenFang's RBAC layer makes YOLO safe" was true at the bridge wire but false at CC's wide-open native surface. With native tools denied, the bridge is now the only path to host resources, and per-agent agent.toml capabilities become authoritative for CC subprocesses. Tests: - test_build_cc_settings_shape: wire shape, bare tool names only - test_cc_native_deny_includes_glob_grep: pins the deny set decisions - test_cc_settings_file_drop_removes_file: RAII cleanup Co-Authored-By: Claude Opus 4.7 --- .../src/drivers/claude_code.rs | 262 +++++++++++++++++- crates/openfang-runtime/src/llm_driver.rs | 17 +- 2 files changed, 274 insertions(+), 5 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index e5c9fa80fc..5510543799 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -79,6 +79,50 @@ fn bridge_debug_enabled() -> bool { /// namespace each tool as `mcp____`. const BRIDGE_MCP_SERVER_NAME: &str = "openfang"; +/// Native Claude Code tools that OpenFang denies by policy when the bridge +/// is wired. Anything that touches the filesystem, executes commands, or +/// fetches the web is funneled through the OpenFang bridge instead, where +/// it can be RBAC-gated per-agent via `agent.toml` capabilities. +/// +/// Mechanism: emitted into a per-spawn `settings.json` and passed to CC via +/// `claude --settings `. Per Anthropic's settings documentation, +/// `permissions.deny` rules are enforced even when +/// `--dangerously-skip-permissions` is set — that flag only bypasses +/// allow/ask prompts, not security-critical denies. So the daemon keeps the +/// non-interactive YOLO flag (required because there is no TTY to answer +/// prompts) while still authoritatively blocking the native tool surface. +/// +/// Naming uses bare tool names (e.g. `"Bash"`, not `"Bash(*)"`), which +/// blanket-denies the tool with no specifier. The MCP namespace +/// (`mcp__openfang__*`) lives in a separate pattern space and is untouched — +/// that's the point: replace the native surface with the gated bridge +/// surface. +/// +/// Inclusions: +/// - `Bash` — shell execution. `shell_exec` (commit 13b) is the gated +/// replacement. +/// - `Read`/`Write`/`Edit`/`MultiEdit`/`NotebookEdit` — filesystem mutation. +/// Bridge's `file_read`/`file_write` are the gated replacements. +/// - `WebFetch`/`WebSearch` — outbound network from the model. Bridge's +/// `web_fetch` is the gated replacement. +/// - `Glob`/`Grep` — filesystem read, denied for symmetry: any FS-read path +/// must go through the bridge's sandboxed `file_read`/`file_list`. +/// +/// Deliberately NOT denied: `TodoWrite`, `Task` (subagent) — agent-internal +/// control flow with no escape surface to the host system. +const CC_NATIVE_DENY: &[&str] = &[ + "Bash", + "Read", + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "WebFetch", + "WebSearch", + "Glob", + "Grep", +]; + /// Environment variable names (and suffixes) to strip from the subprocess /// to prevent leaking API keys from other providers. We keep the full env /// intact (so Node.js, NVM, SSL, proxies, etc. all work) and only remove @@ -437,6 +481,112 @@ fn generate_legacy_bridge_token() -> String { uuid::Uuid::new_v4().to_string() } +/// Per-spawn CC settings file. Holds the path to a `settings.json` that CC +/// reads via `--settings ` and removes it on drop. +/// +/// Lifetime mirrors `BridgeMcpConfig`: the file lives next to the daemon's +/// bridge socket (under `/run/`) for the duration of a single CC +/// invocation. Per-spawn rather than per-agent because settings are static +/// policy and a fresh write per spawn means zero stale-state surface and +/// zero concurrency concerns when an agent spawns multiple CC subprocesses. +struct CcSettingsFile { + path: PathBuf, +} + +impl CcSettingsFile { + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for CcSettingsFile { + fn drop(&mut self) { + // Best-effort cleanup; the file contains no secrets — only a static + // deny set — so staleness is harmless if removal fails. + let _ = std::fs::remove_file(&self.path); + } +} + +/// Build the per-spawn CC `settings.json` JSON document. Pure — no env +/// reads, no filesystem writes — so it's tractable to test in isolation. +/// +/// Wire shape matches what `claude --settings ` accepts: a top-level +/// `permissions` object with a `deny` array of tool-name patterns. We emit +/// nothing else; merging precedence in CC is additive for permissions +/// arrays, so this layers cleanly over any user/managed settings without +/// clobbering them. Deny is monotone — adding entries can only further +/// restrict the surface — so it's safe to merge. +fn build_cc_settings_value(deny: &[&str]) -> serde_json::Value { + let deny_arr = serde_json::Value::Array( + deny.iter() + .map(|s| serde_json::Value::String((*s).into())) + .collect(), + ); + let mut perms = serde_json::Map::new(); + perms.insert("deny".into(), deny_arr); + let mut root = serde_json::Map::new(); + root.insert("permissions".into(), serde_json::Value::Object(perms)); + serde_json::Value::Object(root) +} + +/// Materialize a per-spawn CC settings file containing the OpenFang deny +/// set, returning an RAII handle that removes the file on drop. +/// +/// Gated on `bridge_enabled()` so the deny set is only injected when +/// OpenFang is in authoritative control of this CC subprocess. Without the +/// bridge wired, the agent has no `mcp__openfang__*` surface to fall back +/// on, so denying the native surface would yield a useless agent. Either +/// both (gated bridge + denied native) or neither — the two halves of the +/// "OpenFang is the RBAC layer" claim travel together. +/// +/// Returns `None` if the bridge gate is off, the socket dir can't be +/// located, or the write fails. CC then spawns without `--settings`, which +/// is the pre-13a behavior (native tools open). The warn log on write +/// failure makes that regression observable rather than silent. +/// +/// Co-located with the bridge config under the same socket dir so cleanup +/// is uniform: one directory, one removal pattern, one set of permissions. +fn try_materialize_cc_settings(caller_agent_id: Option<&str>) -> Option { + // Same gate as the bridge config — the two are policy-coupled (see + // function-level docstring) and must turn on or off together. + if !bridge_enabled() { + return None; + } + + let socket = std::env::var(BRIDGE_SOCKET_ENV).ok()?; + let socket_dir = std::path::Path::new(&socket).parent()?.to_path_buf(); + let path = socket_dir.join(format!("cc-settings-{}.json", uuid::Uuid::new_v4())); + + let cfg = build_cc_settings_value(CC_NATIVE_DENY); + let serialized = serde_json::to_string(&cfg).ok()?; + if let Err(e) = std::fs::write(&path, serialized) { + warn!(error = %e, path = %path.display(), "failed to write CC --settings file"); + return None; + } + + // 0600 for symmetry with the bridge config. The file contains no + // secrets, but a uniform permission model is easier to audit than + // exceptions per artifact type. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(&path, perms); + } + } + + debug!( + agent_id = %caller_agent_id.unwrap_or(""), + settings = %path.display(), + deny_count = CC_NATIVE_DENY.len(), + "materialized CC --settings deny set" + ); + + Some(CcSettingsFile { path }) +} + impl ClaudeCodeDriver { /// Build the per-spawn `--mcp-config` JSON for a CC invocation, if the /// daemon has published bridge wiring discovery and the request carries @@ -659,6 +809,18 @@ impl LlmDriver for ClaudeCodeDriver { let bridge_wired = _bridge_cfg.is_some(); let bridge_debug = bridge_wired && bridge_debug_enabled(); + // Inject the per-spawn settings.json that denies CC's native FS, + // shell, and web tools. Guard kept alive for the remainder of the + // call; on drop it removes the temp settings file. Gated on + // `bridge_enabled()` inside `try_materialize_cc_settings` so it + // co-travels with the bridge wiring above — either both or neither. + let _cc_settings = + try_materialize_cc_settings(request.caller_agent_id.as_deref()).map(|s| { + cmd.arg("--settings").arg(s.path()); + s + }); + let native_deny_wired = _cc_settings.is_some(); + Self::apply_env_filter(&mut cmd); // Inject HOME so the CLI can find its credentials (~/.claude/) when @@ -671,7 +833,7 @@ impl LlmDriver for ClaudeCodeDriver { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, bridge_wired, "Spawning Claude Code CLI"); + debug!(cli = %self.cli_path, skip_permissions = self.skip_permissions, bridge_wired, native_deny_wired, "Spawning Claude Code CLI"); // Spawn child process instead of cmd.output() so we can track PID and timeout let mut child = cmd.spawn().map_err(|e| { @@ -895,6 +1057,16 @@ impl LlmDriver for ClaudeCodeDriver { let bridge_wired = _bridge_cfg.is_some(); let bridge_debug = bridge_wired && bridge_debug_enabled(); + // Native-tool deny via per-spawn `--settings` (see `complete()` for + // full rationale). Guard kept alive for the rest of the streaming + // function so the settings file outlives the CC subprocess. + let _cc_settings = + try_materialize_cc_settings(request.caller_agent_id.as_deref()).map(|s| { + cmd.arg("--settings").arg(s.path()); + s + }); + let native_deny_wired = _cc_settings.is_some(); + Self::apply_env_filter(&mut cmd); // Same HOME and stdin hygiene as the non-streaming path. @@ -905,7 +1077,7 @@ impl LlmDriver for ClaudeCodeDriver { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - debug!(cli = %self.cli_path, bridge_wired, "Spawning Claude Code CLI (streaming)"); + debug!(cli = %self.cli_path, bridge_wired, native_deny_wired, "Spawning Claude Code CLI (streaming)"); let mut child = cmd.spawn().map_err(|e| { LlmError::Http(format!( @@ -1453,6 +1625,92 @@ mod tests { assert!(!path.exists(), "file must be removed when guard drops"); } + #[test] + fn test_build_cc_settings_shape() { + // The wire shape CC's `--settings` accepts: top-level `permissions` + // object with a `deny` array of bare tool-name strings. No other + // keys are emitted — we want minimal surface that merges cleanly + // with any user/managed settings without subtracting from them. + let cfg = build_cc_settings_value(CC_NATIVE_DENY); + let root = cfg.as_object().expect("root must be a JSON object"); + assert_eq!(root.len(), 1, "root must contain only `permissions`"); + + let perms = cfg + .pointer("/permissions") + .and_then(|v| v.as_object()) + .expect("permissions object missing"); + assert_eq!(perms.len(), 1, "permissions must contain only `deny`"); + + let deny = cfg + .pointer("/permissions/deny") + .and_then(|v| v.as_array()) + .expect("deny array missing"); + assert_eq!(deny.len(), CC_NATIVE_DENY.len()); + // Bare tool names — no specifier in parens — so the deny is + // blanket, not pattern-scoped. CC treats `"Bash"` as the whole tool. + for entry in deny { + let s = entry.as_str().expect("deny entries must be strings"); + assert!( + !s.contains('(') && !s.contains(')'), + "{s:?} must be a bare tool name, not a specifier pattern" + ); + } + } + + #[test] + fn test_cc_native_deny_includes_glob_grep() { + // Glob and Grep are included for symmetry: any FS-read path goes + // through the bridge's sandboxed `file_read`/`file_list` rather + // than CC's native readers. This test pins that decision so a + // future refactor that drops them needs a deliberate change. + assert!(CC_NATIVE_DENY.contains(&"Glob"), "Glob must be denied"); + assert!(CC_NATIVE_DENY.contains(&"Grep"), "Grep must be denied"); + + // Core dangerous tools — the load-bearing reason for this commit. + for must_deny in [ + "Bash", + "Read", + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "WebFetch", + "WebSearch", + ] { + assert!( + CC_NATIVE_DENY.contains(&must_deny), + "{must_deny} must be denied" + ); + } + + // Agent-internal control flow must NOT be denied — denying these + // would break legitimate agent loops with no security upside. + for must_allow in ["TodoWrite", "Task"] { + assert!( + !CC_NATIVE_DENY.contains(&must_allow), + "{must_allow} must NOT be denied (agent-internal control flow)" + ); + } + } + + #[test] + fn test_cc_settings_file_drop_removes_file() { + // CcSettingsFile is a per-spawn artifact; on drop, the file must + // vanish so successive runs don't accumulate stale settings + // sidecars under the socket dir. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cc-settings-test.json"); + std::fs::write(&path, "{}").expect("seed file"); + assert!(path.exists()); + + { + let _guard = CcSettingsFile { path: path.clone() }; + assert!(path.exists(), "file present while guard held"); + } + + assert!(!path.exists(), "file must be removed when guard drops"); + } + #[test] fn test_bridge_enabled_gate() { // Single test exercises the whole truth table for the gate, in diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index cb8cd9cf32..cea2c3f708 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -213,10 +213,21 @@ pub struct DriverConfig { /// Skip interactive permission prompts (Claude Code provider only). /// /// When `true`, adds `--dangerously-skip-permissions` to the spawned - /// `claude` CLI. Defaults to `true` because OpenFang runs as a daemon + /// `claude` CLI. Defaults to `true` because OpenFang runs as a daemon /// with no interactive terminal, so permission prompts would block - /// indefinitely. OpenFang's own capability / RBAC layer already - /// restricts what agents can do, making this safe. + /// indefinitely. + /// + /// Safety basis: the CC driver injects a per-spawn `settings.json` via + /// `claude --settings ` containing a `permissions.deny` set that + /// blocks CC's native FS, shell, and web tools (see `CC_NATIVE_DENY` in + /// `drivers/claude_code.rs`). Per Anthropic's settings documentation, + /// `permissions.deny` rules are enforced even when + /// `--dangerously-skip-permissions` is set — that flag bypasses only + /// allow/ask prompts, not security-critical denies. Native tools are + /// replaced by the gated `mcp__openfang__*` bridge surface, which is + /// RBAC-checked per-agent against `agent.toml` capabilities. Skipping + /// permission prompts is therefore safe: there is no ungated tool the + /// model can reach to do harm. #[serde(default = "default_skip_permissions")] pub skip_permissions: bool, From 9bd3e48c7121e19886e8c193e4ac3d148bc4c636 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 00:02:25 -0700 Subject: [PATCH 024/105] feat(bridge): expose shell_exec tool with workspace + exec_policy gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 13a denies CC's native Bash via --settings; every CC-driven agent now needs shell access through the OpenFang bridge instead. This adds `shell_exec` to the bridge allowlist and threads the two pieces of context the runtime needs to dispatch it safely: * `exec_policy` resolved from the registered manifest (per-agent override or kernel fallback, already populated by the kernel at agent boot — kernel.rs:1440-1447). Drives the allowlist/full/deny decision plus shell-metacharacter rejection in tool_runner. * `allowed_env_vars` read from `manifest.metadata["hand_allowed_env"]`, mirroring agent_loop.rs:320 — same lookup, same semantics, so the bridge path and the in-proc path see identical hand-granted env. `shell_exec` joins `FS_SANDBOXED_TOOLS`: tool_runner sets the command's cwd to the workspace_root, so without a registered workspace the shell would default to the daemon CWD (`~/.openfang`, where secrets.env and the GCP service-account JSON live). Refusing the call when no workspace is registered keeps that closed. Tests: * `allowlist_contains_shell_exec` — name-locked allowlist membership * `shell_exec_is_workspace_sandboxed` — name-locked sandbox membership * Updated `ipc_handshake_and_allowlist_gate` to use a synthetic non-tool name as the "not on allowlist" example; shell_exec is now permitted there. Backcompat: agents that already declared `shell_exec` in their capabilities get it via the bridge with the same gating they had in-proc. Agents that didn't, can't — gate 2 still rejects. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 55 +++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index e6218c14e7..03ec9db2c1 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -79,6 +79,7 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "memory_recall", "agent_activate", "agent_find", + "shell_exec", ]; /// Subset of [`ALLOWED_TOOLS`] that operates on the agent's workspace @@ -93,7 +94,13 @@ pub const ALLOWED_TOOLS: &[&str] = &[ /// account JSON sitting at the openfang root. The fix below scopes every /// FS call to the *authenticated* agent's workspace and refuses the call /// outright when no workspace is registered. -const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list", "file_write"]; +/// `shell_exec` is included here because the command runs with +/// `current_dir(workspace_root)` (tool_runner.rs:1704-1707). Without a +/// registered workspace the shell would default to the daemon CWD +/// (`~/.openfang`), where `secrets.env` and the GCP service-account JSON +/// live — same sibling-leak surface the file tools had pre-D-fix. Refusing +/// the call when no workspace is registered keeps that closed. +const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list", "file_write", "shell_exec"]; /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { @@ -526,6 +533,23 @@ async fn dispatch_call( } let workspace_root_arg: Option<&Path> = workspace_path.as_deref(); + // shell_exec needs both `exec_policy` (allowlist / full / deny decision) + // and `allowed_env_vars` (hand-granted env passthrough — see + // agent_loop.rs:320 for the mirror of this metadata lookup). Every other + // bridge tool ignores both, so this is cheap to compute unconditionally. + let effective_exec_policy = entry.manifest.exec_policy.as_ref(); + let hand_allowed_env: Vec = entry + .manifest + .metadata + .get("hand_allowed_env") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let allowed_env_arg: Option<&[String]> = if hand_allowed_env.is_empty() { + None + } else { + Some(&hand_allowed_env) + }; + let result = openfang_runtime::tool_runner::execute_tool( &format!("bridge-{}", call.request_id), &call.tool_name, @@ -537,10 +561,10 @@ async fn dispatch_call( Some(&kernel.mcp_connections), Some(&kernel.web_ctx), Some(&kernel.browser_ctx), - None, // allowed_env_vars — unused by the five allowlisted tools + allowed_env_arg, workspace_root_arg, // scoped to the authenticated agent's workspace; gated above Some(&kernel.media_engine), - None, // exec_policy — shell tools not in allowlist + effective_exec_policy, if kernel.config.tts.enabled { Some(&kernel.tts_engine) } else { @@ -690,7 +714,7 @@ mod tests { &Frame::Call(CallRequest { request_id: 1, agent_id: "test-agent".into(), - tool_name: "shell_exec".into(), // deliberately not on the list + tool_name: "definitely_not_a_real_tool".into(), // deliberately not on the list args: serde_json::json!({"cmd": "rm -rf /"}), }), ) @@ -872,6 +896,29 @@ mod tests { ); } + #[test] + fn allowlist_contains_shell_exec() { + // 13b: shell_exec reachable through the bridge so CC subprocesses + // operating under the 13a native-deny set still have a path to the + // shell (gated, sandboxed, exec_policy-enforced). Locking this in by + // name so a refactor of `ALLOWED_TOOLS` doesn't silently drop it. + assert!( + ALLOWED_TOOLS.contains(&"shell_exec"), + "shell_exec must be on the bridge allowlist post-13a" + ); + } + + #[test] + fn shell_exec_is_workspace_sandboxed() { + // shell_exec uses workspace_root as cwd (tool_runner.rs:1704-1707). + // Without sandbox membership, a no-workspace agent would shell out + // in `~/.openfang` and see secrets.env. Fail-closed gate. + assert!( + FS_SANDBOXED_TOOLS.contains(&"shell_exec"), + "shell_exec must require a registered workspace" + ); + } + #[test] fn authenticate_hello_accepts_legacy_non_hex_token() { // Back-compat lane: a non-hex non-empty token (e.g. legacy UUID) From 64078d09e3b150d7add003b7e233b7a46aef7d13 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 00:03:02 -0700 Subject: [PATCH 025/105] feat(bridge): expose web_search tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 13a's deny set blocks CC's native `WebSearch`; researcher agents (researcher, researcher-autumn-business, researcher-autumn-medical) depend on this surface for primary research. Restoring it through the bridge means the request flows through OpenFang's multi-provider chain (Tavily → Brave → Perplexity → DDG) instead of CC's own provider, which is also a sturdier behavior win — we control the retry/fallback policy. Implementation is a one-line allowlist add. Every prerequisite was already in place: * `web_ctx` is already passed to `execute_tool` at the dispatch site. * `web_search` is already a fully-implemented native tool with a ToolDefinition (tool_runner.rs:645) and provider chain (tool_runner.rs:233). * Per-agent capability gating already honors `web_search` against the agent.toml's resolved surface (gate 2 at bridge_ipc.rs:475). No new context arg, no new FS sandbox concerns (pure-net tool). The smallest bridge add to date. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 03ec9db2c1..198b3a69e0 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -80,6 +80,7 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "agent_activate", "agent_find", "shell_exec", + "web_search", ]; /// Subset of [`ALLOWED_TOOLS`] that operates on the agent's workspace @@ -896,6 +897,18 @@ mod tests { ); } + #[test] + fn allowlist_contains_web_search() { + // 13d: native CC `WebSearch` is denied by the 13a deny set; restore + // it through the bridge so researcher agents (medical, business) + // keep their primary research surface. Zero new plumbing — kernel + // `web_ctx` is already passed to `execute_tool` at this call site. + assert!( + ALLOWED_TOOLS.contains(&"web_search"), + "web_search must be on the bridge allowlist post-13a" + ); + } + #[test] fn allowlist_contains_shell_exec() { // 13b: shell_exec reachable through the bridge so CC subprocesses From 904be9a7106997eaec9cc2b7b5da470982f8376c Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 01:00:18 -0700 Subject: [PATCH 026/105] fix(bridge): advertise shell_exec and web_search on the MCP surface Commits 9bd3e48 (13b) and 64078d0 (13d) added shell_exec and web_search to the daemon-side ALLOWED_TOOLS allowlist in bridge_ipc, but missed the two corresponding files on the bridge subprocess side: - crates/openfang-mcp-bridge/src/lib.rs :: built_in_tools() The MCP tools/list surface advertised to CC subprocesses. Without an entry here, CC never sees the tool name or schema; permitted_tools() intersects this list with the dispatcher's allowed_tools, so the daemon's allowlist alone is not enough. - crates/openfang-mcp-bridge/src/main.rs :: DEFAULT_ALLOWED Fallback allowlist used when OPENFANG_BRIDGE_ALLOWED is unset (legacy / dev spawn path). Mirrors the daemon's bridge_ipc list. Net effect: with 13a deploying the deny of CC's native Bash/WebSearch tools, CC agents would have lost shell + web search entirely on deploy. This commit closes that gap so the bridge actually delivers what 13b/13d advertised. Surface drift test (built_in_tools_surface) updated with both names. The permitted_tools intersection test now uses a synthetic 'not_a_real_tool' name since shell_exec is no longer an outsider. Co-Authored-By: Claude Opus 4.7 --- crates/openfang-mcp-bridge/src/lib.rs | 44 ++++++++++++++++++++++++-- crates/openfang-mcp-bridge/src/main.rs | 2 ++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 6e26ceace3..2eb1fa75d2 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -323,6 +323,44 @@ pub fn built_in_tools() -> Vec { "required": ["query"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `shell_exec`. Daemon-side + // `bridge_ipc` enforces workspace cwd sandbox + `exec_policy` (Full / + // Allowlist / Denylist / None) from the calling agent's agent.toml. + // The bridge advertises the tool unconditionally; per-agent capability + // gating via `OPENFANG_BRIDGE_ALLOWED` decides whether any given bridge + // instance actually exposes it, and Gate 2 in `bridge_ipc` rejects + // commands that fall outside the agent's exec_policy. + Tool::new( + "shell_exec", + "Execute a shell command and return its output. Runs in the agent's \ + workspace directory; commands are subject to the agent's exec_policy \ + (Full / Allowlist / Denylist / None) as declared in agent.toml.", + obj(json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "The command to execute" }, + "timeout_seconds": { "type": "integer", "description": "Timeout in seconds (default: 30)" } + }, + "required": ["command"] + })), + ), + // Mirrors `openfang_runtime::tool_runner` → `web_search`. Pure-net, + // no FS sandbox. Multi-provider (Tavily → Brave → Perplexity → DDG + // fallback chain) configured via the daemon's `WebToolsContext`. + Tool::new( + "web_search", + "Search the web using multiple providers (Tavily, Brave, Perplexity, \ + DuckDuckGo) with automatic fallback. Returns structured results with \ + titles, URLs, and snippets.", + obj(json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "The search query" }, + "max_results": { "type": "integer", "description": "Maximum number of results to return (default: 5, max: 20)" } + }, + "required": ["query"] + })), + ), ] } @@ -470,6 +508,8 @@ mod tests { "memory_store", "memory_recall", "agent_find", + "shell_exec", + "web_search", ], "surface drift — update both this test and the runtime tool_runner \ schema when adding or removing built-in bridge tools" @@ -481,8 +521,8 @@ mod tests { let bridge = Bridge::new(Arc::new(StubDispatcher { agent: "a".into(), // Dispatcher permits only file_read of the built-in slice; - // shell_exec is unknown to the bridge and must be ignored. - allowed: vec!["file_read".into(), "shell_exec".into()], + // not_a_real_tool is unknown to the bridge and must be ignored. + allowed: vec!["file_read".into(), "not_a_real_tool".into()], canned: DispatchOk { content: String::new(), is_error: false, diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index a28ede46ed..ed0b5fbc43 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -100,6 +100,8 @@ const DEFAULT_ALLOWED: &[&str] = &[ "memory_recall", "agent_activate", "agent_find", + "shell_exec", + "web_search", ]; #[cfg(unix)] From 2a67030bd905ff4fcac928696290ac5e9944c873 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 01:02:36 -0700 Subject: [PATCH 027/105] feat(bridge): expose apply_patch tool with workspace sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges OpenFang's native apply_patch tool (registered in tool_runner.rs at the ToolDefinition table, dispatched at line 209) end-to-end so CC subprocesses can perform surgical multi-hunk edits instead of paying the token + drift cost of whole-file file_write rewrites. This is the closest analogue to CC's native `Edit` tool that OpenFang ships today — slightly higher emit cost (unified diff vs string substitution) but the same core ergonomic: change only what changes. A native `string_edit` follow-up may replace this as the primary edit ergonomic for agents; for now apply_patch closes the worst of the "no Edit" gap left by 13a's CC native-tool deny set. Three files, mirroring every prior bridge-tool add since 908c39c: - crates/openfang-api/src/bridge_ipc.rs • apply_patch added to ALLOWED_TOOLS • apply_patch added to FS_SANDBOXED_TOOLS — tool_apply_patch resolves Add/Update/Delete paths embedded in the patch against workspace_root, so the no-workspace fail-closed gate is critical (same sibling-leak class as file_write) • Two name-locked tests: allowlist + sandbox membership - crates/openfang-mcp-bridge/src/lib.rs • Tool::new("apply_patch", ...) added to built_in_tools() (mirrors the runtime schema by hand, as the bridge crate is runtime-free by design) • built_in_tools_surface test updated - crates/openfang-mcp-bridge/src/main.rs • apply_patch added to DEFAULT_ALLOWED No new plumbing required — workspace_root + the kernel handle are already threaded into the bridge's execute_tool call site, and the native apply_patch implementation already enforces the sandbox via crate::apply_patch::apply_patch(&ops, root). Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 35 +++++++++++++++++++++++++- crates/openfang-mcp-bridge/src/lib.rs | 28 +++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 1 + 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 198b3a69e0..106b82f6b9 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -81,6 +81,7 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "agent_find", "shell_exec", "web_search", + "apply_patch", ]; /// Subset of [`ALLOWED_TOOLS`] that operates on the agent's workspace @@ -101,7 +102,13 @@ pub const ALLOWED_TOOLS: &[&str] = &[ /// (`~/.openfang`), where `secrets.env` and the GCP service-account JSON /// live — same sibling-leak surface the file tools had pre-D-fix. Refusing /// the call when no workspace is registered keeps that closed. -const FS_SANDBOXED_TOOLS: &[&str] = &["file_read", "file_list", "file_write", "shell_exec"]; +/// `apply_patch` is included for the same reason: `tool_apply_patch` +/// resolves every patch-embedded path (Add / Update / Delete) against +/// `workspace_root`. Without a registered workspace, those paths fall +/// through to the daemon CWD and an attacker-crafted patch could touch +/// `secrets.env` or any sibling workspace. Fail-closed gate. +const FS_SANDBOXED_TOOLS: &[&str] = + &["file_read", "file_list", "file_write", "shell_exec", "apply_patch"]; /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { @@ -921,6 +928,32 @@ mod tests { ); } + #[test] + fn allowlist_contains_apply_patch() { + // 13e: apply_patch reachable through the bridge as a surgical-edit + // alternative to whole-file `file_write` rewrites. Mitigates the + // token + drift cost of the missing CC `Edit` tool while we wait on a + // native `string_edit` follow-up. Name-locked so a refactor can't + // silently drop it. + assert!( + ALLOWED_TOOLS.contains(&"apply_patch"), + "apply_patch must be on the bridge allowlist post-13e" + ); + } + + #[test] + fn apply_patch_is_workspace_sandboxed() { + // tool_apply_patch resolves every patch-embedded path against + // workspace_root. Without sandbox membership a no-workspace agent + // could ship a patch whose Add/Update/Delete targets fall through to + // the daemon CWD (`~/.openfang`) — sibling-workspace + secrets leak. + // Fail-closed gate. + assert!( + FS_SANDBOXED_TOOLS.contains(&"apply_patch"), + "apply_patch must require a registered workspace" + ); + } + #[test] fn shell_exec_is_workspace_sandboxed() { // shell_exec uses workspace_root as cwd (tool_runner.rs:1704-1707). diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 2eb1fa75d2..5add9fd6a0 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -344,6 +344,33 @@ pub fn built_in_tools() -> Vec { "required": ["command"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `apply_patch`. Workspace- + // scoped via the daemon-side `FS_SANDBOXED_TOOLS` gate in `bridge_ipc` + // — `tool_apply_patch` resolves every patch-embedded path against + // `workspace_root`, so the no-workspace fail-closed gate is critical + // (same sibling-leak surface as `file_write`). + // + // Why this is bridged: serves as a surgical-edit alternative to + // whole-file `file_write` rewrites for CC subprocesses that lack + // CC's native `Edit` tool. A native `string_edit` follow-up may + // replace this as the primary edit ergonomic; for now, apply_patch + // is the closest thing we have to Edit's emit-cost profile. + Tool::new( + "apply_patch", + "Apply a multi-hunk diff patch to add, update, move, or delete files. \ + Use this for targeted edits instead of full file overwrites. Paths in \ + the patch are resolved relative to the agent workspace.", + obj(json!({ + "type": "object", + "properties": { + "patch": { + "type": "string", + "description": "The patch in *** Begin Patch / *** End Patch format. Use *** Add File:, *** Update File:, *** Delete File: markers. Hunks use @@ headers with space (context), - (remove), + (add) prefixed lines." + } + }, + "required": ["patch"] + })), + ), // Mirrors `openfang_runtime::tool_runner` → `web_search`. Pure-net, // no FS sandbox. Multi-provider (Tavily → Brave → Perplexity → DDG // fallback chain) configured via the daemon's `WebToolsContext`. @@ -509,6 +536,7 @@ mod tests { "memory_recall", "agent_find", "shell_exec", + "apply_patch", "web_search", ], "surface drift — update both this test and the runtime tool_runner \ diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index ed0b5fbc43..0d57ba0821 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -102,6 +102,7 @@ const DEFAULT_ALLOWED: &[&str] = &[ "agent_find", "shell_exec", "web_search", + "apply_patch", ]; #[cfg(unix)] From ce1c9718acd9f05d6e3a75975cb3cbb4685690f5 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 09:48:53 -0700 Subject: [PATCH 028/105] feat(driver): expand CC_NATIVE_DENY to close deny-set audit gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 18 closes the gaps surfaced by the deny-set audit (plan §10d). Adds 14 entries to CC_NATIVE_DENY, bringing it from 10 → 24 names, grouped into five categories: - Bash adjuncts / shell streamer: BashOutput, KillShell, KillBash, Monitor. Inert today (Bash is denied) but locked down as defense in depth — Monitor in particular is Bash-with-streaming and bypasses shell_exec's exec_policy gating entirely. - FS escape via worktree: EnterWorktree, ExitWorktree. Direct FS-escape primitive; native workspace-aware variant is on the follow-up backlog. - Notebook read: NotebookRead. Symmetry with NotebookEdit; forward-compat against future CC versions that ship it. - Skill substrate: SlashCommand. CC's parallel skill curation surface; OF's is canonical. - OpenFang-First scheduling / control plane: CronCreate, CronDelete, CronList, ScheduleWakeup, RemoteTrigger, plus PushNotification on the comms-routing axis. Deliberately NOT denied (pinned in tests): TodoWrite, Task, Skill, AskUserQuestion, EnterPlanMode, ExitPlanMode, TaskOutput, TaskStop, ToolSearch — agent-internal control flow / UX with no escape surface. Tests: existing pins extended; new test_cc_native_deny_covers_audit_gaps locks in each new addition with a category-rationale assertion. 20/20 in drivers::claude_code green; 1005/1005 openfang-runtime lib green. Co-Authored-By: Claude Opus 4.7 --- .../src/drivers/claude_code.rs | 125 +++++++++++++++++- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 5510543799..4e68584577 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -107,20 +107,69 @@ const BRIDGE_MCP_SERVER_NAME: &str = "openfang"; /// `web_fetch` is the gated replacement. /// - `Glob`/`Grep` — filesystem read, denied for symmetry: any FS-read path /// must go through the bridge's sandboxed `file_read`/`file_list`. +/// - `BashOutput`/`KillShell`/`KillBash` — read stdout / kill backgrounded +/// `Bash`. Inert today (Bash is denied) but locked down as defense in +/// depth: if a future CC release introduces a separate path to background +/// shells, these adjuncts must not become live. +/// - `SlashCommand` — invokes CC's skill substrate, a parallel curation +/// surface. OpenFang's skill curation is the canonical path; deny CC's. +/// - `EnterWorktree`/`ExitWorktree` — creates a git worktree on disk +/// outside the agent workspace, a direct FS-escape primitive. A native +/// workspace-aware worktree tool is on the follow-up backlog; until then, +/// no worktree creation through CC. +/// - `NotebookRead` — symmetry with `NotebookEdit`. Not advertised in the +/// current CC version we target, but pre-denied for forward-compat: if +/// a future CC ships it, the deny is already in place. +/// - `CronCreate`/`CronDelete`/`CronList` — CC-side scheduling control +/// plane (Anthropic-hosted Routines). Per the OpenFang-First Principle, +/// scheduling is owned by OF's cron / orchestrator. No parallel plane. +/// - `ScheduleWakeup` — same class as Cron*: lets CC self-pace dynamic +/// loops outside OF's orchestration. Denied. +/// - `RemoteTrigger` — directly manages Anthropic-hosted scheduled jobs +/// via `claude.ai/v1/code/triggers`. Same OpenFang-First rationale. +/// - `Monitor` — long-running shell streamer. Executes arbitrary shell +/// commands, bypassing `shell_exec`'s `exec_policy` gating. This is +/// `Bash`-with-streaming; denied for the same reason as `Bash`. +/// - `PushNotification` — bypasses OF's channel routing for user-facing +/// notifications. Soft policy gap rather than security; denied to keep +/// comms on the canonical path. /// -/// Deliberately NOT denied: `TodoWrite`, `Task` (subagent) — agent-internal -/// control flow with no escape surface to the host system. +/// Deliberately NOT denied: `TodoWrite`, `Task` (subagent), `Skill`, +/// `AskUserQuestion`, `EnterPlanMode`/`ExitPlanMode`, `TaskOutput`/ +/// `TaskStop`, `ToolSearch` — agent-internal control flow / UX with no +/// escape surface to the host system. const CC_NATIVE_DENY: &[&str] = &[ + // Shell execution + adjuncts "Bash", + "BashOutput", + "KillShell", + "KillBash", + "Monitor", + // Filesystem read/write "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", - "WebFetch", - "WebSearch", + "NotebookRead", "Glob", "Grep", + // Worktree (FS escape) + "EnterWorktree", + "ExitWorktree", + // Network + "WebFetch", + "WebSearch", + // Skill / command substrate + "SlashCommand", + // Scheduling / remote control plane (OpenFang-First) + "CronCreate", + "CronDelete", + "CronList", + "ScheduleWakeup", + "RemoteTrigger", + // Comms routing + "PushNotification", ]; /// Environment variable names (and suffixes) to strip from the subprocess @@ -1685,7 +1734,17 @@ mod tests { // Agent-internal control flow must NOT be denied — denying these // would break legitimate agent loops with no security upside. - for must_allow in ["TodoWrite", "Task"] { + for must_allow in [ + "TodoWrite", + "Task", + "Skill", + "AskUserQuestion", + "EnterPlanMode", + "ExitPlanMode", + "TaskOutput", + "TaskStop", + "ToolSearch", + ] { assert!( !CC_NATIVE_DENY.contains(&must_allow), "{must_allow} must NOT be denied (agent-internal control flow)" @@ -1693,6 +1752,62 @@ mod tests { } } + #[test] + fn test_cc_native_deny_covers_audit_gaps() { + // Commit 18: the deny-set audit closed gaps in five categories. + // Pin each addition so a future refactor that drops one needs a + // deliberate change with this test as the canary. + + // Bash adjuncts — inert today (Bash is denied) but locked down + // as defense in depth against future CC backgrounding paths. + for must_deny in ["BashOutput", "KillShell", "KillBash", "Monitor"] { + assert!( + CC_NATIVE_DENY.contains(&must_deny), + "{must_deny} must be denied (Bash adjunct / shell streamer)" + ); + } + + // Worktree creation — direct FS-escape primitive. + for must_deny in ["EnterWorktree", "ExitWorktree"] { + assert!( + CC_NATIVE_DENY.contains(&must_deny), + "{must_deny} must be denied (FS escape via git worktree)" + ); + } + + // Notebook read — symmetry with NotebookEdit + forward-compat. + assert!( + CC_NATIVE_DENY.contains(&"NotebookRead"), + "NotebookRead must be denied (forward-compat with NotebookEdit)" + ); + + // SlashCommand — parallel skill substrate; OF's is canonical. + assert!( + CC_NATIVE_DENY.contains(&"SlashCommand"), + "SlashCommand must be denied (parallel skill curation surface)" + ); + + // Scheduling / remote control plane — OpenFang-First. + for must_deny in [ + "CronCreate", + "CronDelete", + "CronList", + "ScheduleWakeup", + "RemoteTrigger", + ] { + assert!( + CC_NATIVE_DENY.contains(&must_deny), + "{must_deny} must be denied (OpenFang-First: scheduling owned by OF)" + ); + } + + // Comms routing — keep on canonical OF path. + assert!( + CC_NATIVE_DENY.contains(&"PushNotification"), + "PushNotification must be denied (OF channel routing is canonical)" + ); + } + #[test] fn test_cc_settings_file_drop_removes_file() { // CcSettingsFile is a per-spawn artifact; on drop, the file must From 478ac5a59f35123835a8806ffb18292ce02094c4 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 09:59:06 -0700 Subject: [PATCH 029/105] test(bridge): three-way drift-catcher for tool-surface correspondence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the invariant that the bridge tool surface is described by three co-equal lists, all of which must agree: 1. openfang_api::bridge_ipc::ALLOWED_TOOLS (daemon dispatch gate) 2. openfang_mcp_bridge::built_in_tools() (MCP advertise surface) 3. openfang_mcp_bridge::DEFAULT_ALLOWED (bridge process default) 13b/13d shipped with a tool dispatchable on the daemon side but absent from the MCP advertise surface — invisible to CC. The existing IPC smoke tests exercised the daemon path directly and never hit `tools/list`, so the gap shipped silently in two commits in a row. This commit closes that class of failure permanently. Changes: - Move `DEFAULT_ALLOWED` from `main.rs` (binary, untestable) into `lib.rs` as `pub const`, so the drift-catcher can reach all three sets from `openfang-api` tests. `main.rs` now imports it. - Add `allowlist_three_way_correspondence` in `bridge_ipc::tests` — asserts the three sets are equal, with diff output identifying which set is missing what when it fires. - Add `allowlist_count_is_sixteen` — pins cardinality on all three sets so an off-by-one add/remove on any single side fails loudly. Both tests are documented with the lesson from 13b/13d and the three-file pattern any future bridge tool add must follow. Test results: - openfang-api lib: 13/13 bridge_ipc tests pass (was 11; +2 new) - openfang-mcp-bridge lib: 5/5 - openfang-runtime lib: 1005/1005 - openfang-runtime drivers::claude_code: 20/20 Co-Authored-By: Claude Opus 4.7 --- crates/openfang-api/src/bridge_ipc.rs | 72 ++++++++++++++++++++++++++ crates/openfang-mcp-bridge/src/lib.rs | 26 ++++++++++ crates/openfang-mcp-bridge/src/main.rs | 26 +--------- 3 files changed, 99 insertions(+), 25 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 106b82f6b9..d07908bd65 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -941,6 +941,78 @@ mod tests { ); } + /// **Drift-catcher: three-way correspondence.** + /// + /// Three lists must agree on the bridge tool surface: + /// + /// 1. `openfang_api::bridge_ipc::ALLOWED_TOOLS` — daemon-side dispatch + /// allowlist (the call-time gate). + /// 2. `openfang_mcp_bridge::built_in_tools()` — MCP advertise surface + /// (what CC actually sees in `tools/list`). + /// 3. `openfang_mcp_bridge::DEFAULT_ALLOWED` — bridge process default + /// when `OPENFANG_BRIDGE_ALLOWED` is unset (legacy/dev path). + /// + /// Lesson from 13b/13d: a tool can be daemon-dispatchable (in + /// `ALLOWED_TOOLS`) but invisible to CC because someone forgot to add it + /// to `built_in_tools()`. The smoke tests fire the IPC path directly and + /// never hit `tools/list`, so the gap shipped silently in two commits in + /// a row. This test fails loudly if any of the three sets drifts. + /// + /// If you're here because this test failed: a bridge tool add or + /// remove must touch **all three files** — `crates/openfang-api/src/ + /// bridge_ipc.rs` (`ALLOWED_TOOLS`), `crates/openfang-mcp-bridge/src/ + /// lib.rs` (`built_in_tools` + `DEFAULT_ALLOWED`). Update both before + /// landing the commit. + #[test] + fn allowlist_three_way_correspondence() { + use openfang_mcp_bridge::{DEFAULT_ALLOWED, built_in_tools}; + use std::collections::BTreeSet; + + let daemon_set: BTreeSet<&str> = ALLOWED_TOOLS.iter().copied().collect(); + let advertise_set: BTreeSet = built_in_tools() + .iter() + .map(|t| t.name.as_ref().to_string()) + .collect(); + let advertise_borrowed: BTreeSet<&str> = + advertise_set.iter().map(|s| s.as_str()).collect(); + let default_set: BTreeSet<&str> = DEFAULT_ALLOWED.iter().copied().collect(); + + assert_eq!( + daemon_set, advertise_borrowed, + "drift: ALLOWED_TOOLS (daemon dispatch) ≠ built_in_tools() (MCP advertise). \ + daemon-only: {:?}, advertise-only: {:?}", + daemon_set.difference(&advertise_borrowed).collect::>(), + advertise_borrowed.difference(&daemon_set).collect::>(), + ); + assert_eq!( + daemon_set, default_set, + "drift: ALLOWED_TOOLS (daemon dispatch) ≠ DEFAULT_ALLOWED (bridge default). \ + daemon-only: {:?}, default-only: {:?}", + daemon_set.difference(&default_set).collect::>(), + default_set.difference(&daemon_set).collect::>(), + ); + } + + /// Pins the tool-surface cardinality at 16. Bumps to this number are + /// expected when a new bridge tool lands — update intentionally, in + /// lockstep with the three sets exercised by + /// [`allowlist_three_way_correspondence`]. + #[test] + fn allowlist_count_is_sixteen() { + use openfang_mcp_bridge::{DEFAULT_ALLOWED, built_in_tools}; + assert_eq!(ALLOWED_TOOLS.len(), 16, "ALLOWED_TOOLS surface cardinality"); + assert_eq!( + built_in_tools().len(), + 16, + "built_in_tools() advertise surface cardinality" + ); + assert_eq!( + DEFAULT_ALLOWED.len(), + 16, + "DEFAULT_ALLOWED bridge-default cardinality" + ); + } + #[test] fn apply_patch_is_workspace_sandboxed() { // tool_apply_patch resolves every patch-embedded path against diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 5add9fd6a0..da2c4114b0 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -124,6 +124,32 @@ pub enum ToolDispatchError { /// ANAI-30 slice. Per-agent gating via `OPENFANG_BRIDGE_ALLOWED` /// (sourced from each agent's `agent.toml` capabilities) decides /// whether any given bridge instance actually advertises it. +/// Default tool allowlist used by `main.rs` when [`OPENFANG_BRIDGE_ALLOWED`] +/// is unset (legacy/dev path). Lives in the library — not in `main.rs` — so +/// the bridge_ipc drift-catcher test in `openfang-api` can assert three-way +/// correspondence between this set, [`built_in_tools`], and the daemon-side +/// `bridge_ipc::ALLOWED_TOOLS`. Three files, one truth. +/// +/// [`OPENFANG_BRIDGE_ALLOWED`]: ../../openfang_mcp_bridge/index.html +pub const DEFAULT_ALLOWED: &[&str] = &[ + "file_read", + "file_list", + "file_write", + "web_fetch", + "agent_list", + "channel_send", + "agent_send", + "agent_spawn", + "agent_kill", + "memory_store", + "memory_recall", + "agent_activate", + "agent_find", + "shell_exec", + "web_search", + "apply_patch", +]; + pub fn built_in_tools() -> Vec { use serde_json::json; diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 0d57ba0821..d8d104e79d 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -56,7 +56,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{Context, Result, anyhow, bail}; #[cfg(unix)] use openfang_mcp_bridge::{ - Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, + Bridge, DEFAULT_ALLOWED, DispatchOk, ToolDispatchError, ToolDispatcher, protocol::{ CallRequest, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_ENV_VAR, TOKEN_ENV_VAR, codec, @@ -81,30 +81,6 @@ const AGENT_ID_ENV_VAR: &str = "OPENFANG_BRIDGE_AGENT_ID"; #[cfg(unix)] const ALLOWED_ENV_VAR: &str = "OPENFANG_BRIDGE_ALLOWED"; -/// Default tool allowlist when [`ALLOWED_ENV_VAR`] is unset. Mirrors the -/// daemon's `bridge_ipc::ALLOWED_TOOLS`. Tracks the bridge's `built_in_tools` -/// surface so a bridge spawned without per-agent gating (legacy/dev path) -/// still advertises everything it's capable of dispatching. -#[cfg(unix)] -const DEFAULT_ALLOWED: &[&str] = &[ - "file_read", - "file_list", - "file_write", - "web_fetch", - "agent_list", - "channel_send", - "agent_send", - "agent_spawn", - "agent_kill", - "memory_store", - "memory_recall", - "agent_activate", - "agent_find", - "shell_exec", - "web_search", - "apply_patch", -]; - #[cfg(unix)] #[tokio::main] async fn main() -> Result<()> { From e59cdaf13c4f6541e383ff75457c9a4e201258ab Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Fri, 15 May 2026 12:42:53 -0700 Subject: [PATCH 030/105] fix(bridge): strip trailing period from allowlist deny reason The exec_policy deny path in shell_exec and process_start formatted the rejection message as "{reason}. Current exec_policy.mode = ...", but validate_command_allowlist already terminates its reason string with a period. Result was "safe_bins.. Current ..." in every deny payload. Trim a trailing "." off the reason before interpolating so the rendered message has a single, correctly-placed period. Cosmetic only - no behavior change to the deny gate itself. --- crates/openfang-runtime/src/tool_runner.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 16695616ee..983cf94c1c 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -263,6 +263,7 @@ pub async fn execute_tool( if let Err(reason) = crate::subprocess_sandbox::validate_command_allowlist(command, policy) { + let reason = reason.trim_end_matches('.'); return ToolResult { tool_use_id: tool_use_id.to_string(), content: format!( @@ -3382,6 +3383,7 @@ async fn tool_process_start( if let Some(policy) = exec_policy { if let Err(reason) = crate::subprocess_sandbox::validate_command_allowlist(command, policy) { + let reason = reason.trim_end_matches('.'); return Err(format!( "process_start blocked: {reason}. Current exec_policy.mode = '{:?}'. \ To allow this command, add it to exec_policy.allowed_commands or \ From 521391a97258d48c77f3dd86234087d19d1c7e38 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 19:40:15 -0700 Subject: [PATCH 031/105] fix(security): unify FS_SANDBOXED_TOOLS gate; bridge create_directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes S1-11 / S3-03 / S8-02 (FS-sandbox drift) and the FS-half of S3-01 (HTTP `/mcp` parity) by collapsing two drifted copies of the FS-sandbox allowlist into one canonical `pub const FS_SANDBOXED_TOOLS` in `openfang_runtime::tool_runner` (6-tool union). Before: `bridge_ipc.rs` carried a 5-tool list (file_read, file_list, file_write, shell_exec, apply_patch); `routes.rs` carried a 4-tool list (file_read, file_list, file_write, create_directory). Both lists gate whether a tool call's path args get rewritten against the caller's `workspace_root` and whether the call fail-closes when no workspace is registered. Drift meant: - HTTP `/mcp` invoking `shell_exec` did **not** force cwd to `workspace_root` — sibling-workspace / secrets.env leak. - HTTP `/mcp` invoking `apply_patch` did **not** sandbox patch-embedded paths — same leak surface. - IPC invoking `create_directory` did **not** sandbox the target path (when reachable) — same leak surface inverted. The union closes all three holes in one place. Both surfaces now consult the same constant; future FS-touching tools land in one file. Side effect: `create_directory` is added to `ALLOWED_TOOLS`, `DEFAULT_ALLOWED`, and `built_in_tools()`. The PR's stated objective is full native-tool parity on the bridge; its absence from those three lists was an oversight, not a deliberate gate. Audit of `~/.openfang/agents/*/agent.toml` confirms zero references to `create_directory` — no agent was relying on it being unreachable. Tests: - New `fs_sandboxed_tools_subset_of_allowed_tools` (belt-and-braces): catches "added an FS tool to the sandbox set without putting it on the bridge surface" or the inverse. - Existing `allowlist_three_way_correspondence` continues to enforce daemon-dispatch ≡ MCP-advertise ≡ bridge-default sets. - Cardinality assertion bumped 16 → 17 in lockstep. Test results: cargo test -p openfang-api -p openfang-mcp-bridge -p openfang-runtime → 1170 passed, 0 failed. --- crates/openfang-api/src/bridge_ipc.rs | 35 +++++++++++++++++----- crates/openfang-api/src/routes.rs | 10 +++---- crates/openfang-mcp-bridge/src/lib.rs | 19 ++++++++++++ crates/openfang-runtime/src/tool_runner.rs | 26 ++++++++++++++++ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index d07908bd65..969507ffca 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -69,6 +69,7 @@ pub const ALLOWED_TOOLS: &[&str] = &[ "file_read", "file_list", "file_write", + "create_directory", "web_fetch", "agent_list", "channel_send", @@ -107,8 +108,11 @@ pub const ALLOWED_TOOLS: &[&str] = &[ /// `workspace_root`. Without a registered workspace, those paths fall /// through to the daemon CWD and an attacker-crafted patch could touch /// `secrets.env` or any sibling workspace. Fail-closed gate. -const FS_SANDBOXED_TOOLS: &[&str] = - &["file_read", "file_list", "file_write", "shell_exec", "apply_patch"]; +// Canonical FS-sandbox gate lives in `openfang_runtime::tool_runner` so +// the IPC and HTTP `/mcp` surfaces consult one source. Re-exported under +// the original module path to keep existing call sites (and tests at +// :1024,1035 pre-unification) compiling unchanged. +pub use openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS; /// Daemon-version string sent in [`HelloAck::Ok`]. fn daemon_version() -> String { @@ -993,22 +997,22 @@ mod tests { ); } - /// Pins the tool-surface cardinality at 16. Bumps to this number are + /// Pins the tool-surface cardinality at 17. Bumps to this number are /// expected when a new bridge tool lands — update intentionally, in /// lockstep with the three sets exercised by /// [`allowlist_three_way_correspondence`]. #[test] - fn allowlist_count_is_sixteen() { + fn allowlist_count_is_seventeen() { use openfang_mcp_bridge::{DEFAULT_ALLOWED, built_in_tools}; - assert_eq!(ALLOWED_TOOLS.len(), 16, "ALLOWED_TOOLS surface cardinality"); + assert_eq!(ALLOWED_TOOLS.len(), 17, "ALLOWED_TOOLS surface cardinality"); assert_eq!( built_in_tools().len(), - 16, + 17, "built_in_tools() advertise surface cardinality" ); assert_eq!( DEFAULT_ALLOWED.len(), - 16, + 17, "DEFAULT_ALLOWED bridge-default cardinality" ); } @@ -1037,6 +1041,23 @@ mod tests { ); } + /// Belt-and-braces: every tool the FS sandbox gates must also be on + /// the daemon-dispatch allowlist. Catches "added an FS tool to + /// `FS_SANDBOXED_TOOLS` without wiring it onto the bridge surface" + /// (or the inverse: exposed a new FS tool without sandboxing it). + #[test] + fn fs_sandboxed_tools_subset_of_allowed_tools() { + use std::collections::BTreeSet; + let allowed: BTreeSet<&str> = ALLOWED_TOOLS.iter().copied().collect(); + let sandboxed: BTreeSet<&str> = FS_SANDBOXED_TOOLS.iter().copied().collect(); + let extras: Vec<&&str> = sandboxed.difference(&allowed).collect(); + assert!( + extras.is_empty(), + "FS_SANDBOXED_TOOLS contains tools missing from ALLOWED_TOOLS: {:?}", + extras + ); + } + #[test] fn authenticate_hello_accepts_legacy_non_hex_token() { // Back-compat lane: a non-hex non-empty token (e.g. legacy UUID) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index fb1d015975..13cd521705 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7068,12 +7068,10 @@ pub async fn mcp_http( // rather than fall through to the unscoped legacy path. Kernel- // level tools (agent_list, channel_send, etc.) continue to work // without an `_agent_id` since they don't touch the filesystem. - const FS_SANDBOXED_TOOLS: &[&str] = &[ - "file_read", - "file_list", - "file_write", - "create_directory", - ]; + // Canonical FS-sandbox gate (see + // `openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS`). + // Single source of truth across the IPC and HTTP `/mcp` surfaces. + use openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS; let agent_id_opt: Option = arguments .get("_agent_id") .and_then(|v| v.as_str()) diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index da2c4114b0..735d0601a0 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -135,6 +135,7 @@ pub const DEFAULT_ALLOWED: &[&str] = &[ "file_read", "file_list", "file_write", + "create_directory", "web_fetch", "agent_list", "channel_send", @@ -197,6 +198,23 @@ pub fn built_in_tools() -> Vec { "required": ["path", "content"] })), ), + // Mirrors `openfang_runtime::tool_runner` → `create_directory`. + // Workspace-scoped via the daemon-side `FS_SANDBOXED_TOOLS` gate. + // Idempotent: succeeds if the directory already exists; creates + // intermediate parents as needed. + Tool::new( + "create_directory", + "Create a directory (and any missing parent directories) at the given path. \ + Paths are relative to the agent workspace. Idempotent: succeeds if the \ + directory already exists.", + obj(json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "The directory path to create" } + }, + "required": ["path"] + })), + ), // Mirrors `openfang_runtime::tool_runner` → `web_fetch`. No FS touch, // so it does not appear in `FS_SANDBOXED_TOOLS`; SSRF protection lives // in the runtime implementation. @@ -551,6 +569,7 @@ mod tests { "file_read", "file_list", "file_write", + "create_directory", "web_fetch", "agent_list", "channel_send", diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 983cf94c1c..f5c200a726 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -18,6 +18,32 @@ use tracing::{debug, warn}; /// Maximum inter-agent call depth to prevent infinite recursion (A->B->C->...). const MAX_AGENT_CALL_DEPTH: u32 = 5; +/// Tools whose invocation **must** be scoped to the calling agent's +/// `workspace_root`. Canonical source of truth, consumed by both bridge +/// surfaces (IPC + HTTP `/mcp`) to decide whether a given call's path +/// arguments require workspace-rewriting and whether the call should +/// fail-closed when no workspace is registered. +/// +/// History: this list was duplicated in `openfang_api::bridge_ipc` and +/// `openfang_api::routes`, drifted (5 vs 4 tools), and missed +/// `create_directory` on the IPC side + `shell_exec`/`apply_patch` on the +/// HTTP side — both real sandbox gaps. Unified here so additions land in +/// one place. +/// +/// Membership criteria: the tool touches the filesystem via a path arg +/// (or, for `shell_exec`, uses `workspace_root` as cwd). Tools that do +/// not touch the FS (`web_fetch`, `agent_*`, `memory_*`, `web_search`, +/// `channel_send`) are intentionally absent. +pub const FS_SANDBOXED_TOOLS: &[&str] = &[ + "file_read", + "file_list", + "file_write", + "create_directory", + "shell_exec", + "apply_patch", +]; + + /// Check if a tool name refers to a shell execution tool. /// /// Used to determine whether exec_policy settings should bypass the approval gate. From 48a7741f3120a075dca56650d5e6bb92bcd84193 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 20:28:21 -0700 Subject: [PATCH 032/105] fix(security): pwsh -EncodedCommand decode + load-from-disk hard-deny (S9-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier B: PowerShell -EncodedCommand / -ec / -e payloads are base64(UTF-16LE) encoded scripts that bypass the existing inline-flag allowlist. Decode them and feed the inner script back through the same wrapper-extraction logic so they are validated against allowed_commands / safe_bins like any other inline script. Recursion is capped at MAX_SHELL_RECURSION_DEPTH = 2 (one outer wrapper plus one nested wrapper), which is more than any legitimate pwsh-in-pwsh use case needs and prevents algorithmic-DoS via deeply-nested payloads. Tier C: hard-deny load-from-disk and interactive flags on every known shell wrapper, regardless of allowlist contents — the validator cannot see what those flags will load. - pwsh / powershell: -File, -PSConsoleFile - bash / sh / zsh: --rcfile, --init-file, -i - bash / sh / zsh: -O extdebug (two-token form, handled separately) These checks run before any other parsing in validate_command_allowlist, so a flag like `bash -i -c "..."` is rejected outright rather than relying on the inline-script extractor. Internal API: extract_shell_wrapper_commands and extract_inner_script_commands now return Result, String> so decode failures, depth violations, and disk-load flag violations propagate as clear errors. Public surface (validate_command_allowlist) signature is unchanged. Tests: 13 new unit tests covering both tiers, plus a roundtrip and odd-length test for the UTF-16LE decoder. Existing 50 subprocess_sandbox tests unchanged. cargo test -p openfang-runtime --lib subprocess_sandbox → 63 passed, 0 failed. Closes S9-09. --- .../src/subprocess_sandbox.rs | 427 ++++++++++++++++-- 1 file changed, 387 insertions(+), 40 deletions(-) diff --git a/crates/openfang-runtime/src/subprocess_sandbox.rs b/crates/openfang-runtime/src/subprocess_sandbox.rs index 673d5d6cfc..2f94a671bb 100644 --- a/crates/openfang-runtime/src/subprocess_sandbox.rs +++ b/crates/openfang-runtime/src/subprocess_sandbox.rs @@ -209,60 +209,195 @@ const SHELL_INLINE_FLAGS: &[(&[&str], &str)] = &[ (&["bash", "sh", "zsh"], "--command"), ]; +/// PowerShell-style encoded-command flags. The next arg is the base64 of a +/// UTF-16LE-encoded script (per Microsoft's `-EncodedCommand` spec). We decode +/// and feed the inner script back through allowlist validation so wrapped +/// commands cannot bypass the gate. +const SHELL_ENCODED_FLAGS: &[(&[&str], &str)] = &[ + (&["powershell", "pwsh"], "-EncodedCommand"), + (&["powershell", "pwsh"], "-encodedcommand"), + (&["powershell", "pwsh"], "-ec"), + (&["powershell", "pwsh"], "-e"), +]; + +/// Flags that load scripts or config from disk (or otherwise sidestep inline +/// allowlist validation entirely). Hard-denied on any shell wrapper regardless +/// of allowlist contents: the validator cannot see what the file will execute. +/// +/// Also hard-denies `bash -i` interactive mode — no legitimate use via +/// `shell_exec`, opens stdin attack surface. The `bash -O extdebug` two-token +/// form is handled separately in `check_load_from_disk`. +const SHELL_LOAD_FROM_DISK_FLAGS: &[(&[&str], &str)] = &[ + // PowerShell — load script / console config from disk. + (&["powershell", "pwsh"], "-File"), + (&["powershell", "pwsh"], "-file"), + (&["powershell", "pwsh"], "-PSConsoleFile"), + (&["powershell", "pwsh"], "-psconsolefile"), + // POSIX shells — load rcfile / init-file / force interactive. + (&["bash", "sh", "zsh"], "--rcfile"), + (&["bash", "sh", "zsh"], "--init-file"), + (&["bash", "sh", "zsh"], "-i"), +]; + +/// Maximum recursion depth for shell-wrapper unwrapping. One outer wrapper +/// plus one nested wrapper is permitted; anything deeper is pathological and +/// rejected (also prevents algorithmic DoS via deeply-nested base64 payloads). +const MAX_SHELL_RECURSION_DEPTH: u32 = 2; + +/// Decode a PowerShell `-EncodedCommand` payload: base64(UTF-16LE(script)). +/// +/// Returns the decoded script as a `String`. Invalid base64 or odd-byte-length +/// payloads (which cannot be UTF-16) are reported as errors so the validator +/// can reject the whole command. +fn decode_pwsh_encoded_command(payload: &str) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .map_err(|e| format!("pwsh -EncodedCommand: invalid base64 ({e})"))?; + if bytes.len() % 2 != 0 { + return Err( + "pwsh -EncodedCommand: payload length not UTF-16LE aligned (odd byte count)" + .to_string(), + ); + } + let u16s: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + Ok(String::from_utf16_lossy(&u16s)) +} + +/// If `segment` invokes a shell wrapper with any load-from-disk / interactive +/// flag from `SHELL_LOAD_FROM_DISK_FLAGS` (or `bash -O extdebug`), return Err. +/// Otherwise return Ok(()). Non-wrapper commands pass through unchanged. +fn check_load_from_disk(segment: &str) -> Result<(), String> { + let trimmed = segment.trim(); + let base = extract_base_command(trimmed); + let base_lower = base.to_lowercase(); + let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); + if !SHELL_WRAPPERS.contains(&base_normalized) { + return Ok(()); + } + let args: Vec<&str> = trimmed.split_whitespace().skip(1).collect(); + + // Two-token form: bash -O extdebug (shopt that enables source-file tracing + // which can be abused for exfil / arbitrary script load). + if ["bash", "sh", "zsh"].contains(&base_normalized) { + for window in args.windows(2) { + if window[0] == "-O" && window[1].eq_ignore_ascii_case("extdebug") { + return Err(format!( + "Shell wrapper '{base}' invoked with '-O extdebug' (debug/load-from-disk flag) — denied." + )); + } + } + } + + for arg in &args { + for (wrappers, flag) in SHELL_LOAD_FROM_DISK_FLAGS { + if !wrappers.contains(&base_normalized) { + continue; + } + if arg.eq_ignore_ascii_case(flag) { + return Err(format!( + "Shell wrapper '{base}' invoked with '{flag}' (load-from-disk / interactive flag) — denied." + )); + } + } + } + Ok(()) +} + /// If the base command is a known shell wrapper, extract any inline script -/// passed via -Command / -c / /c flags and return the commands within it. +/// passed via -Command / -c / /c (or via PowerShell -EncodedCommand) and +/// return the commands within it. /// /// Returns the list of base command names found inside the inline script, -/// or an empty vec if the command is not a shell wrapper or has no inline flag. -fn extract_shell_wrapper_commands(command: &str) -> Vec { - let trimmed = command.trim(); +/// or an empty vec if the command is not a shell wrapper or has no +/// inline/encoded flag. Returns Err if a load-from-disk flag is set, an +/// encoded payload fails to decode, or recursion exceeds the depth cap. +fn extract_shell_wrapper_commands(command: &str) -> Result, String> { + extract_shell_wrapper_inner(command, 1) +} + +/// Inner workhorse for shell-wrapper inline extraction with depth tracking. +/// `depth` is the nesting level of the wrapper being inspected (outermost = 1). +fn extract_shell_wrapper_inner(segment: &str, depth: u32) -> Result, String> { + let trimmed = segment.trim(); let base = extract_base_command(trimmed); - // Check if the base command is a known shell wrapper (case-insensitive) let base_lower = base.to_lowercase(); - // Also strip .exe suffix for Windows let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); if !SHELL_WRAPPERS.contains(&base_normalized) { - return Vec::new(); + return Ok(Vec::new()); } - // Find the inline flag and extract everything after it - for (wrappers, flag) in SHELL_INLINE_FLAGS { - if !wrappers.contains(&base_normalized) { - continue; + let args: Vec<&str> = trimmed.split_whitespace().skip(1).collect(); + + // Encoded form first: pwsh -EncodedCommand . + // We decode and recurse with depth+1 so a nested encoded payload is also + // validated (until MAX_SHELL_RECURSION_DEPTH). + for (i, arg) in args.iter().enumerate() { + for (wrappers, flag) in SHELL_ENCODED_FLAGS { + if !wrappers.contains(&base_normalized) { + continue; + } + if !arg.eq_ignore_ascii_case(flag) { + continue; + } + if i + 1 >= args.len() { + return Err(format!( + "Shell wrapper '{base}' invoked with '{flag}' but no payload — denied." + )); + } + let payload = args[i + 1]; + let decoded = decode_pwsh_encoded_command(payload)?; + return extract_inner_script_commands(&decoded, depth); } - // Search for the flag in the command args (case-insensitive for PowerShell) - let rest = trimmed.split_whitespace().skip(1); // skip the base command - let args: Vec<&str> = rest.collect(); - for (i, arg) in args.iter().enumerate() { - if arg.eq_ignore_ascii_case(flag) { - // Everything after this flag is the inline script - if i + 1 < args.len() { - let script = args[i + 1..].join(" "); - // Strip surrounding quotes if present - let script = script.trim(); - let script = if (script.starts_with('"') && script.ends_with('"')) - || (script.starts_with('\'') && script.ends_with('\'')) - { - &script[1..script.len() - 1] - } else { - script - }; - // Extract commands from the inline script - // For PowerShell, commands can be separated by `;` - // For POSIX shells, by `;`, `&&`, `||`, `|` - return extract_inner_script_commands(script); - } + } + + // Plain inline form: literal script after -c / -Command / /c. + for (i, arg) in args.iter().enumerate() { + for (wrappers, flag) in SHELL_INLINE_FLAGS { + if !wrappers.contains(&base_normalized) { + continue; + } + if !arg.eq_ignore_ascii_case(flag) { + continue; + } + if i + 1 >= args.len() { + continue; } + let script = args[i + 1..].join(" "); + let script = script.trim(); + let script = if (script.starts_with('"') && script.ends_with('"')) + || (script.starts_with('\'') && script.ends_with('\'')) + { + &script[1..script.len() - 1] + } else { + script + }; + return extract_inner_script_commands(script, depth); } } - Vec::new() + Ok(Vec::new()) } -/// Extract base command names from an inline script string. -/// Splits on `;`, `&&`, `||`, `|` and returns the base command of each segment. -fn extract_inner_script_commands(script: &str) -> Vec { +/// Extract base command names from an inline script string, recursing into +/// any nested shell-wrapper invocations (e.g. `pwsh -ec ` whose payload +/// itself contains `pwsh -c "..."`). Recursion is capped at +/// `MAX_SHELL_RECURSION_DEPTH` to prevent algorithmic DoS via deeply-nested +/// encoded payloads. Also enforces `check_load_from_disk` on every wrapper +/// segment encountered along the way. +/// +/// Splits on `;`, `&&`, `||`, `|` and returns the base command of each segment +/// (plus any further commands extracted from inner wrapper payloads). +fn extract_inner_script_commands(script: &str, depth: u32) -> Result, String> { + if depth > MAX_SHELL_RECURSION_DEPTH { + return Err(format!( + "Shell-wrapper recursion exceeds depth cap of {MAX_SHELL_RECURSION_DEPTH} — denied." + )); + } let mut commands = Vec::new(); let mut rest = script; while !rest.is_empty() { @@ -281,13 +416,22 @@ fn extract_inner_script_commands(script: &str) -> Vec { let base = extract_base_command(segment); if !base.is_empty() { commands.push(base.to_string()); + // If this segment is itself a shell wrapper, recurse: first deny + // any load-from-disk flag, then unwrap inline/encoded payload. + let base_lower = base.to_lowercase(); + let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); + if SHELL_WRAPPERS.contains(&base_normalized) { + check_load_from_disk(segment)?; + let inner = extract_shell_wrapper_inner(segment, depth + 1)?; + commands.extend(inner); + } } if earliest_pos + earliest_len >= rest.len() { break; } rest = &rest[earliest_pos + earliest_len..]; } - commands + Ok(commands) } /// Extract all commands from a shell command string. @@ -339,6 +483,11 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result< Ok(()) } ExecSecurityMode::Allowlist => { + // SECURITY (S9-09): Hard-deny load-from-disk / interactive flags + // on any shell wrapper BEFORE doing any other parsing. These flags + // sidestep inline allowlist validation entirely. + check_load_from_disk(command)?; + // SECURITY: Check for shell metacharacters BEFORE base-command extraction. // These can smuggle commands inside arguments of allowed binaries. // @@ -346,7 +495,7 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result< // shell wrapper (e.g. `powershell -Command "..."`) because the // inline script naturally contains metacharacters (quotes, semicolons). // Those inner commands are validated separately below. - let inner_commands = extract_shell_wrapper_commands(command); + let inner_commands = extract_shell_wrapper_commands(command)?; let is_shell_wrapper = !inner_commands.is_empty(); if !is_shell_wrapper { @@ -1224,10 +1373,208 @@ mod tests { #[test] fn test_shell_wrapper_extract_no_flag() { // When powershell is called without -Command, no inner commands are extracted - let cmds = extract_shell_wrapper_commands("powershell script.ps1"); + let cmds = extract_shell_wrapper_commands("powershell script.ps1").unwrap(); assert!(cmds.is_empty()); } + // ── S9-09: encoded-command (Tier B) + load-from-disk (Tier C) ────── + + /// Helper: encode a script as pwsh -EncodedCommand expects + /// (base64(UTF-16LE(s))). + fn pwsh_encode(s: &str) -> String { + use base64::Engine; + let utf16: Vec = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + base64::engine::general_purpose::STANDARD.encode(&utf16) + } + + #[test] + fn test_pwsh_encoded_command_allowed_inner_passes() { + let cmd = format!("pwsh -EncodedCommand {}", pwsh_encode("Get-Process")); + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string(), "Get-Process".to_string()], + ..ExecPolicy::default() + }; + assert!( + validate_command_allowlist(&cmd, &policy).is_ok(), + "pwsh -EncodedCommand should pass when inner is allowlisted" + ); + } + + #[test] + fn test_pwsh_encoded_command_unlisted_inner_blocked() { + let cmd = format!( + "pwsh -ec {}", + pwsh_encode("Invoke-WebRequest https://evil.com") + ); + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist(&cmd, &policy).unwrap_err(); + assert!( + err.contains("Invoke-WebRequest"), + "Error should name the rejected inner command, got: {err}" + ); + } + + #[test] + fn test_pwsh_encoded_command_malformed_base64_rejected() { + let cmd = "pwsh -e !!!not-base64!!!"; + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist(cmd, &policy).unwrap_err(); + assert!( + err.to_lowercase().contains("base64") || err.contains("EncodedCommand"), + "Error should mention base64 / EncodedCommand, got: {err}" + ); + } + + #[test] + fn test_pwsh_encoded_nested_within_depth_cap() { + // Depth 2: outer pwsh -ec + let inner = pwsh_encode(r#"pwsh -c "Get-Process""#); + let cmd = format!("pwsh -EncodedCommand {inner}"); + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string(), "Get-Process".to_string()], + ..ExecPolicy::default() + }; + assert!( + validate_command_allowlist(&cmd, &policy).is_ok(), + "Depth-2 nesting (outer -ec, inner -c) within cap should pass" + ); + } + + #[test] + fn test_pwsh_encoded_recursion_depth_exceeded() { + // Depth 3: pwsh -ec ( pwsh -ec ( pwsh -ec ( Get-Process ) ) ) — denied. + let level3 = pwsh_encode("Get-Process"); + let level2 = pwsh_encode(&format!("pwsh -ec {level3}")); + let level1 = pwsh_encode(&format!("pwsh -ec {level2}")); + let cmd = format!("pwsh -ec {level1}"); + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string(), "Get-Process".to_string()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist(&cmd, &policy).unwrap_err(); + assert!( + err.contains("recursion") || err.contains("depth"), + "Error should mention recursion/depth cap, got: {err}" + ); + } + + #[test] + fn test_pwsh_file_flag_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist("pwsh -File foo.ps1", &policy).unwrap_err(); + assert!( + err.contains("-File") || err.to_lowercase().contains("load-from-disk"), + "Error should flag -File / load-from-disk, got: {err}" + ); + } + + #[test] + fn test_pwsh_psconsolefile_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["pwsh".to_string()], + ..ExecPolicy::default() + }; + assert!( + validate_command_allowlist("pwsh -PSConsoleFile evil.psc1 -Command true", &policy) + .is_err() + ); + } + + #[test] + fn test_bash_rcfile_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["bash".to_string()], + ..ExecPolicy::default() + }; + assert!( + validate_command_allowlist(r#"bash --rcfile /tmp/evil -c "true""#, &policy).is_err() + ); + } + + #[test] + fn test_bash_init_file_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["bash".to_string()], + ..ExecPolicy::default() + }; + assert!( + validate_command_allowlist(r#"bash --init-file /tmp/evil -c "true""#, &policy) + .is_err() + ); + } + + #[test] + fn test_bash_interactive_flag_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["bash".to_string()], + ..ExecPolicy::default() + }; + assert!(validate_command_allowlist(r#"bash -i -c "echo ok""#, &policy).is_err()); + } + + #[test] + fn test_bash_extdebug_denied() { + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["bash".to_string()], + ..ExecPolicy::default() + }; + let err = + validate_command_allowlist(r#"bash -O extdebug -c "echo ok""#, &policy).unwrap_err(); + assert!( + err.contains("extdebug"), + "Error should mention extdebug, got: {err}" + ); + } + + #[test] + fn test_bash_plain_c_still_works_post_tier_c() { + // Regression guard: the Tier C hard-deny must not break legitimate + // `bash -c ""` invocations. + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["bash".to_string()], + ..ExecPolicy::default() + }; + assert!(validate_command_allowlist(r#"bash -c "echo hello""#, &policy).is_ok()); + } + + #[test] + fn test_decode_pwsh_encoded_command_odd_length_rejected() { + // 3 bytes of base64 -> odd payload, cannot be UTF-16LE. + use base64::Engine; + let odd = base64::engine::general_purpose::STANDARD.encode([0x41, 0x00, 0x42]); + let result = decode_pwsh_encoded_command(&odd); + assert!(result.is_err()); + } + + #[test] + fn test_decode_pwsh_encoded_command_roundtrip() { + let s = "Get-Process"; + let enc = pwsh_encode(s); + let decoded = decode_pwsh_encoded_command(&enc).unwrap(); + assert_eq!(decoded, s); + } + #[test] fn test_extract_all_commands_cjk_separators() { // Ensure extract_all_commands handles CJK content between separators From 52e13abf333db1f4f1d43c2df87eeaa198249e4c Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 20:52:09 -0700 Subject: [PATCH 033/105] fix(security): wrapper-binary recursion + hard-deny for sysadmin/interp tools (S9-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes S9-08. Three new gates in Allowlist mode: 1. WRAPPER_BINARIES_RECURSE (env, sudo, nice, nohup, timeout). The validator now unwraps these and validates the inner binary against the allowlist — plugging the `env FOO=bar /bin/evil` and `sudo /bin/evil` bypass where only the outer wrapper was previously checked. Per-wrapper flag-parsers handle the common flag-with-value forms (`-u`, `-g`, `--unset=`, `-n`, `-s SIG`, `-k DUR`, `--`, `KEY=VALUE` env assignments, `timeout`'s DURATION positional). Fail-closed: unrecognized flag patterns or missing inner command → reject. 2. WRAPPER_BINARIES_DENY (xargs, find, strace, gdb, chroot, unshare, setsid, stdbuf, flock, time) — hard-denied regardless of allowlist contents. Sentinel-token execution (`xargs`, `find -exec`), process-tracing (`strace`, `gdb`), and namespace/buffer manipulation have no legitimate use through LLM-driven `shell_exec`. 3. INLINE_SCRIPT_INTERPRETERS — `python -c`, `node -e/--eval/-p/--print`, `perl -e/-E`, `ruby -e`. Inline scripts in these languages are not parseable for allowlist validation; hard-denied. Operators can still pass a script file path (a plain arg, not a command) or use Full mode. Recursion: wrapper-binary chains share the existing MAX_SHELL_RECURSION_DEPTH cap (2). Inner segments that are themselves shell wrappers route through `extract_shell_wrapper_inner`; nested wrapper binaries recurse through `extract_wrapper_binary_chain`. Gates also fire on segments extracted from shell-wrapper inline scripts, so `bash -c "sudo evil"` and `bash -c "xargs ls"` are caught. Tests: 25 new (88 total in subprocess_sandbox; 1044 in -runtime; 113 in -api; 33 in -mcp-bridge). All passing. No exposure change on any surface — this is allowlist-validation tightening only. --- .../src/subprocess_sandbox.rs | 569 +++++++++++++++++- 1 file changed, 564 insertions(+), 5 deletions(-) diff --git a/crates/openfang-runtime/src/subprocess_sandbox.rs b/crates/openfang-runtime/src/subprocess_sandbox.rs index 2f94a671bb..281e320af9 100644 --- a/crates/openfang-runtime/src/subprocess_sandbox.rs +++ b/crates/openfang-runtime/src/subprocess_sandbox.rs @@ -244,6 +244,36 @@ const SHELL_LOAD_FROM_DISK_FLAGS: &[(&[&str], &str)] = &[ /// rejected (also prevents algorithmic DoS via deeply-nested base64 payloads). const MAX_SHELL_RECURSION_DEPTH: u32 = 2; +/// Process-wrapper binaries whose first non-flag positional is the inner +/// command we should recurse into for allowlist validation. Without this, +/// `env FOO=bar /bin/evil` validates only `env` and silently executes the +/// inner unlisted binary. (S9-08.) +const WRAPPER_BINARIES_RECURSE: &[&str] = &["env", "sudo", "nice", "nohup", "timeout"]; + +/// Process-wrapper binaries hard-denied in Allowlist mode regardless of +/// allowlist contents. These are sysadmin / tracing / namespace tools whose +/// parser surface is too large to trust (`xargs`, `find -exec`, `strace`, +/// `gdb`, `chroot`, `unshare`, `setsid`, `stdbuf`, `flock`, `time`). They +/// have no legitimate use through LLM-driven `shell_exec` — refuse outright +/// even if an operator explicitly allowlists them. (S9-08.) +const WRAPPER_BINARIES_DENY: &[&str] = &[ + "xargs", "find", "strace", "gdb", "chroot", "unshare", "setsid", "stdbuf", "flock", "time", +]; + +/// Interpreter binaries that, when invoked with an inline-script flag +/// (`-c` / `-e` / `--eval` / `-p`), run arbitrary user-supplied code in a +/// language we cannot parse for allowlist validation. Hard-denied in +/// Allowlist mode. Operators wanting to run scripts can pass a script file +/// path (a regular path argument, not a command) or switch to Full mode. +/// +/// Each entry: `(interpreter names, inline-script flags)`. (S9-08.) +const INLINE_SCRIPT_INTERPRETERS: &[(&[&str], &[&str])] = &[ + (&["python", "python2", "python3"], &["-c"]), + (&["node", "nodejs"], &["-e", "--eval", "-p", "--print"]), + (&["perl"], &["-e", "-E"]), + (&["ruby"], &["-e"]), +]; + /// Decode a PowerShell `-EncodedCommand` payload: base64(UTF-16LE(script)). /// /// Returns the decoded script as a `String`. Invalid base64 or odd-byte-length @@ -307,6 +337,225 @@ fn check_load_from_disk(segment: &str) -> Result<(), String> { Ok(()) } +/// Hard-deny check: if the segment's base command is in WRAPPER_BINARIES_DENY, +/// reject regardless of allowlist contents. (S9-08.) +fn check_wrapper_binary_deny(segment: &str) -> Result<(), String> { + let base = extract_base_command(segment.trim()); + let base_lower = base.to_lowercase(); + let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); + if WRAPPER_BINARIES_DENY.contains(&base_normalized) { + return Err(format!( + "Wrapper binary '{base}' is hard-denied in Allowlist mode \ + (process-tracing / namespace / sentinel-execution tools cannot be validated)." + )); + } + Ok(()) +} + +/// Hard-deny check: if the segment's base is an interpreter from +/// INLINE_SCRIPT_INTERPRETERS and any arg matches its inline-script flag +/// list, reject. (S9-08.) +fn check_inline_script_interpreter(segment: &str) -> Result<(), String> { + let trimmed = segment.trim(); + let base = extract_base_command(trimmed); + let base_lower = base.to_lowercase(); + let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); + for (interps, flags) in INLINE_SCRIPT_INTERPRETERS { + if !interps.contains(&base_normalized) { + continue; + } + for arg in trimmed.split_whitespace().skip(1) { + for flag in *flags { + if arg.eq_ignore_ascii_case(flag) { + return Err(format!( + "Interpreter '{base}' invoked with inline-script flag '{flag}' \ + — denied in Allowlist mode (inline scripts are not parseable for \ + validation; pass a script file path instead)." + )); + } + } + } + } + Ok(()) +} + +/// Skip the wrapper's own flags and return the slice of args starting at the +/// inner command, or Err if the inner command is missing / the flag pattern +/// is unrecognized. Fail-closed: when we can't confidently identify the +/// inner command, reject the whole invocation. (S9-08.) +fn unwrap_wrapper_args<'a>(wrapper: &str, args: &'a [&'a str]) -> Result<&'a [&'a str], String> { + let mut i = 0; + match wrapper { + "env" => { + while i < args.len() { + let a = args[i]; + if a == "--" { + i += 1; + break; + } + if a == "-u" || a == "--unset" { + if i + 1 >= args.len() { + return Err("env: dangling -u/--unset flag".to_string()); + } + i += 2; + continue; + } + if a.starts_with("--unset=") { + i += 1; + continue; + } + if a.starts_with('-') { + i += 1; + continue; + } + if a.contains('=') { + // KEY=VALUE env var assignment + i += 1; + continue; + } + break; + } + } + "sudo" => { + const SUDO_CONSUMING: &[&str] = &[ + "-u", "-g", "-U", "-D", "-h", "-p", "-r", "-t", "-T", "-C", "--user", "--group", + "--other-user", "--chdir", "--host", "--prompt", "--role", "--type", + "--command-timeout", "--close-from", + ]; + while i < args.len() { + let a = args[i]; + if a == "--" { + i += 1; + break; + } + if SUDO_CONSUMING.contains(&a) { + if i + 1 >= args.len() { + return Err(format!("sudo: dangling flag '{a}'")); + } + i += 2; + continue; + } + if a.starts_with('-') { + i += 1; + continue; + } + break; + } + } + "nice" => { + while i < args.len() { + let a = args[i]; + if a == "-n" { + if i + 1 >= args.len() { + return Err("nice: dangling -n flag".to_string()); + } + i += 2; + continue; + } + if a.starts_with("--adjustment=") { + i += 1; + continue; + } + if a.starts_with('-') { + i += 1; + continue; + } + break; + } + } + "nohup" => { + // No flag-consuming behavior; first positional is the inner command. + } + "timeout" => { + const TIMEOUT_CONSUMING: &[&str] = &["-s", "--signal", "-k", "--kill-after"]; + while i < args.len() { + let a = args[i]; + if TIMEOUT_CONSUMING.contains(&a) { + if i + 1 >= args.len() { + return Err(format!("timeout: dangling flag '{a}'")); + } + i += 2; + continue; + } + if a.starts_with("--signal=") || a.starts_with("--kill-after=") { + i += 1; + continue; + } + if a.starts_with('-') { + i += 1; + continue; + } + break; + } + // First positional is DURATION; skip it. Inner = next positional. + if i >= args.len() { + return Err("timeout: missing duration".to_string()); + } + i += 1; + } + _ => return Err(format!("unknown wrapper binary '{wrapper}'")), + } + if i >= args.len() { + return Err(format!( + "wrapper binary '{wrapper}' invoked with no inner command — denied." + )); + } + Ok(&args[i..]) +} + +/// For a segment whose base is a recursable wrapper binary (env / sudo / +/// nice / nohup / timeout), unwrap it and return all inner command bases +/// that must be validated against the allowlist. Recurses into nested +/// wrappers (both wrapper-binary and shell-wrapper varieties), capped at +/// `MAX_SHELL_RECURSION_DEPTH`. (S9-08.) +fn extract_wrapper_binary_chain(segment: &str, depth: u32) -> Result, String> { + if depth > MAX_SHELL_RECURSION_DEPTH { + return Err(format!( + "Wrapper-binary recursion exceeds depth cap of {MAX_SHELL_RECURSION_DEPTH} — denied." + )); + } + let trimmed = segment.trim(); + let base = extract_base_command(trimmed); + let base_lower = base.to_lowercase(); + let base_normalized = base_lower.strip_suffix(".exe").unwrap_or(&base_lower); + + if !WRAPPER_BINARIES_RECURSE.contains(&base_normalized) { + return Ok(Vec::new()); + } + + let args: Vec<&str> = trimmed.split_whitespace().skip(1).collect(); + let inner = unwrap_wrapper_args(base_normalized, &args)?; + let inner_segment = inner.join(" "); + + // Hard-deny / load-from-disk checks on the unwrapped inner segment. + check_wrapper_binary_deny(&inner_segment)?; + check_inline_script_interpreter(&inner_segment)?; + check_load_from_disk(&inner_segment)?; + + let mut chain: Vec = Vec::new(); + let inner_base = extract_base_command(&inner_segment).to_string(); + if !inner_base.is_empty() { + chain.push(inner_base.clone()); + } + + let inner_base_lower = inner_base.to_lowercase(); + let inner_base_normalized = inner_base_lower + .strip_suffix(".exe") + .unwrap_or(&inner_base_lower); + + if SHELL_WRAPPERS.contains(&inner_base_normalized) { + // Inner is a shell wrapper (e.g. `sudo bash -c "..."`). + let shell_inner = extract_shell_wrapper_inner(&inner_segment, depth + 1)?; + chain.extend(shell_inner); + } else if WRAPPER_BINARIES_RECURSE.contains(&inner_base_normalized) { + // Inner is another wrapper binary (e.g. `sudo env FOO=bar /bin/ls`). + let nested = extract_wrapper_binary_chain(&inner_segment, depth + 1)?; + chain.extend(nested); + } + Ok(chain) +} + + /// If the base command is a known shell wrapper, extract any inline script /// passed via -Command / -c / /c (or via PowerShell -EncodedCommand) and /// return the commands within it. @@ -412,7 +661,13 @@ fn extract_inner_script_commands(script: &str, depth: u32) -> Result } } } - let segment = &rest[..earliest_pos]; + let segment_raw = &rest[..earliest_pos]; + let segment = segment_raw.trim(); + // SECURITY (S9-08): apply hard-deny gates inside nested shell-wrapper + // scripts too, so `bash -c "xargs ..."` / `bash -c "python -c ..."` + // cannot bypass the outer-level checks. + check_wrapper_binary_deny(segment)?; + check_inline_script_interpreter(segment)?; let base = extract_base_command(segment); if !base.is_empty() { commands.push(base.to_string()); @@ -425,6 +680,12 @@ fn extract_inner_script_commands(script: &str, depth: u32) -> Result let inner = extract_shell_wrapper_inner(segment, depth + 1)?; commands.extend(inner); } + // S9-08: also recurse into wrapper-binary inner commands inside + // shell scripts (e.g. `bash -c "sudo ls"` must validate `ls`). + if WRAPPER_BINARIES_RECURSE.contains(&base_normalized) { + let chain = extract_wrapper_binary_chain(segment, depth)?; + commands.extend(chain); + } } if earliest_pos + earliest_len >= rest.len() { break; @@ -434,6 +695,37 @@ fn extract_inner_script_commands(script: &str, depth: u32) -> Result Ok(commands) } +/// Split a command string into segments by top-level shell separators +/// (`;`, `&&`, `||`, `|`). Returns trimmed, non-empty segment slices. +/// Used by `validate_command_allowlist` to apply per-segment S9-08 gates. +fn extract_all_segments(command: &str) -> Vec<&str> { + let mut segs = Vec::new(); + let mut rest = command; + while !rest.is_empty() { + let separators: &[&str] = &["&&", "||", "|", ";"]; + let mut earliest_pos = rest.len(); + let mut earliest_len = 0; + for sep in separators { + if let Some(pos) = rest.find(sep) { + if pos < earliest_pos { + earliest_pos = pos; + earliest_len = sep.len(); + } + } + } + let segment = rest[..earliest_pos].trim(); + if !segment.is_empty() { + segs.push(segment); + } + if earliest_pos + earliest_len >= rest.len() { + break; + } + rest = &rest[earliest_pos + earliest_len..]; + } + segs +} + +#[cfg(test)] /// Extract all commands from a shell command string. /// Handles pipes (`|`), semicolons (`;`), `&&`, and `||`. fn extract_all_commands(command: &str) -> Vec<&str> { @@ -506,14 +798,28 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result< } } - let base_commands = extract_all_commands(command); - for base in &base_commands { + // SECURITY (S9-08): per-segment hard-deny gates and wrapper-binary + // recursion. Build the full set of base commands that must be in + // the allowlist, including binaries reached through env / sudo / + // nice / nohup / timeout. + let mut all_bases: Vec = Vec::new(); + for seg in extract_all_segments(command) { + check_wrapper_binary_deny(seg)?; + check_inline_script_interpreter(seg)?; + let base = extract_base_command(seg); + if !base.is_empty() { + all_bases.push(base.to_string()); + } + let chain = extract_wrapper_binary_chain(seg, 1)?; + all_bases.extend(chain); + } + for base in &all_bases { // Check safe_bins first - if policy.safe_bins.iter().any(|sb| sb == base) { + if policy.safe_bins.iter().any(|sb| sb == base.as_str()) { continue; } // Check allowed_commands - if policy.allowed_commands.iter().any(|ac| ac == base) { + if policy.allowed_commands.iter().any(|ac| ac == base.as_str()) { continue; } return Err(format!( @@ -1584,4 +1890,257 @@ mod tests { assert_eq!(cmds.len(), 1); assert_eq!(cmds[0], "\u{4f60}\u{597d}"); } + + + // ── S9-08 wrapper-binary recursion & hard-deny ───────────────────── + + fn wrapper_policy() -> ExecPolicy { + // Allowlist mode with env/sudo/nice/nohup/timeout + a couple of + // inner binaries allowlisted, plus shell wrappers for nested cases. + ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec![ + "env".into(), "sudo".into(), "nice".into(), "nohup".into(), + "timeout".into(), "ls".into(), "bash".into(), "python3".into(), + ], + ..ExecPolicy::default() + } + } + + #[test] + fn test_s908_env_allowed_inner_passes() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("env FOO=bar ls -la", &p).is_ok()); + } + + #[test] + fn test_s908_env_unlisted_inner_blocked() { + let p = wrapper_policy(); + let err = validate_command_allowlist("env FOO=bar /bin/evil", &p) + .expect_err("inner /bin/evil must be rejected"); + assert!(err.contains("evil"), "got: {err}"); + } + + #[test] + fn test_s908_env_with_unset_flag() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("env -u HOME ls", &p).is_ok()); + } + + #[test] + fn test_s908_env_double_dash_passes() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("env -- ls", &p).is_ok()); + } + + #[test] + fn test_s908_sudo_unlisted_inner_blocked() { + let p = wrapper_policy(); + let err = validate_command_allowlist("sudo /bin/evil", &p) + .expect_err("inner /bin/evil must be rejected"); + assert!(err.contains("evil"), "got: {err}"); + } + + #[test] + fn test_s908_sudo_with_user_flag_passes() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("sudo -u root ls", &p).is_ok()); + } + + #[test] + fn test_s908_sudo_bash_inner_validates_inner_command() { + let p = wrapper_policy(); + // sudo+bash allowlisted, ls allowlisted → pass. + assert!(validate_command_allowlist("sudo bash -c \"ls\"", &p).is_ok()); + // sudo+bash allowlisted, evil NOT → reject. + let err = validate_command_allowlist("sudo bash -c \"evil\"", &p) + .expect_err("inner evil must be rejected"); + assert!(err.contains("evil"), "got: {err}"); + } + + #[test] + fn test_s908_sudo_env_nested_chain() { + let p = wrapper_policy(); + // sudo → env → ls. All allowlisted, should pass. + assert!(validate_command_allowlist("sudo env FOO=bar ls", &p).is_ok()); + // sudo → env → evil. ls swapped to evil, must reject. + let err = validate_command_allowlist("sudo env FOO=bar evil", &p) + .expect_err("nested inner evil must be rejected"); + assert!(err.contains("evil"), "got: {err}"); + } + + #[test] + fn test_s908_nice_unlisted_blocked() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("nice -n 10 evil", &p).is_err()); + assert!(validate_command_allowlist("nice -n 10 ls", &p).is_ok()); + } + + #[test] + fn test_s908_nohup_inner_validated() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("nohup ls", &p).is_ok()); + assert!(validate_command_allowlist("nohup evil", &p).is_err()); + } + + #[test] + fn test_s908_timeout_skips_duration() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("timeout 5s ls", &p).is_ok()); + assert!(validate_command_allowlist("timeout 5s evil", &p).is_err()); + } + + #[test] + fn test_s908_timeout_with_signal_flag() { + let p = wrapper_policy(); + assert!(validate_command_allowlist("timeout -s KILL 5s ls", &p).is_ok()); + } + + #[test] + fn test_s908_xargs_hard_denied_even_if_allowlisted() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["xargs".into(), "ls".into()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist("xargs ls", &p) + .expect_err("xargs must be hard-denied"); + assert!(err.contains("hard-denied"), "got: {err}"); + } + + #[test] + fn test_s908_find_hard_denied() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["find".into()], + ..ExecPolicy::default() + }; + assert!(validate_command_allowlist("find . -name foo", &p).is_err()); + } + + #[test] + fn test_s908_strace_hard_denied() { + let p = wrapper_policy(); + // strace not in allowlist anyway, but ensure error message is the deny one. + let p2 = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["strace".into(), "ls".into()], + ..ExecPolicy::default() + }; + let err = validate_command_allowlist("strace ls", &p2) + .expect_err("strace must be hard-denied"); + assert!(err.contains("hard-denied"), "got: {err}"); + // Also via wrapper_policy: same outcome. + assert!(validate_command_allowlist("strace ls", &p).is_err()); + } + + #[test] + fn test_s908_time_chroot_unshare_setsid_stdbuf_flock_gdb_denied() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec![ + "time".into(), "chroot".into(), "unshare".into(), "setsid".into(), + "stdbuf".into(), "flock".into(), "gdb".into(), "ls".into(), + ], + ..ExecPolicy::default() + }; + for w in &["time", "chroot", "unshare", "setsid", "stdbuf", "flock", "gdb"] { + let cmd = format!("{w} ls"); + let err = validate_command_allowlist(&cmd, &p) + .expect_err(&format!("{w} must be hard-denied")); + assert!(err.contains("hard-denied"), "{w}: got: {err}"); + } + } + + #[test] + fn test_s908_python_dash_c_denied() { + let p = wrapper_policy(); + let err = validate_command_allowlist("python3 -c \"import os\"", &p) + .expect_err("python3 -c must be denied"); + assert!(err.contains("inline-script flag"), "got: {err}"); + } + + #[test] + fn test_s908_python_script_file_passes() { + let p = wrapper_policy(); + // python3 with a script file (no -c) is fine. + assert!(validate_command_allowlist("python3 script.py", &p).is_ok()); + } + + #[test] + fn test_s908_node_eval_flags_denied() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["node".into()], + ..ExecPolicy::default() + }; + for flag in &["-e", "--eval", "-p", "--print"] { + let cmd = format!("node {flag} foo"); + assert!( + validate_command_allowlist(&cmd, &p).is_err(), + "node {flag} should be denied" + ); + } + } + + #[test] + fn test_s908_perl_dash_e_denied() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["perl".into()], + ..ExecPolicy::default() + }; + assert!(validate_command_allowlist("perl -e foo", &p).is_err()); + assert!(validate_command_allowlist("perl -E foo", &p).is_err()); + } + + #[test] + fn test_s908_ruby_dash_e_denied() { + let p = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["ruby".into()], + ..ExecPolicy::default() + }; + assert!(validate_command_allowlist("ruby -e foo", &p).is_err()); + } + + #[test] + fn test_s908_bash_c_sudo_inner_validates() { + // bash -c "sudo ls" — bash is shell wrapper, inner script contains + // sudo+ls. Must recurse into sudo and validate ls. + let p = wrapper_policy(); + assert!(validate_command_allowlist("bash -c \"sudo ls\"", &p).is_ok()); + let err = validate_command_allowlist("bash -c \"sudo evil\"", &p) + .expect_err("nested sudo evil must be rejected"); + assert!(err.contains("evil"), "got: {err}"); + } + + #[test] + fn test_s908_bash_c_xargs_inside_denied() { + // xargs hard-deny must fire even when nested inside a shell wrapper. + let p = wrapper_policy(); + let err = validate_command_allowlist("bash -c \"xargs ls\"", &p) + .expect_err("nested xargs must be denied"); + assert!(err.contains("hard-denied"), "got: {err}"); + } + + #[test] + fn test_s908_wrapper_recursion_depth_cap() { + // sudo → env → sudo → ls is depth 3 in wrapper-binary chain + // (extract_wrapper_binary_chain starts at depth 1; nests bump it to + // 2, then 3 > MAX_SHELL_RECURSION_DEPTH = 2 → reject). + let p = wrapper_policy(); + let err = validate_command_allowlist("sudo env FOO=bar sudo ls", &p) + .expect_err("depth-3 wrapper chain must be rejected"); + assert!(err.contains("depth cap"), "got: {err}"); + } + + #[test] + fn test_s908_wrapper_without_inner_rejected() { + let p = wrapper_policy(); + // `sudo` with no inner command must reject (fail-closed). + assert!(validate_command_allowlist("sudo", &p).is_err()); + // `env` with only KEY=VALUE assignments and no inner command must reject. + assert!(validate_command_allowlist("env FOO=bar", &p).is_err()); + } } From 8c74500ce135784659df1c40fffac97b1db96bf4 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 21:57:46 -0700 Subject: [PATCH 034/105] fix(security): HTTP /mcp exec_policy parity with bridge IPC (S3-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes S3-01 from the bridge-v2 audit. Before this commit, `shell_exec` dispatched through the HTTP `/mcp` endpoint ran with `exec_policy = None` and an empty hand-allowed-env list — degrading to the daemon-global `ExecPolicy` (typically `Full` on developer setups) and silently bypassing every per-agent manifest gate the operator had authored. The bridge IPC path applied the gate correctly; HTTP did not. This commit: 1. Extracts the per-agent resolution into a shared helper module `openfang_runtime::agent_tool_context` exposing: - `AgentExecContext::from_manifest(&AgentManifest)` — pure read-only derivation of `exec_policy` and `hand_allowed_env` from the manifest, matching the existing pattern at agent_loop.rs:320, agent_loop.rs:1572, and bridge_ipc.rs:553. - `EXEC_POLICY_REQUIRED_TOOLS` + `requires_exec_policy(tool)` — the tight set of tools (currently `shell_exec`) whose dispatch is unsafe without a manifest-bound policy. 2. Wires the helper into `bridge_ipc::dispatch_call`, replacing the inline 16-line extraction with a 4-line shared call. Behavior unchanged on the bridge IPC path. 3. Wires the helper into `routes::mcp_http`: - Resolves the registry entry once so workspace lookup and exec_context lookup share a manifest snapshot (no TOCTOU). - Fail-loud (-32602) when `requires_exec_policy(tool_name)` and no manifest-bound `exec_policy` is available — distinct error messages for "no `_agent_id` supplied", "agent has no registry entry", and "manifest has no `[exec_policy]`". Per Ben's call: loud during the security-fix window so misconfigured callers surface immediately rather than silently degrade. - Threads `allowed_env_arg` and `effective_exec_policy` into `execute_tool` from `AgentExecContext` instead of hard-coded `None, None`. Tests (7 new in `agent_tool_context::tests`): - Default manifest → no policy, no env. - Manifest with `exec_policy` propagates intact (mode + allowed_commands). - `hand_allowed_env` array reads from metadata correctly. - Malformed `hand_allowed_env` value (string instead of array) → fail-closed to empty (NOT partial-bind). - Empty `hand_allowed_env` array → `allowed_env()` returns `None` (parity with bridge_ipc.rs:559's "fall through to runtime default" semantics, not "explicitly grant nothing"). - `requires_exec_policy` flags `shell_exec` and nothing else. - Drift-catcher: `EXEC_POLICY_REQUIRED_TOOLS` still contains `shell_exec` (forces intentional change if someone removes it). Verification: - `cargo test -p openfang-runtime --lib agent_tool_context` → 7 passed, 0 failed. - `cargo test -p openfang-api --lib` → 113 passed, 0 failed (bridge_ipc tests unchanged after refactor). - `cargo test -p openfang-runtime --lib subprocess_sandbox` → 88 passed (prior Stage 9 commits unaffected). - `cargo build -p openfang-api` clean. Files touched: 4 (1 new). +143/-19 LOC including the new helper module and its tests. Bridge-v2 audit: S3-01 (HIGH, block-this-PR). Stage 9 rollup unaffected. --- crates/openfang-api/src/bridge_ipc.rs | 25 +-- crates/openfang-api/src/routes.rs | 72 ++++++- .../src/agent_tool_context.rs | 196 ++++++++++++++++++ crates/openfang-runtime/src/lib.rs | 1 + 4 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 crates/openfang-runtime/src/agent_tool_context.rs diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 969507ffca..3bf38f1f01 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -546,21 +546,16 @@ async fn dispatch_call( let workspace_root_arg: Option<&Path> = workspace_path.as_deref(); // shell_exec needs both `exec_policy` (allowlist / full / deny decision) - // and `allowed_env_vars` (hand-granted env passthrough — see - // agent_loop.rs:320 for the mirror of this metadata lookup). Every other - // bridge tool ignores both, so this is cheap to compute unconditionally. - let effective_exec_policy = entry.manifest.exec_policy.as_ref(); - let hand_allowed_env: Vec = entry - .manifest - .metadata - .get("hand_allowed_env") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - let allowed_env_arg: Option<&[String]> = if hand_allowed_env.is_empty() { - None - } else { - Some(&hand_allowed_env) - }; + // and `allowed_env_vars` (hand-granted env passthrough). Resolution is + // shared with the HTTP `/mcp` path via `AgentExecContext` so the two + // surfaces apply identical scoping — see S3-01 in the bridge-v2 audit. + // Every other bridge tool ignores both, so this is cheap to compute + // unconditionally. + let exec_ctx = openfang_runtime::agent_tool_context::AgentExecContext::from_manifest( + &entry.manifest, + ); + let effective_exec_policy = exec_ctx.exec_policy_ref(); + let allowed_env_arg: Option<&[String]> = exec_ctx.allowed_env(); let result = openfang_runtime::tool_runner::execute_tool( &format!("bridge-{}", call.request_id), diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 13cd521705..1961b7126a 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7071,14 +7071,31 @@ pub async fn mcp_http( // Canonical FS-sandbox gate (see // `openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS`). // Single source of truth across the IPC and HTTP `/mcp` surfaces. + use openfang_runtime::agent_tool_context::{ + AgentExecContext, requires_exec_policy, + }; use openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS; let agent_id_opt: Option = arguments .get("_agent_id") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()); - let workspace_path: Option = agent_id_opt - .and_then(|aid| state.kernel.registry.get(aid)) + // Resolve the registry entry once so both workspace and exec + // context come from the same manifest snapshot (no TOCTOU between + // the workspace lookup and the exec_policy lookup). + let registry_entry = agent_id_opt + .and_then(|aid| state.kernel.registry.get(aid)); + let workspace_path: Option = registry_entry + .as_ref() .and_then(|entry| entry.manifest.workspace.clone()); + // S3-01: resolve per-agent exec_policy + hand_allowed_env via the + // shared helper so HTTP `/mcp` applies the SAME scoping the bridge + // IPC path applies. Without this, `shell_exec` over HTTP ran with + // exec_policy=None (i.e. degraded to the daemon-global policy) and + // empty env-passthrough — bypassing every per-agent manifest gate + // an operator authored. + let exec_ctx: Option = registry_entry + .as_ref() + .map(|entry| AgentExecContext::from_manifest(&entry.manifest)); if FS_SANDBOXED_TOOLS.contains(&tool_name) && workspace_path.is_none() { return Json(serde_json::json!({ "jsonrpc": "2.0", @@ -7093,6 +7110,45 @@ pub async fn mcp_http( } })); } + // S3-01 fail-loud: tools in EXEC_POLICY_REQUIRED_TOOLS (currently + // just `shell_exec`) refuse to dispatch without a manifest-bound + // exec_policy. Falling through to `execute_tool` with + // `exec_policy = None` degrades to the daemon-global policy — + // typically `Full` on developer setups — and silently bypasses + // every per-agent gate. Reject with a clear error so misconfigured + // callers surface during the security-fix window rather than + // running unbounded shells. + if requires_exec_policy(tool_name) { + let has_policy = exec_ctx + .as_ref() + .and_then(|c| c.exec_policy_ref()) + .is_some(); + if !has_policy { + let reason = match agent_id_opt { + None => "no `_agent_id` provided in arguments".to_string(), + Some(aid) => match registry_entry.as_ref() { + None => format!("agent '{aid}' has no registry entry"), + Some(_) => format!( + "agent '{aid}' has no `[exec_policy]` in its \ + manifest; refusing to dispatch against the \ + daemon-global policy" + ), + }, + }; + return Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned(), + "error": { + "code": -32602, + "message": format!( + "tool '{tool_name}' requires a manifest-bound \ + exec_policy ({reason}). Refusing to dispatch \ + without per-agent shell scoping (S3-01)." + ) + } + })); + } + } // Snapshot skill registry before async call (RwLockReadGuard is !Send) let skill_snapshot = state @@ -7106,6 +7162,14 @@ pub async fn mcp_http( let kernel_handle: Arc = state.kernel.clone() as Arc; let agent_id_string: Option = agent_id_opt.as_ref().map(|a| a.to_string()); + // S3-01: source allowed_env + exec_policy from the manifest-bound + // context (resolved above). Falls through to `None, None` only + // when no `_agent_id` was supplied AND the tool isn't gated by + // `requires_exec_policy` (which short-circuits above). + let allowed_env_arg: Option<&[String]> = + exec_ctx.as_ref().and_then(|c| c.allowed_env()); + let effective_exec_policy = + exec_ctx.as_ref().and_then(|c| c.exec_policy_ref()); let result = openfang_runtime::tool_runner::execute_tool( "mcp-http", tool_name, @@ -7117,10 +7181,10 @@ pub async fn mcp_http( Some(&state.kernel.mcp_connections), Some(&state.kernel.web_ctx), Some(&state.kernel.browser_ctx), - None, + allowed_env_arg, workspace_path.as_deref(), Some(&state.kernel.media_engine), - None, // exec_policy + effective_exec_policy, if state.kernel.config.tts.enabled { Some(&state.kernel.tts_engine) } else { diff --git a/crates/openfang-runtime/src/agent_tool_context.rs b/crates/openfang-runtime/src/agent_tool_context.rs new file mode 100644 index 0000000000..df2e81fcaa --- /dev/null +++ b/crates/openfang-runtime/src/agent_tool_context.rs @@ -0,0 +1,196 @@ +//! Per-agent execution context resolved from an [`AgentManifest`]. +//! +//! Single source of truth so the bridge IPC dispatcher +//! (`openfang-api::bridge_ipc::dispatch_call`) and the HTTP `/mcp` endpoint +//! (`openfang-api::routes::mcp_http`) apply *identical* sandbox / +//! exec_policy / env-passthrough scoping when invoking +//! [`crate::tool_runner::execute_tool`]. +//! +//! Closes **S3-01** (HTTP `/mcp` lacked exec_policy + env-passthrough parity +//! with the bridge IPC path) from the bridge-v2 audit. Prior to this fix, +//! a caller authenticated against the dashboard could invoke `shell_exec` +//! over HTTP `/mcp` and execute *outside* the manifest's `ExecPolicy` — +//! effectively `ExecPolicy::Full` regardless of the agent's actual policy. +//! +//! Both call sites resolve the registry entry independently (they have +//! their own permission gates and workspace plumbing), then funnel the +//! manifest through [`AgentExecContext::from_manifest`] to derive the +//! arguments threaded into `execute_tool`. + +use openfang_types::agent::AgentManifest; +use openfang_types::config::ExecPolicy; + +/// Resolved per-agent execution context. Built from an [`AgentManifest`] +/// at dispatch time; held by reference into the surrounding scope so the +/// borrowed slices can be passed straight to +/// [`crate::tool_runner::execute_tool`]. +#[derive(Debug, Clone)] +pub struct AgentExecContext { + /// Per-agent `[exec_policy]` override from `agent.toml`. `None` means + /// the agent did not override the global policy — most call sites + /// treat this as "fall back to global" by passing `None` straight + /// through to `execute_tool`, which then enforces the daemon-global + /// `ExecPolicy` from `config.toml`. + pub exec_policy: Option, + + /// Subset of host environment variables the kernel has explicitly + /// authorized the agent to receive in `shell_exec` subprocesses + /// (stored in `manifest.metadata["hand_allowed_env"]` — + /// see `openfang_kernel::kernel::OpenFangKernel::ensure_hand_metadata` + /// at kernel.rs:3932). Empty vector means "no extra env vars beyond + /// the global `shell_env_passthrough` list." + pub hand_allowed_env: Vec, +} + +impl AgentExecContext { + /// Resolve context from the agent's manifest. Pure read-only helper — + /// no I/O, no lock acquisition. + pub fn from_manifest(manifest: &AgentManifest) -> Self { + let exec_policy = manifest.exec_policy.clone(); + let hand_allowed_env: Vec = manifest + .metadata + .get("hand_allowed_env") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + Self { + exec_policy, + hand_allowed_env, + } + } + + /// Borrow the `exec_policy` as the `Option<&ExecPolicy>` shape + /// `execute_tool` expects. + pub fn exec_policy_ref(&self) -> Option<&ExecPolicy> { + self.exec_policy.as_ref() + } + + /// Borrow the hand-allowed env list as the `Option<&[String]>` shape + /// `execute_tool` expects. Returns `None` (not `Some(&[])`) for an + /// empty list — matches the call-site convention in + /// `agent_loop.rs:953` and `bridge_ipc.rs:559` where `None` selects + /// the runtime default instead of "explicitly grant nothing." + pub fn allowed_env(&self) -> Option<&[String]> { + if self.hand_allowed_env.is_empty() { + None + } else { + Some(&self.hand_allowed_env) + } + } +} + +/// Tools whose dispatch is *unsafe without* an agent-bound exec_policy +/// resolution. If any of these are requested through a surface that +/// cannot bind the call to an [`AgentExecContext`] (e.g. HTTP `/mcp` +/// without `_agent_id`), the surface MUST fail-loud rather than fall +/// through to `execute_tool` with `exec_policy = None` — that path +/// degrades to the daemon-global policy, which is typically `Full` on +/// developer setups and silently bypasses every manifest gate the +/// operator authored. +/// +/// Keep this list tight. Anything added here closes one HTTP-side +/// privilege-escalation vector and breaks the corresponding caller +/// pattern simultaneously. +pub const EXEC_POLICY_REQUIRED_TOOLS: &[&str] = &["shell_exec"]; + +/// Returns `true` when `tool_name` is in [`EXEC_POLICY_REQUIRED_TOOLS`]. +/// Constant-time string compare; safe to call per-request. +pub fn requires_exec_policy(tool_name: &str) -> bool { + EXEC_POLICY_REQUIRED_TOOLS.contains(&tool_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use openfang_types::config::{ExecPolicy, ExecSecurityMode}; + use serde_json::json; + + fn manifest_with_metadata(kv: Vec<(&str, serde_json::Value)>) -> AgentManifest { + let mut m = AgentManifest::default(); + for (k, v) in kv { + m.metadata.insert(k.to_string(), v); + } + m + } + + #[test] + fn default_manifest_yields_no_policy_no_env() { + let m = AgentManifest::default(); + let ctx = AgentExecContext::from_manifest(&m); + assert!(ctx.exec_policy_ref().is_none()); + assert!(ctx.allowed_env().is_none()); + assert!(ctx.hand_allowed_env.is_empty()); + } + + #[test] + fn manifest_with_exec_policy_propagates() { + let mut m = AgentManifest::default(); + let mut policy = ExecPolicy::default(); + policy.mode = ExecSecurityMode::Allowlist; + policy.allowed_commands = vec!["echo".into(), "ls".into()]; + m.exec_policy = Some(policy.clone()); + + let ctx = AgentExecContext::from_manifest(&m); + let resolved = ctx.exec_policy_ref().expect("policy should be present"); + assert_eq!(resolved.mode, ExecSecurityMode::Allowlist); + assert_eq!(resolved.allowed_commands, vec!["echo", "ls"]); + } + + #[test] + fn hand_allowed_env_array_is_read_from_metadata() { + let m = manifest_with_metadata(vec![( + "hand_allowed_env", + json!(["OPENAI_API_KEY", "GITHUB_TOKEN"]), + )]); + let ctx = AgentExecContext::from_manifest(&m); + assert_eq!( + ctx.hand_allowed_env, + vec!["OPENAI_API_KEY".to_string(), "GITHUB_TOKEN".to_string()] + ); + assert_eq!( + ctx.allowed_env(), + Some(&["OPENAI_API_KEY".to_string(), "GITHUB_TOKEN".to_string()][..]) + ); + } + + #[test] + fn malformed_hand_allowed_env_falls_back_to_empty() { + // S3-01 fail-closed: a malformed metadata value should NOT propagate + // as `Some(...)` — it would partially-bind env passthrough on a + // best-effort basis, which is the inverse of what an operator wants + // out of a security gate. Treat malformed as "no override". + let m = manifest_with_metadata(vec![( + "hand_allowed_env", + json!("not an array"), + )]); + let ctx = AgentExecContext::from_manifest(&m); + assert!(ctx.hand_allowed_env.is_empty()); + assert!(ctx.allowed_env().is_none()); + } + + #[test] + fn empty_hand_allowed_env_returns_none_not_empty_slice() { + let m = manifest_with_metadata(vec![("hand_allowed_env", json!([]))]); + let ctx = AgentExecContext::from_manifest(&m); + // Parity with bridge_ipc.rs:559 — `None` means "fall through to + // runtime default", not "explicitly grant nothing". + assert!(ctx.allowed_env().is_none()); + } + + #[test] + fn requires_exec_policy_only_flags_shell_exec() { + assert!(requires_exec_policy("shell_exec")); + assert!(!requires_exec_policy("file_read")); + assert!(!requires_exec_policy("agent_send")); + assert!(!requires_exec_policy("web_fetch")); + assert!(!requires_exec_policy("")); + } + + #[test] + fn exec_policy_required_tools_is_non_empty_and_stable() { + // Drift-catcher: if someone removes shell_exec from this list + // without replacing it with an equivalent gate elsewhere, the + // S3-01 fix regresses silently. Force the change to land + // intentionally. + assert!(EXEC_POLICY_REQUIRED_TOOLS.contains(&"shell_exec")); + } +} diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index 731ccfa5b8..3c3aa1a022 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -10,6 +10,7 @@ pub const USER_AGENT: &str = "openfang/0.3.48"; pub mod a2a; pub mod agent_context; pub mod agent_loop; +pub mod agent_tool_context; pub mod apply_patch; pub mod audit; pub mod auth_cooldown; From f01700ceae0576c6c29188358275e4eb30cc960b Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 23:09:19 -0700 Subject: [PATCH 035/105] fix(security): refuse caller-supplied sender identity on HTTP message routes (S6-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/agents/{id}/message` and `/api/agents/{id}/message/stream` accepted `sender_id` / `sender_name` from the request body and threaded them straight into the kernel, where they land in the agent's prompt context as the authoritative "this turn came from $sender". Neither route has a provenance/auth path today (no `BridgeAuthority` resolve, no per-spawn token, no signed envelope), so any local POST could forge any channel identity — e.g. claim to be a specific WhatsApp number or Telegram user-id and the LLM would treat the turn as that user's. This is the impersonation primitive behind the S6-04 → S6-09 → S4-03 chain described in `stage-6-findings.md`. Tight fix: refuse the assertion entirely. Both HTTP routes now hard-fail (400) with a stable error string when `sender_id` or `sender_name` is present, and pass `None`/`None` to the kernel unconditionally. Empty strings are treated as absence (defensive). Channel adapters (Telegram, WhatsApp, Discord, ...) do **not** call these routes — they reach the kernel directly via the channel router — so legitimate channel flows are unaffected. The follow-up that *restores* the legitimate identity-forwarding capability under a proper trust model is filed as **FU-01** in `_shared/openfang/findings/bridge-v2/followups.md` (Option B from the design discussion): add a bridge-auth-style provenance path so verified callers can supply identity, with hard-fail on mismatch, and tag inbound messages with a remote-origin envelope (merges naturally with the S6-09 / S5-01 family). Bundled together because that's a single design across three patch points; cheap to do once, expensive to retrofit later. Implementation: - New `pub(crate) fn reject_unauthenticated_sender_identity(&MessageRequest) -> Option<&'static str>` with stable error strings (test-pinned). - `send_message` and `send_message_stream` both validate before kernel dispatch; on rejection, log a structured warn with the agent id and the reason, return 400 JSON `{"error": }`. - Kernel calls now pass `None, None` for sender identity in both routes — validator guarantees the fields are absent/empty, but we pass `None` explicitly to be defensive (belt-and-suspenders). Tests (6 new, all passing): - `s604_no_sender_fields_is_allowed` - `s604_empty_sender_fields_is_allowed` (empty-string == absence) - `s604_sender_id_alone_is_rejected` - `s604_sender_name_alone_is_rejected` - `s604_both_fields_rejected_with_combined_message` - `s604_reason_strings_are_stable` (pins exact error strings — breaking change for callers if these are bumped) `cargo test -p openfang-api --lib` → **119 passed** (113 prior + 6 new). Closes S6-04 (HIGH, block-this-PR). Refs: FU-01 in `_shared/openfang/findings/bridge-v2/followups.md` --- crates/openfang-api/src/routes.rs | 161 +++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 4 deletions(-) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 1961b7126a..c273978931 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -336,6 +336,50 @@ pub fn inject_attachments_into_session( } } +/// Reject caller-supplied sender identity on unauthenticated HTTP message routes. +/// +/// **S6-04 tight fix.** The `/api/agents/{id}/message` and `/message/stream` +/// HTTP routes have no provenance/auth path today (no `BridgeAuthority` +/// resolve, no per-spawn token, no signed envelope). Any local POST can +/// therefore claim to be any platform user (e.g. a WhatsApp number, +/// a Telegram user-id) and the kernel will faithfully thread that identity +/// into the agent's prompt context, which is then trusted by the LLM as +/// "this turn came from $sender". +/// +/// Until the provenance/auth path exists (tracked as **FU-01** in +/// `_shared/openfang/findings/bridge-v2/followups.md`), the only safe +/// behaviour is to *refuse* requests that try to assert a sender identity +/// at all. Channel adapters (Telegram, WhatsApp, Discord, ...) do **not** +/// call this route — they reach the kernel directly via the channel +/// router — so this rejection has no effect on legitimate channel flows. +/// +/// Returns `Some(reason)` when the request must be rejected (400), +/// `None` when it is safe to forward to the kernel with `sender_id = None`. +pub(crate) fn reject_unauthenticated_sender_identity( + req: &MessageRequest, +) -> Option<&'static str> { + let has_id = req + .sender_id + .as_ref() + .is_some_and(|s| !s.is_empty()); + let has_name = req + .sender_name + .as_ref() + .is_some_and(|s| !s.is_empty()); + match (has_id, has_name) { + (false, false) => None, + (true, false) => Some( + "sender_id is not accepted on this route (no provenance/auth path yet — see FU-01)", + ), + (false, true) => Some( + "sender_name is not accepted on this route (no provenance/auth path yet — see FU-01)", + ), + (true, true) => Some( + "sender_id/sender_name are not accepted on this route (no provenance/auth path yet — see FU-01)", + ), + } +} + /// POST /api/agents/:id/message — Send a message to an agent. pub async fn send_message( State(state): State>, @@ -361,6 +405,18 @@ pub async fn send_message( ); } + // SECURITY (S6-04): refuse caller-supplied sender identity on this route. + if let Some(reason) = reject_unauthenticated_sender_identity(&req) { + tracing::warn!( + agent = %id, + "Rejected /api/agents/{{id}}/message with caller-supplied sender identity: {reason}" + ); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": reason})), + ); + } + // Check agent exists before processing if state.kernel.registry.get(agent_id).is_none() { return ( @@ -391,8 +447,11 @@ pub async fn send_message( &req.message, Some(kernel_handle), content_blocks, - req.sender_id, - req.sender_name, + // SECURITY (S6-04): identity is *never* threaded from this route + // until FU-01 (provenance/auth) lands. Validator above guarantees + // these are None/empty; pass None explicitly to be defensive. + None, + None, ) .await { @@ -1548,6 +1607,19 @@ pub async fn send_message_stream( .into_response(); } + // SECURITY (S6-04): refuse caller-supplied sender identity on this route. + if let Some(reason) = reject_unauthenticated_sender_identity(&req) { + tracing::warn!( + agent = %id, + "Rejected /api/agents/{{id}}/message/stream with caller-supplied sender identity: {reason}" + ); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": reason})), + ) + .into_response(); + } + let agent_id: AgentId = match id.parse() { Ok(id) => id, Err(_) => { @@ -1572,8 +1644,11 @@ pub async fn send_message_stream( agent_id, &req.message, Some(kernel_handle), - req.sender_id, - req.sender_name, + // SECURITY (S6-04): identity is *never* threaded from this route + // until FU-01 (provenance/auth) lands. Validator above guarantees + // these are None/empty; pass None explicitly to be defensive. + None, + None, None, // SSE streaming doesn't support image attachments yet ) { Ok(pair) => pair, @@ -13081,3 +13156,81 @@ mod uninstall_agent_tests { ); } } + + +#[cfg(test)] +mod s604_validator_tests { + use super::*; + use crate::types::MessageRequest; + + fn req(sender_id: Option<&str>, sender_name: Option<&str>) -> MessageRequest { + MessageRequest { + message: "hi".to_string(), + attachments: Vec::new(), + sender_id: sender_id.map(|s| s.to_string()), + sender_name: sender_name.map(|s| s.to_string()), + } + } + + #[test] + fn s604_no_sender_fields_is_allowed() { + assert!(reject_unauthenticated_sender_identity(&req(None, None)).is_none()); + } + + #[test] + fn s604_empty_sender_fields_is_allowed() { + // Defensive: clients sending empty strings (deserialized from `""`) + // are treated as absence, not as an attempted identity assertion. + assert!(reject_unauthenticated_sender_identity(&req(Some(""), Some(""))).is_none()); + } + + #[test] + fn s604_sender_id_alone_is_rejected() { + let reason = reject_unauthenticated_sender_identity(&req(Some("+15551234567"), None)) + .expect("must reject caller-supplied sender_id"); + assert!(reason.contains("sender_id")); + assert!(reason.contains("FU-01")); + } + + #[test] + fn s604_sender_name_alone_is_rejected() { + let reason = reject_unauthenticated_sender_identity(&req(None, Some("Ben"))) + .expect("must reject caller-supplied sender_name"); + assert!(reason.contains("sender_name")); + assert!(reason.contains("FU-01")); + } + + #[test] + fn s604_both_fields_rejected_with_combined_message() { + let reason = + reject_unauthenticated_sender_identity(&req(Some("+15551234567"), Some("Ben"))) + .expect("must reject when both fields are present"); + assert!(reason.contains("sender_id")); + assert!(reason.contains("sender_name")); + assert!(reason.contains("FU-01")); + } + + #[test] + fn s604_reason_strings_are_stable() { + // Stable error strings let CI / clients pattern-match. Changing these + // is a breaking change for callers; bump deliberately. + assert_eq!( + reject_unauthenticated_sender_identity(&req(Some("x"), None)), + Some( + "sender_id is not accepted on this route (no provenance/auth path yet — see FU-01)" + ) + ); + assert_eq!( + reject_unauthenticated_sender_identity(&req(None, Some("x"))), + Some( + "sender_name is not accepted on this route (no provenance/auth path yet — see FU-01)" + ) + ); + assert_eq!( + reject_unauthenticated_sender_identity(&req(Some("x"), Some("y"))), + Some( + "sender_id/sender_name are not accepted on this route (no provenance/auth path yet — see FU-01)" + ) + ); + } +} From b42131873ce3472fbe1d696263f56f8f5c2d3a27 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Mon, 18 May 2026 23:29:05 -0700 Subject: [PATCH 036/105] fix(security): exclude privileged lifecycle tools from bridge default allowlist (S7-06/S4-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's `DEFAULT_ALLOWED` constant — used by `openfang-mcp-bridge`'s `main.rs` when `OPENFANG_BRIDGE_ALLOWED` is unset (legacy/dev/test fallback) — previously included `agent_spawn`, `agent_kill`, and `agent_activate`. In the fallback path any agent could spawn/kill/activate sibling agents regardless of its `agent.toml` capabilities. The production runtime path is unaffected: `agent_loop.rs` already threads the manifest-derived `available_tools` into `OPENFANG_BRIDGE_ALLOWED`, so agents that opt in via manifest still reach these tools. The daemon-side dispatch (`ALLOWED_TOOLS`) and MCP advertise surface (`built_in_tools()`) retain all 17 tools so opted-in agents can still dispatch them. Changes: - `openfang-mcp-bridge::DEFAULT_ALLOWED` shrinks from 17 → 14 entries (drops `agent_spawn`, `agent_kill`, `agent_activate`). - New `openfang-mcp-bridge::PRIVILEGED_DEFAULT_DENY` lists the three excluded tools with the rationale doc-pinned in source. - Drift-catcher in `bridge_ipc::tests` evolves from three-way equality to: - Invariant A (equality): `ALLOWED_TOOLS == built_in_tools()`. - Invariant B (safe-by-default subset): `DEFAULT_ALLOWED ⊂ ALLOWED_TOOLS`, `PRIVILEGED_DEFAULT_DENY ⊂ ALLOWED_TOOLS`, `DEFAULT_ALLOWED ∩ PRIVILEGED_DEFAULT_DENY = ∅`, `DEFAULT_ALLOWED ∪ PRIVILEGED_DEFAULT_DENY = ALLOWED_TOOLS` (so every new tool must be classified default-safe or privileged-deny). - New name-level regression guard `privileged_lifecycle_tools_excluded_from_default` hard-pins the three tool names so a grep for `agent_spawn` lands on the guard. Tests: `cargo test -p openfang-mcp-bridge --lib` → 5 passed. `cargo test -p openfang-api --lib` → 120 passed (was 119; +1 new). Closes S7-06 / S4-02 (bridge-side), the last block-this-PR HIGH on `feat/bridge-tool-surface-v2`. --- crates/openfang-api/src/bridge_ipc.rs | 133 ++++++++++++++++++++------ crates/openfang-mcp-bridge/src/lib.rs | 41 ++++++-- 2 files changed, 138 insertions(+), 36 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 3bf38f1f01..5a50c8a242 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -940,31 +940,37 @@ mod tests { ); } - /// **Drift-catcher: three-way correspondence.** + /// **Drift-catcher: surface correspondence + safe-by-default subset.** /// - /// Three lists must agree on the bridge tool surface: + /// Three lists describe the bridge tool surface, with two distinct + /// relationships: /// /// 1. `openfang_api::bridge_ipc::ALLOWED_TOOLS` — daemon-side dispatch /// allowlist (the call-time gate). /// 2. `openfang_mcp_bridge::built_in_tools()` — MCP advertise surface /// (what CC actually sees in `tools/list`). - /// 3. `openfang_mcp_bridge::DEFAULT_ALLOWED` — bridge process default - /// when `OPENFANG_BRIDGE_ALLOWED` is unset (legacy/dev path). + /// 3. `openfang_mcp_bridge::DEFAULT_ALLOWED` — bridge-process default + /// when `OPENFANG_BRIDGE_ALLOWED` is unset (legacy/dev fallback). /// - /// Lesson from 13b/13d: a tool can be daemon-dispatchable (in - /// `ALLOWED_TOOLS`) but invisible to CC because someone forgot to add it - /// to `built_in_tools()`. The smoke tests fire the IPC path directly and - /// never hit `tools/list`, so the gap shipped silently in two commits in - /// a row. This test fails loudly if any of the three sets drifts. + /// **Invariant A (equality):** `ALLOWED_TOOLS == built_in_tools()`. + /// Lesson from 13b/13d: a tool can be daemon-dispatchable but invisible + /// to CC because someone forgot to add it to `built_in_tools()`. /// - /// If you're here because this test failed: a bridge tool add or - /// remove must touch **all three files** — `crates/openfang-api/src/ - /// bridge_ipc.rs` (`ALLOWED_TOOLS`), `crates/openfang-mcp-bridge/src/ - /// lib.rs` (`built_in_tools` + `DEFAULT_ALLOWED`). Update both before - /// landing the commit. + /// **Invariant B (safe-by-default subset, S7-06 / S4-02):** + /// `DEFAULT_ALLOWED ⊂ ALLOWED_TOOLS`, with the deliberate exclusion of + /// `PRIVILEGED_DEFAULT_DENY` — `agent_spawn`, `agent_kill`, + /// `agent_activate`. The runtime threads the manifest-derived allowlist + /// through `OPENFANG_BRIDGE_ALLOWED`, so opted-in agents still reach + /// these tools; only the no-env-var fallback is narrowed. + /// + /// If you're here because this test failed: a bridge tool add or remove + /// must touch `crates/openfang-api/src/bridge_ipc.rs` (`ALLOWED_TOOLS`) + /// and `crates/openfang-mcp-bridge/src/lib.rs` (`built_in_tools` and + /// usually `DEFAULT_ALLOWED`). For privileged additions, append to + /// `PRIVILEGED_DEFAULT_DENY` and **not** to `DEFAULT_ALLOWED`. #[test] - fn allowlist_three_way_correspondence() { - use openfang_mcp_bridge::{DEFAULT_ALLOWED, built_in_tools}; + fn allowlist_surface_correspondence() { + use openfang_mcp_bridge::{DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY, built_in_tools}; use std::collections::BTreeSet; let daemon_set: BTreeSet<&str> = ALLOWED_TOOLS.iter().copied().collect(); @@ -975,7 +981,10 @@ mod tests { let advertise_borrowed: BTreeSet<&str> = advertise_set.iter().map(|s| s.as_str()).collect(); let default_set: BTreeSet<&str> = DEFAULT_ALLOWED.iter().copied().collect(); + let privileged_set: BTreeSet<&str> = + PRIVILEGED_DEFAULT_DENY.iter().copied().collect(); + // Invariant A — daemon dispatch and MCP advertise must agree. assert_eq!( daemon_set, advertise_borrowed, "drift: ALLOWED_TOOLS (daemon dispatch) ≠ built_in_tools() (MCP advertise). \ @@ -983,35 +992,99 @@ mod tests { daemon_set.difference(&advertise_borrowed).collect::>(), advertise_borrowed.difference(&daemon_set).collect::>(), ); - assert_eq!( - daemon_set, default_set, - "drift: ALLOWED_TOOLS (daemon dispatch) ≠ DEFAULT_ALLOWED (bridge default). \ - daemon-only: {:?}, default-only: {:?}", - daemon_set.difference(&default_set).collect::>(), - default_set.difference(&daemon_set).collect::>(), + + // Invariant B.1 — every default-tool is daemon-dispatchable. + let default_extras: Vec<&&str> = default_set.difference(&daemon_set).collect(); + assert!( + default_extras.is_empty(), + "drift: DEFAULT_ALLOWED contains tools missing from ALLOWED_TOOLS: {:?}", + default_extras, + ); + + // Invariant B.2 — every privileged tool is daemon-dispatchable. + let privileged_extras: Vec<&&str> = privileged_set.difference(&daemon_set).collect(); + assert!( + privileged_extras.is_empty(), + "drift: PRIVILEGED_DEFAULT_DENY contains tools missing from ALLOWED_TOOLS: {:?}", + privileged_extras, + ); + + // Invariant B.3 — privileged tools must NOT be in the default + // fallback. This is the S7-06 / S4-02 pin: if a future commit + // accidentally re-adds `agent_spawn` etc. to DEFAULT_ALLOWED, the + // legacy/dev path silently re-grants lifecycle control. + let leaked: Vec<&&str> = privileged_set.intersection(&default_set).collect(); + assert!( + leaked.is_empty(), + "S7-06/S4-02 regression: PRIVILEGED_DEFAULT_DENY entries leaked into \ + DEFAULT_ALLOWED: {:?}. Privileged agent-lifecycle tools must reach the \ + bridge only via manifest-driven OPENFANG_BRIDGE_ALLOWED.", + leaked, + ); + + // Invariant B.4 — the privileged set and the default set together + // cover the daemon surface. Catches "added a new tool to + // ALLOWED_TOOLS/built_in_tools but forgot to classify it as + // default-safe or privileged-deny". + let mut union: BTreeSet<&str> = default_set.clone(); + union.extend(privileged_set.iter().copied()); + let unclassified: Vec<&&str> = daemon_set.difference(&union).collect(); + assert!( + unclassified.is_empty(), + "drift: ALLOWED_TOOLS entries unclassified (neither in DEFAULT_ALLOWED \ + nor PRIVILEGED_DEFAULT_DENY): {:?}", + unclassified, ); } - /// Pins the tool-surface cardinality at 17. Bumps to this number are - /// expected when a new bridge tool lands — update intentionally, in - /// lockstep with the three sets exercised by - /// [`allowlist_three_way_correspondence`]. + /// Pins the tool-surface cardinality. Bumps are expected when a new + /// bridge tool lands — update intentionally, in lockstep with the sets + /// exercised by [`allowlist_surface_correspondence`]. + /// + /// Privileged tools (S7-06 / S4-02) are absent from `DEFAULT_ALLOWED` + /// by design, so its cardinality lags `ALLOWED_TOOLS` by exactly + /// `PRIVILEGED_DEFAULT_DENY.len()`. #[test] - fn allowlist_count_is_seventeen() { - use openfang_mcp_bridge::{DEFAULT_ALLOWED, built_in_tools}; + fn allowlist_cardinality_pin() { + use openfang_mcp_bridge::{DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY, built_in_tools}; assert_eq!(ALLOWED_TOOLS.len(), 17, "ALLOWED_TOOLS surface cardinality"); assert_eq!( built_in_tools().len(), 17, "built_in_tools() advertise surface cardinality" ); + assert_eq!( + PRIVILEGED_DEFAULT_DENY.len(), + 3, + "PRIVILEGED_DEFAULT_DENY cardinality (agent_spawn/agent_kill/agent_activate)" + ); assert_eq!( DEFAULT_ALLOWED.len(), - 17, - "DEFAULT_ALLOWED bridge-default cardinality" + 14, + "DEFAULT_ALLOWED bridge-default cardinality (17 − 3 privileged)" ); } + /// S7-06 / S4-02 pin — explicit, name-level. The set-level test above + /// catches drift generically; this one names the three tools so a + /// grep for `agent_spawn` lands on the regression guard. + #[test] + fn privileged_lifecycle_tools_excluded_from_default() { + use openfang_mcp_bridge::{DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY}; + for tool in ["agent_spawn", "agent_kill", "agent_activate"] { + assert!( + PRIVILEGED_DEFAULT_DENY.contains(&tool), + "{tool} must be in PRIVILEGED_DEFAULT_DENY", + ); + assert!( + !DEFAULT_ALLOWED.contains(&tool), + "S7-06/S4-02 regression: {tool} re-introduced into DEFAULT_ALLOWED. \ + Privileged agent-lifecycle tools must only reach the bridge via \ + manifest-derived OPENFANG_BRIDGE_ALLOWED.", + ); + } + } + #[test] fn apply_patch_is_workspace_sandboxed() { // tool_apply_patch resolves every patch-embedded path against diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 735d0601a0..ada4929ac9 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -126,9 +126,21 @@ pub enum ToolDispatchError { /// whether any given bridge instance actually advertises it. /// Default tool allowlist used by `main.rs` when [`OPENFANG_BRIDGE_ALLOWED`] /// is unset (legacy/dev path). Lives in the library — not in `main.rs` — so -/// the bridge_ipc drift-catcher test in `openfang-api` can assert three-way -/// correspondence between this set, [`built_in_tools`], and the daemon-side -/// `bridge_ipc::ALLOWED_TOOLS`. Three files, one truth. +/// the bridge_ipc drift-catcher tests in `openfang-api` can assert the +/// surface relationships between this set, [`built_in_tools`], and the +/// daemon-side `bridge_ipc::ALLOWED_TOOLS`. +/// +/// **Safe-by-default policy (S7-06 / S4-02):** the default set deliberately +/// *excludes* the privileged agent-lifecycle tools enumerated by +/// [`PRIVILEGED_DEFAULT_DENY`] — `agent_spawn`, `agent_kill`, +/// `agent_activate`. These remain in [`built_in_tools`] and in +/// `bridge_ipc::ALLOWED_TOOLS` (daemon dispatch) so that agents whose +/// `agent.toml` grants them can still invoke them: the runtime threads +/// the manifest-derived allowlist through `OPENFANG_BRIDGE_ALLOWED` +/// (see `agent_loop.rs`'s `allowed_tools` plumbing). Only the +/// no-env-var fallback — used in tests, dev shells, and any caller +/// who forgot to set the var — is downgraded to the non-privileged +/// subset. /// /// [`OPENFANG_BRIDGE_ALLOWED`]: ../../openfang_mcp_bridge/index.html pub const DEFAULT_ALLOWED: &[&str] = &[ @@ -140,17 +152,34 @@ pub const DEFAULT_ALLOWED: &[&str] = &[ "agent_list", "channel_send", "agent_send", - "agent_spawn", - "agent_kill", "memory_store", "memory_recall", - "agent_activate", "agent_find", "shell_exec", "web_search", "apply_patch", ]; +/// Agent-lifecycle tools that are dispatchable by the daemon and advertised +/// by the bridge, **but excluded from [`DEFAULT_ALLOWED`]** so the +/// no-env-var fallback path cannot reach them. +/// +/// Closes S7-06 / S4-02 (bridge-side): in the legacy/dev path +/// (`OPENFANG_BRIDGE_ALLOWED` unset) an agent's bridge would otherwise be +/// able to spawn / kill / activate sibling agents regardless of its +/// manifest. Production callers thread `OPENFANG_BRIDGE_ALLOWED` from the +/// manifest-derived `available_tools`, so opted-in agents are unaffected; +/// only the fallback is narrowed. +/// +/// Drift-pin: the `bridge_ipc::allowlist_*` tests assert that every entry +/// here is **present** in `ALLOWED_TOOLS` and `built_in_tools()` and +/// **absent** from `DEFAULT_ALLOWED`. +pub const PRIVILEGED_DEFAULT_DENY: &[&str] = &[ + "agent_spawn", + "agent_kill", + "agent_activate", +]; + pub fn built_in_tools() -> Vec { use serde_json::json; From 421e5d99d53c2fbc15a6cbc229946848325437e8 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 12:35:46 -0700 Subject: [PATCH 037/105] fix(security): hard-deny sensitive OpenFang home paths in workspace sandbox Defense-in-depth for the sandbox: in addition to confining agent file operations to `workspace_root`, `resolve_sandbox_path` now hard-denies a curated set of sensitive paths under the OpenFang home, regardless of the agent's workspace_root configuration. Motivation: the workspace-confinement check is sufficient only when each agent's `workspace_root` is correctly scoped to its own subdir under `workspaces/`. A misconfigured or privileged manifest (e.g. `workspace_root = "~/.openfang"` or `"~"`), or a future tool surface that bypasses workspace confinement, could otherwise read or overwrite credentials, runtime tokens, and daemon state. Generalizes jaberjaber's review feedback on the closed #1162 to the right layer. Deny categories (returned as stable reason strings for audit): - config config.toml - config-backup config.toml.bak* - secrets secrets.env, .env - daemon-state daemon.json - credential-file gcp-key*, *.pem, *.key, *.p12, *.pfx - runtime-tokens run/ (bridge sockets, mcp-config-*.json) - credential-vault vault/ - paired-devices paired_devices.json - daemon-log daemon.stderr.log*, daemon.stdout.log* Workspaces, skills, bin, scripts, and other non-sensitive subdirs of the OpenFang home remain unaffected. Implementation notes: - New private `is_sensitive_openfang_path()` predicate, called after canonicalization (symlink escapes are resolved before the check). - `openfang_home()` honors `OPENFANG_HOME` env var, falls back to `$HOME/.openfang`. No new crate dependency (uses std::env only). - Denials emit `tracing::warn!` with target `openfang_runtime::sandbox`, the user-supplied path, the resolved path, and the reason string, for auditability. Tests: 8 new unit tests covering each deny category, non-sensitive passthroughs, the misconfigured-workspace_root case, and the absolute-path escape attempt. All 14 workspace_sandbox tests pass. --- .../openfang-runtime/src/workspace_sandbox.rs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/crates/openfang-runtime/src/workspace_sandbox.rs b/crates/openfang-runtime/src/workspace_sandbox.rs index 306c49f2a5..ae6725d064 100644 --- a/crates/openfang-runtime/src/workspace_sandbox.rs +++ b/crates/openfang-runtime/src/workspace_sandbox.rs @@ -2,9 +2,76 @@ //! //! Confines agent file operations to their workspace directory. //! Prevents path traversal, symlink escapes, and access outside the sandbox. +//! +//! Defense in depth: in addition to confining to `workspace_root`, this module +//! hard-denies access to a curated set of sensitive paths under the OpenFang +//! home directory (secrets, credentials, runtime tokens, daemon logs). The +//! deny-list fires regardless of `workspace_root` configuration, so a +//! misconfigured agent manifest (e.g. `workspace_root = "~/.openfang"`) or a +//! future tool surface that bypasses workspace confinement cannot exfiltrate +//! these files. use std::path::{Path, PathBuf}; +/// Resolve the OpenFang home directory. +/// +/// Priority: `OPENFANG_HOME` env var > `~/.openfang`. +/// +/// Mirrors `openfang_types::config::openfang_home_dir` (private there). Kept +/// local to avoid a cross-crate dependency cycle from runtime → types. +fn openfang_home() -> Option { + if let Ok(home) = std::env::var("OPENFANG_HOME") { + return Some(PathBuf::from(home)); + } + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".openfang")) +} + +/// Returns `Some(reason)` if `path` resolves to a sensitive file/dir under the +/// OpenFang home that must never be read or written by an agent, regardless of +/// the agent's `workspace_root`. +/// +/// `path` should be canonicalized (or its parent canonicalized + filename +/// joined) before this check, so symlink escapes have already been resolved. +/// +/// The categories returned are stable strings suitable for WARN-log audit. +pub(crate) fn is_sensitive_openfang_path(path: &Path) -> Option<&'static str> { + let home = openfang_home()?; + // Canonicalize the home root if it exists so we compare like-for-like with + // the (already canonicalized) candidate. If the home doesn't exist yet, + // there is nothing sensitive in it. + let canon_home = home.canonicalize().ok()?; + let rel = path.strip_prefix(&canon_home).ok()?; + let first = rel.components().next()?.as_os_str().to_str()?; + + match first { + // Tier 1: credentials / secrets + "config.toml" => Some("config"), + "secrets.env" | ".env" => Some("secrets"), + "daemon.json" => Some("daemon-state"), + s if s.starts_with("config.toml.bak") => Some("config-backup"), + s if s.starts_with("gcp-key") + || s.ends_with(".pem") + || s.ends_with(".key") + || s.ends_with(".p12") + || s.ends_with(".pfx") => + { + Some("credential-file") + } + // Tier 2: impersonation / runtime surface + "run" => Some("runtime-tokens"), + "vault" => Some("credential-vault"), + "paired_devices.json" => Some("paired-devices"), + // Tier 3: log exfil / recon + "daemon.stderr.log" | "daemon.stdout.log" => Some("daemon-log"), + s if s.starts_with("daemon.stderr.log.") + || s.starts_with("daemon.stdout.log.") => + { + Some("daemon-log") + } + _ => None, + } +} + /// Resolve a user-supplied path within a workspace sandbox. /// /// - Rejects `..` components outright. @@ -12,6 +79,8 @@ use std::path::{Path, PathBuf}; /// - Absolute paths are checked against the workspace root after canonicalization. /// - For new files: canonicalizes the parent directory and appends the filename. /// - The final canonical path must start with the canonical workspace root. +/// - Hard-denies sensitive OpenFang paths (`config.toml`, secrets, vault, run/, +/// daemon logs, credential files) regardless of workspace root. pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result { let path = Path::new(user_path); @@ -53,6 +122,23 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result = Mutex::new(()); + + struct FakeHome { + _dir: TempDir, + path: PathBuf, + _guard: std::sync::MutexGuard<'static, ()>, + prev: Option, + } + + impl FakeHome { + fn new() -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var("OPENFANG_HOME").ok(); + let dir = TempDir::new().unwrap(); + let path = dir.path().canonicalize().unwrap(); + std::env::set_var("OPENFANG_HOME", &path); + Self { + _dir: dir, + path, + _guard: guard, + prev, + } + } + } + + impl Drop for FakeHome { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var("OPENFANG_HOME", v), + None => std::env::remove_var("OPENFANG_HOME"), + } + } + } + + #[test] + fn test_sensitive_config_toml() { + let h = FakeHome::new(); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("config.toml")), + Some("config") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("config.toml.bak-20260101")), + Some("config-backup") + ); + } + + #[test] + fn test_sensitive_secrets_and_env() { + let h = FakeHome::new(); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("secrets.env")), + Some("secrets") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join(".env")), + Some("secrets") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("daemon.json")), + Some("daemon-state") + ); + } + + #[test] + fn test_sensitive_credential_files() { + let h = FakeHome::new(); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("gcp-key--annabelle-service-01.json")), + Some("credential-file") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("tls.pem")), + Some("credential-file") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("agent.key")), + Some("credential-file") + ); + } + + #[test] + fn test_sensitive_runtime_and_vault() { + let h = FakeHome::new(); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("run").join("mcp-config-abc.json")), + Some("runtime-tokens") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("vault").join("anything")), + Some("credential-vault") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("paired_devices.json")), + Some("paired-devices") + ); + } + + #[test] + fn test_sensitive_daemon_logs() { + let h = FakeHome::new(); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("daemon.stderr.log")), + Some("daemon-log") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("daemon.stdout.log.1")), + Some("daemon-log") + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("daemon.stderr.log.2026-05-19")), + Some("daemon-log") + ); + } + + #[test] + fn test_non_sensitive_paths_pass() { + let h = FakeHome::new(); + // Workspaces, skills, bin, src, etc. must remain accessible. + assert_eq!( + is_sensitive_openfang_path(&h.path.join("workspaces").join("foo").join("data.txt")), + None + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("skills").join("x.md")), + None + ); + assert_eq!( + is_sensitive_openfang_path(&h.path.join("bin").join("openfang")), + None + ); + // Paths outside OPENFANG_HOME entirely. + assert_eq!(is_sensitive_openfang_path(Path::new("/tmp/foo")), None); + } + + #[test] + fn test_resolve_blocks_sensitive_even_when_inside_misconfigured_root() { + // Simulate the bad case: workspace_root = OPENFANG_HOME itself. + let h = FakeHome::new(); + // Plant a config.toml inside the fake home. + std::fs::write(h.path.join("config.toml"), "secret = true").unwrap(); + + let result = resolve_sandbox_path("config.toml", &h.path); + assert!(result.is_err(), "expected sensitive-path denial"); + let err = result.unwrap_err(); + assert!(err.contains("protected OpenFang resource"), "got: {}", err); + assert!(err.contains("config"), "got: {}", err); + } + + #[test] + fn test_resolve_blocks_secrets_env_via_absolute_path() { + let h = FakeHome::new(); + std::fs::write(h.path.join("secrets.env"), "X=1").unwrap(); + // Workspace root is a subdir; absolute path attempts to escape upward. + let ws = h.path.join("workspaces").join("agent"); + std::fs::create_dir_all(&ws).unwrap(); + let abs = h.path.join("secrets.env"); + let result = resolve_sandbox_path(abs.to_str().unwrap(), &ws); + assert!(result.is_err()); + // Either the outside-workspace check or the sensitive-path check may + // fire first; we only require that one of them denies access. + let err = result.unwrap_err(); + assert!(err.contains("Access denied"), "got: {}", err); + } } From 1aec0bb84228b35eb2f23a1644f3538db737231b Mon Sep 17 00:00:00 2001 From: benhoverter Date: Tue, 19 May 2026 14:53:32 -0700 Subject: [PATCH 038/105] chore(deps): bump lettre 0.11.21 to 0.11.22 (RUSTSEC-2026-0141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears RUSTSEC-2026-0141 (CVSS 9.1 — TLS hostname verification disabled with Boring TLS backend). Workspace uses tokio1-rustls-tls so the vuln is not exploitable in this config, but the advisory still gates cargo audit so we bump to clear CI. --- Cargo.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4947320326..72977c0132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -893,7 +893,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1555,7 +1555,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2743,7 +2743,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3255,9 +3255,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.21" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", "base64 0.22.1", @@ -3739,7 +3739,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5130,7 +5130,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5168,7 +5168,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5751,7 +5751,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5810,7 +5810,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6370,7 +6370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7106,7 +7106,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7678,7 +7678,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8551,7 +8551,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From b5258b73d20249a4f620ac5f47009992a6ef531b Mon Sep 17 00:00:00 2001 From: benhoverter Date: Tue, 19 May 2026 14:53:45 -0700 Subject: [PATCH 039/105] style: cargo fmt + clippy fixes for CI Pre-flight for upstream CI on PR #1205. Three clippy lints fixed alongside a workspace-wide cargo fmt --all run: doc_lazy_continuation in openfang-mcp-bridge/src/lib.rs (paragraph break before the 'Default tool allowlist' doc block), manual_inspect at four call sites in runtime/drivers/claude_code.rs (switch .map to .inspect for side-effect-only closures over Option of TempPath), and field_reassign_with_default in runtime/agent_tool_context.rs (collapsed mut policy assignment into struct-update-syntax init). No behavior changes. --- crates/openfang-api/src/bridge_ipc.rs | 53 +++---- crates/openfang-api/src/routes.rs | 28 +--- crates/openfang-api/src/server.rs | 138 +++++++++--------- crates/openfang-cli/src/main.rs | 6 +- crates/openfang-kernel/src/kernel.rs | 10 +- crates/openfang-mcp-bridge/src/lib.rs | 31 ++-- crates/openfang-mcp-bridge/src/main.rs | 28 ++-- crates/openfang-mcp-bridge/src/protocol.rs | 5 +- crates/openfang-runtime/src/agent_loop.rs | 62 ++++---- .../src/agent_tool_context.rs | 13 +- .../src/drivers/claude_code.rs | 53 ++++--- .../src/subprocess_sandbox.rs | 60 ++++++-- crates/openfang-runtime/src/tool_runner.rs | 1 - .../openfang-runtime/src/workspace_sandbox.rs | 4 +- crates/openfang-types/src/bridge_auth.rs | 4 +- 15 files changed, 252 insertions(+), 244 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 5a50c8a242..54ba9d25c1 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -40,8 +40,8 @@ use crate::bridge_auth::BridgeAuthority; use openfang_kernel::OpenFangKernel; use openfang_mcp_bridge::protocol::{ - CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, - SOCKET_RELATIVE_PATH, codec, + codec, CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, + SOCKET_RELATIVE_PATH, }; use openfang_types::agent::AgentId; use openfang_types::bridge_auth::Token; @@ -339,7 +339,9 @@ async fn handle_connection( let result = dispatch_call(&call, &kernel, identity.agent_id.as_ref()).await; let result_kind = match &result { - CallResult::Ok { is_error: false, .. } => "ok", + CallResult::Ok { + is_error: false, .. + } => "ok", CallResult::Ok { is_error: true, .. } => "tool_error", CallResult::Error { .. } => "dispatch_error", }; @@ -501,10 +503,7 @@ async fn dispatch_call( "bridge IPC: rejecting tool not in agent's permitted set" ); return CallResult::Error { - message: format!( - "tool '{}' not permitted for this agent", - call.tool_name - ), + message: format!("tool '{}' not permitted for this agent", call.tool_name), }; } @@ -518,8 +517,7 @@ async fn dispatch_call( // parameter; passing our four-tool set makes the runtime's check // belt-and-suspenders with ours. If the two ever drift, the runtime's // is authoritative — it sits closer to the actual tool implementations. - let allowed_tools_owned: Vec = - ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); + let allowed_tools_owned: Vec = ALLOWED_TOOLS.iter().map(|s| (*s).to_string()).collect(); // --- Gate 3: workspace sandbox for filesystem tools (D-fix) ------------ // Fail-closed for filesystem tools without a workspace. The runtime's @@ -551,9 +549,8 @@ async fn dispatch_call( // surfaces apply identical scoping — see S3-01 in the bridge-v2 audit. // Every other bridge tool ignores both, so this is cheap to compute // unconditionally. - let exec_ctx = openfang_runtime::agent_tool_context::AgentExecContext::from_manifest( - &entry.manifest, - ); + let exec_ctx = + openfang_runtime::agent_tool_context::AgentExecContext::from_manifest(&entry.manifest); let effective_exec_policy = exec_ctx.exec_policy_ref(); let allowed_env_arg: Option<&[String]> = exec_ctx.allowed_env(); @@ -878,8 +875,7 @@ mod tests { token: guard.token().to_hex(), bridge_version: "t".into(), }; - let identity = - authenticate_hello(&h, &authority).expect("hardened path should succeed"); + let identity = authenticate_hello(&h, &authority).expect("hardened path should succeed"); assert_eq!(identity.agent_id, Some(agent_id)); assert_eq!(identity.token_fingerprint, Some(guard.fingerprint())); } @@ -970,7 +966,7 @@ mod tests { /// `PRIVILEGED_DEFAULT_DENY` and **not** to `DEFAULT_ALLOWED`. #[test] fn allowlist_surface_correspondence() { - use openfang_mcp_bridge::{DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY, built_in_tools}; + use openfang_mcp_bridge::{built_in_tools, DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY}; use std::collections::BTreeSet; let daemon_set: BTreeSet<&str> = ALLOWED_TOOLS.iter().copied().collect(); @@ -978,19 +974,22 @@ mod tests { .iter() .map(|t| t.name.as_ref().to_string()) .collect(); - let advertise_borrowed: BTreeSet<&str> = - advertise_set.iter().map(|s| s.as_str()).collect(); + let advertise_borrowed: BTreeSet<&str> = advertise_set.iter().map(|s| s.as_str()).collect(); let default_set: BTreeSet<&str> = DEFAULT_ALLOWED.iter().copied().collect(); - let privileged_set: BTreeSet<&str> = - PRIVILEGED_DEFAULT_DENY.iter().copied().collect(); + let privileged_set: BTreeSet<&str> = PRIVILEGED_DEFAULT_DENY.iter().copied().collect(); // Invariant A — daemon dispatch and MCP advertise must agree. assert_eq!( - daemon_set, advertise_borrowed, + daemon_set, + advertise_borrowed, "drift: ALLOWED_TOOLS (daemon dispatch) ≠ built_in_tools() (MCP advertise). \ daemon-only: {:?}, advertise-only: {:?}", - daemon_set.difference(&advertise_borrowed).collect::>(), - advertise_borrowed.difference(&daemon_set).collect::>(), + daemon_set + .difference(&advertise_borrowed) + .collect::>(), + advertise_borrowed + .difference(&daemon_set) + .collect::>(), ); // Invariant B.1 — every default-tool is daemon-dispatchable. @@ -1046,7 +1045,7 @@ mod tests { /// `PRIVILEGED_DEFAULT_DENY.len()`. #[test] fn allowlist_cardinality_pin() { - use openfang_mcp_bridge::{DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY, built_in_tools}; + use openfang_mcp_bridge::{built_in_tools, DEFAULT_ALLOWED, PRIVILEGED_DEFAULT_DENY}; assert_eq!(ALLOWED_TOOLS.len(), 17, "ALLOWED_TOOLS surface cardinality"); assert_eq!( built_in_tools().len(), @@ -1138,9 +1137,11 @@ mod tests { token: "550e8400-e29b-41d4-a716-446655440000".into(), bridge_version: "t".into(), }; - let identity = - authenticate_hello(&h, &authority).expect("legacy path should succeed"); - assert!(identity.agent_id.is_none(), "legacy path must not bind agent_id"); + let identity = authenticate_hello(&h, &authority).expect("legacy path should succeed"); + assert!( + identity.agent_id.is_none(), + "legacy path must not bind agent_id" + ); assert!( identity.token_fingerprint.is_none(), "legacy path has no fingerprint" diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index c273978931..3f02c1ba7a 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -355,17 +355,9 @@ pub fn inject_attachments_into_session( /// /// Returns `Some(reason)` when the request must be rejected (400), /// `None` when it is safe to forward to the kernel with `sender_id = None`. -pub(crate) fn reject_unauthenticated_sender_identity( - req: &MessageRequest, -) -> Option<&'static str> { - let has_id = req - .sender_id - .as_ref() - .is_some_and(|s| !s.is_empty()); - let has_name = req - .sender_name - .as_ref() - .is_some_and(|s| !s.is_empty()); +pub(crate) fn reject_unauthenticated_sender_identity(req: &MessageRequest) -> Option<&'static str> { + let has_id = req.sender_id.as_ref().is_some_and(|s| !s.is_empty()); + let has_name = req.sender_name.as_ref().is_some_and(|s| !s.is_empty()); match (has_id, has_name) { (false, false) => None, (true, false) => Some( @@ -7146,9 +7138,7 @@ pub async fn mcp_http( // Canonical FS-sandbox gate (see // `openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS`). // Single source of truth across the IPC and HTTP `/mcp` surfaces. - use openfang_runtime::agent_tool_context::{ - AgentExecContext, requires_exec_policy, - }; + use openfang_runtime::agent_tool_context::{requires_exec_policy, AgentExecContext}; use openfang_runtime::tool_runner::FS_SANDBOXED_TOOLS; let agent_id_opt: Option = arguments .get("_agent_id") @@ -7157,8 +7147,7 @@ pub async fn mcp_http( // Resolve the registry entry once so both workspace and exec // context come from the same manifest snapshot (no TOCTOU between // the workspace lookup and the exec_policy lookup). - let registry_entry = agent_id_opt - .and_then(|aid| state.kernel.registry.get(aid)); + let registry_entry = agent_id_opt.and_then(|aid| state.kernel.registry.get(aid)); let workspace_path: Option = registry_entry .as_ref() .and_then(|entry| entry.manifest.workspace.clone()); @@ -7241,10 +7230,8 @@ pub async fn mcp_http( // context (resolved above). Falls through to `None, None` only // when no `_agent_id` was supplied AND the tool isn't gated by // `requires_exec_policy` (which short-circuits above). - let allowed_env_arg: Option<&[String]> = - exec_ctx.as_ref().and_then(|c| c.allowed_env()); - let effective_exec_policy = - exec_ctx.as_ref().and_then(|c| c.exec_policy_ref()); + let allowed_env_arg: Option<&[String]> = exec_ctx.as_ref().and_then(|c| c.allowed_env()); + let effective_exec_policy = exec_ctx.as_ref().and_then(|c| c.exec_policy_ref()); let result = openfang_runtime::tool_runner::execute_tool( "mcp-http", tool_name, @@ -13157,7 +13144,6 @@ mod uninstall_agent_tests { } } - #[cfg(test)] mod s604_validator_tests { use super::*; diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index d20a6525a0..1844866aef 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -877,81 +877,81 @@ pub async fn run_daemon( ); } #[cfg(unix)] - let _bridge_ipc = match crate::bridge_ipc::BridgeIpcServer::start( - kernel.clone(), - bridge_authority.clone(), - ) - .await - { - Ok(h) => { - // Publish discovery env vars so subprocess drivers (Claude Code, - // future Codex-style) can find the socket and the bridge binary. - // ANAI-30 step 4: the CC driver consults these to wire CC's - // `--mcp-config` so each spawned `claude` invocation gets an - // MCP server pointed at our daemon. - // - // SAFETY: setting process env is `unsafe` on edition 2024 because - // it's not thread-safe under concurrent readers in other threads. - // Here we run before any subprocess driver spawns and before the - // axum server accepts connections, so there are no concurrent - // env readers. - // SAFETY: see comment above — set during single-threaded daemon startup. - unsafe { - std::env::set_var( - openfang_mcp_bridge::protocol::SOCKET_ENV_VAR, - h.socket_path(), - ); - } - // Resolve the bridge binary path: /openfang-mcp-bridge - // unless the operator has overridden it. Operators can set - // OPENFANG_BRIDGE_BIN explicitly (e.g. for non-standard installs - // or development cargo builds where the binary lives elsewhere). - const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; - match std::env::var_os(BRIDGE_BIN_ENV) { - Some(path) => { - let p = std::path::PathBuf::from(&path); - if p.exists() { - info!( - bridge_bin = %p.display(), - "bridge binary resolved via OPENFANG_BRIDGE_BIN override" - ); - } else { - warn!( - bridge_bin = %p.display(), - "OPENFANG_BRIDGE_BIN points at a non-existent path; \ - CC bridge wiring will fail until the binary is installed" - ); - } + let _bridge_ipc = + match crate::bridge_ipc::BridgeIpcServer::start(kernel.clone(), bridge_authority.clone()) + .await + { + Ok(h) => { + // Publish discovery env vars so subprocess drivers (Claude Code, + // future Codex-style) can find the socket and the bridge binary. + // ANAI-30 step 4: the CC driver consults these to wire CC's + // `--mcp-config` so each spawned `claude` invocation gets an + // MCP server pointed at our daemon. + // + // SAFETY: setting process env is `unsafe` on edition 2024 because + // it's not thread-safe under concurrent readers in other threads. + // Here we run before any subprocess driver spawns and before the + // axum server accepts connections, so there are no concurrent + // env readers. + // SAFETY: see comment above — set during single-threaded daemon startup. + unsafe { + std::env::set_var( + openfang_mcp_bridge::protocol::SOCKET_ENV_VAR, + h.socket_path(), + ); } - None => { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let candidate = dir.join("openfang-mcp-bridge"); - if candidate.exists() { - // SAFETY: see above. - unsafe { std::env::set_var(BRIDGE_BIN_ENV, &candidate); } - info!( - bridge_bin = %candidate.display(), - "bridge binary resolved via boot probe" - ); - } else { - warn!( - expected = %candidate.display(), - "bridge binary not found next to daemon; \ - set OPENFANG_BRIDGE_BIN to enable CC bridge wiring" - ); + // Resolve the bridge binary path: /openfang-mcp-bridge + // unless the operator has overridden it. Operators can set + // OPENFANG_BRIDGE_BIN explicitly (e.g. for non-standard installs + // or development cargo builds where the binary lives elsewhere). + const BRIDGE_BIN_ENV: &str = "OPENFANG_BRIDGE_BIN"; + match std::env::var_os(BRIDGE_BIN_ENV) { + Some(path) => { + let p = std::path::PathBuf::from(&path); + if p.exists() { + info!( + bridge_bin = %p.display(), + "bridge binary resolved via OPENFANG_BRIDGE_BIN override" + ); + } else { + warn!( + bridge_bin = %p.display(), + "OPENFANG_BRIDGE_BIN points at a non-existent path; \ + CC bridge wiring will fail until the binary is installed" + ); + } + } + None => { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("openfang-mcp-bridge"); + if candidate.exists() { + // SAFETY: see above. + unsafe { + std::env::set_var(BRIDGE_BIN_ENV, &candidate); + } + info!( + bridge_bin = %candidate.display(), + "bridge binary resolved via boot probe" + ); + } else { + warn!( + expected = %candidate.display(), + "bridge binary not found next to daemon; \ + set OPENFANG_BRIDGE_BIN to enable CC bridge wiring" + ); + } } } } } + Some(h) } - Some(h) - } - Err(e) => { - warn!(error = %e, "bridge IPC listener failed to start; MCP bridge unavailable"); - None - } - }; + Err(e) => { + warn!(error = %e, "bridge IPC listener failed to start; MCP bridge unavailable"); + None + } + }; // Write daemon info file if let Some(info_path) = daemon_info_path { diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index fb0ab967c9..f9012362a1 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1579,10 +1579,8 @@ fn cmd_start(config: Option, yolo: bool) { #[cfg(not(unix))] let kernel_issuer = None; - let kernel = match OpenFangKernel::boot_with_config_and_issuer( - kernel_config, - kernel_issuer, - ) { + let kernel = match OpenFangKernel::boot_with_config_and_issuer(kernel_config, kernel_issuer) + { Ok(k) => k, Err(e) => { boot_kernel_error(&e); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index b14a291851..661510c1fe 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -4129,10 +4129,7 @@ impl OpenFangKernel { /// Claude Code driver, and (Phase C2) by `KernelHandle::token_issuer` so /// the agent loop's fallback paths can mint hardened tokens too. pub fn token_issuer(&self) -> Option> { - self.token_issuer - .read() - .ok() - .and_then(|slot| slot.clone()) + self.token_issuer.read().ok().and_then(|slot| slot.clone()) } // ─── Agent Binding management ────────────────────────────────────── @@ -9518,9 +9515,8 @@ system_prompt = "You are a test agent." }; let issuer: Arc = Arc::new(FakeIssuer); - let kernel = - OpenFangKernel::boot_with_config_and_issuer(config, Some(issuer.clone())) - .expect("kernel boots with issuer"); + let kernel = OpenFangKernel::boot_with_config_and_issuer(config, Some(issuer.clone())) + .expect("kernel boots with issuer"); assert!( kernel.token_issuer().is_some(), diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index ada4929ac9..4faf336417 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -39,11 +39,7 @@ pub mod protocol; use std::sync::Arc; -use rmcp::{ - ErrorData as McpError, ServerHandler, - model::*, - service::RequestContext, -}; +use rmcp::{model::*, service::RequestContext, ErrorData as McpError, ServerHandler}; /// Narrow seam between the bridge and the OpenFang runtime. /// @@ -124,6 +120,7 @@ pub enum ToolDispatchError { /// ANAI-30 slice. Per-agent gating via `OPENFANG_BRIDGE_ALLOWED` /// (sourced from each agent's `agent.toml` capabilities) decides /// whether any given bridge instance actually advertises it. +/// /// Default tool allowlist used by `main.rs` when [`OPENFANG_BRIDGE_ALLOWED`] /// is unset (legacy/dev path). Lives in the library — not in `main.rs` — so /// the bridge_ipc drift-catcher tests in `openfang-api` can assert the @@ -174,11 +171,7 @@ pub const DEFAULT_ALLOWED: &[&str] = &[ /// Drift-pin: the `bridge_ipc::allowlist_*` tests assert that every entry /// here is **present** in `ALLOWED_TOOLS` and `built_in_tools()` and /// **absent** from `DEFAULT_ALLOWED`. -pub const PRIVILEGED_DEFAULT_DENY: &[&str] = &[ - "agent_spawn", - "agent_kill", - "agent_activate", -]; +pub const PRIVILEGED_DEFAULT_DENY: &[&str] = &["agent_spawn", "agent_kill", "agent_activate"]; pub fn built_in_tools() -> Vec { use serde_json::json; @@ -543,17 +536,19 @@ impl ServerHandler for Bridge { CallToolResult::success(blocks) }) } - Err(ToolDispatchError::UnknownTool(name)) => Ok(CallToolResult::error(vec![ - Content::text(format!("unknown tool: {name}")), - ])), - Err(ToolDispatchError::NotPermitted(name)) => Ok(CallToolResult::error(vec![ - Content::text(format!("not permitted: {name}")), - ])), - Err(ToolDispatchError::InvalidArgs { tool, reason }) => { + Err(ToolDispatchError::UnknownTool(name)) => { + Ok(CallToolResult::error(vec![Content::text(format!( + "unknown tool: {name}" + ))])) + } + Err(ToolDispatchError::NotPermitted(name)) => { Ok(CallToolResult::error(vec![Content::text(format!( - "invalid args for {tool}: {reason}" + "not permitted: {name}" ))])) } + Err(ToolDispatchError::InvalidArgs { tool, reason }) => Ok(CallToolResult::error( + vec![Content::text(format!("invalid args for {tool}: {reason}"))], + )), Err(ToolDispatchError::Execution(e)) => Ok(CallToolResult::error(vec![Content::text( format!("tool execution failed: {e}"), )])), diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index d8d104e79d..6f776838b2 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -48,27 +48,27 @@ #[cfg(unix)] use std::collections::HashMap; #[cfg(unix)] -use std::sync::Arc; -#[cfg(unix)] use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] +use std::sync::Arc; #[cfg(unix)] -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{anyhow, bail, Context, Result}; #[cfg(unix)] use openfang_mcp_bridge::{ - Bridge, DEFAULT_ALLOWED, DispatchOk, ToolDispatchError, ToolDispatcher, protocol::{ - CallRequest, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_ENV_VAR, - TOKEN_ENV_VAR, codec, + codec, CallRequest, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_ENV_VAR, + TOKEN_ENV_VAR, }, + Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, DEFAULT_ALLOWED, }; #[cfg(unix)] -use rmcp::{ServiceExt, transport::stdio}; +use rmcp::{transport::stdio, ServiceExt}; use tokio::io::BufReader; #[cfg(unix)] use tokio::net::UnixStream; #[cfg(unix)] -use tokio::sync::{Mutex, mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, Mutex}; use tracing_subscriber::EnvFilter; /// Env var carrying the parent agent id. Stub for ANAI-30; ANAI-31 derives @@ -152,7 +152,10 @@ async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { .await .context("write Hello")?; - match codec::read_frame(&mut read_half).await.context("read HelloAck")? { + match codec::read_frame(&mut read_half) + .await + .context("read HelloAck")? + { Frame::HelloAck(HelloAck::Ok { daemon_version }) => { tracing::info!(daemon_version, "bridge IPC handshake ok"); Ok(()) @@ -220,9 +223,10 @@ impl ToolDispatcher for IpcDispatcher { reply: reply_tx, }; - self.tx.send(req).await.map_err(|_| { - ToolDispatchError::Execution(anyhow!("bridge IPC actor has shut down")) - })?; + self.tx + .send(req) + .await + .map_err(|_| ToolDispatchError::Execution(anyhow!("bridge IPC actor has shut down")))?; let result = reply_rx .await diff --git a/crates/openfang-mcp-bridge/src/protocol.rs b/crates/openfang-mcp-bridge/src/protocol.rs index ed106ce6c8..cadb8d321b 100644 --- a/crates/openfang-mcp-bridge/src/protocol.rs +++ b/crates/openfang-mcp-bridge/src/protocol.rs @@ -150,10 +150,7 @@ pub mod codec { } /// Write one length-prefixed JSON frame to `w`. - pub async fn write_frame( - w: &mut W, - frame: &Frame, - ) -> io::Result<()> { + pub async fn write_frame(w: &mut W, frame: &Frame) -> io::Result<()> { let bytes = serde_json::to_vec(frame) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("encode: {e}")))?; if bytes.len() > MAX_FRAME_BYTES { diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 4cb7d3f4ac..2a7be017d9 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -544,9 +544,7 @@ pub async fn run_agent_loop( // Subprocess drivers thread this into `OPENFANG_BRIDGE_ALLOWED` // so the bridge's advertised surface matches the agent's // permissions instead of falling back to the static default. - allowed_tools: Some( - available_tools.iter().map(|t| t.name.clone()).collect(), - ), + allowed_tools: Some(available_tools.iter().map(|t| t.name.clone()).collect()), }; // Notify phase: Thinking @@ -1287,19 +1285,20 @@ async fn call_with_retry( // `token_issuer` arg, which the agent loop sources // from `KernelHandle::token_issuer()`) so these // sessions stay on the hardened bridge auth path. - let fb_driver = match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { - Ok(d) => d, - Err(driver_err) => { - warn!( - fallback_index = fb_idx, - provider = %fb.provider, - model = %fb.model, - error = %driver_err, - "Failed to create fallback driver, skipping" - ); - continue; - } - }; + let fb_driver = + match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { + Ok(d) => d, + Err(driver_err) => { + warn!( + fallback_index = fb_idx, + provider = %fb.provider, + model = %fb.model, + error = %driver_err, + "Failed to create fallback driver, skipping" + ); + continue; + } + }; let mut fb_request = request.clone(); fb_request.model = fb.model.clone(); warn!( @@ -1477,19 +1476,20 @@ async fn stream_with_retry( // `token_issuer` arg, which the agent loop sources // from `KernelHandle::token_issuer()`) so these // sessions stay on the hardened bridge auth path. - let fb_driver = match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { - Ok(d) => d, - Err(driver_err) => { - warn!( - fallback_index = fb_idx, - provider = %fb.provider, - model = %fb.model, - error = %driver_err, - "Failed to create fallback stream driver, skipping" - ); - continue; - } - }; + let fb_driver = + match crate::drivers::create_driver(&fb_config, token_issuer.clone()) { + Ok(d) => d, + Err(driver_err) => { + warn!( + fallback_index = fb_idx, + provider = %fb.provider, + model = %fb.model, + error = %driver_err, + "Failed to create fallback stream driver, skipping" + ); + continue; + } + }; let mut fb_request = request.clone(); fb_request.model = fb.model.clone(); warn!( @@ -1805,9 +1805,7 @@ pub async fn run_agent_loop_streaming( // Subprocess drivers thread this into `OPENFANG_BRIDGE_ALLOWED` // so the bridge's advertised surface matches the agent's // permissions instead of falling back to the static default. - allowed_tools: Some( - available_tools.iter().map(|t| t.name.clone()).collect(), - ), + allowed_tools: Some(available_tools.iter().map(|t| t.name.clone()).collect()), }; // Notify phase: on first iteration emit Streaming; on subsequent diff --git a/crates/openfang-runtime/src/agent_tool_context.rs b/crates/openfang-runtime/src/agent_tool_context.rs index df2e81fcaa..3afa90ed9a 100644 --- a/crates/openfang-runtime/src/agent_tool_context.rs +++ b/crates/openfang-runtime/src/agent_tool_context.rs @@ -124,9 +124,11 @@ mod tests { #[test] fn manifest_with_exec_policy_propagates() { let mut m = AgentManifest::default(); - let mut policy = ExecPolicy::default(); - policy.mode = ExecSecurityMode::Allowlist; - policy.allowed_commands = vec!["echo".into(), "ls".into()]; + let policy = ExecPolicy { + mode: ExecSecurityMode::Allowlist, + allowed_commands: vec!["echo".into(), "ls".into()], + ..Default::default() + }; m.exec_policy = Some(policy.clone()); let ctx = AgentExecContext::from_manifest(&m); @@ -158,10 +160,7 @@ mod tests { // as `Some(...)` — it would partially-bind env passthrough on a // best-effort basis, which is the inverse of what an operator wants // out of a security gate. Treat malformed as "no override". - let m = manifest_with_metadata(vec![( - "hand_allowed_env", - json!("not an array"), - )]); + let m = manifest_with_metadata(vec![("hand_allowed_env", json!("not an array"))]); let ctx = AgentExecContext::from_manifest(&m); assert!(ctx.hand_allowed_env.is_empty()); assert!(ctx.allowed_env().is_none()); diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 4e68584577..ed9168e8a8 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -493,10 +493,7 @@ fn build_bridge_mcp_config_value( // instead of silently falling back to the default. if let Some(tools) = allowed_tools { let joined = tools.join(","); - env_map.insert( - BRIDGE_ALLOWED_ENV.into(), - serde_json::Value::String(joined), - ); + env_map.insert(BRIDGE_ALLOWED_ENV.into(), serde_json::Value::String(joined)); } let mut server_entry = serde_json::Map::new(); @@ -836,11 +833,12 @@ impl LlmDriver for ClaudeCodeDriver { // env vars (`OPENFANG_BRIDGE_SOCKET` + `OPENFANG_BRIDGE_BIN`) and // the request carries a caller identity. The guard lives for the // remainder of the call; on drop it removes the temp config file. - let _bridge_cfg = - self.try_build_bridge_mcp_config( + let _bridge_cfg = self + .try_build_bridge_mcp_config( request.caller_agent_id.as_deref(), request.allowed_tools.as_deref(), - ).map(|cfg| { + ) + .inspect(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); // `--strict-mcp-config` makes CC ignore any user/global MCP // config that might otherwise merge in — we want exactly @@ -853,7 +851,6 @@ impl LlmDriver for ClaudeCodeDriver { if bridge_debug_enabled() { cmd.arg("--debug"); } - cfg }); let bridge_wired = _bridge_cfg.is_some(); let bridge_debug = bridge_wired && bridge_debug_enabled(); @@ -864,9 +861,8 @@ impl LlmDriver for ClaudeCodeDriver { // `bridge_enabled()` inside `try_materialize_cc_settings` so it // co-travels with the bridge wiring above — either both or neither. let _cc_settings = - try_materialize_cc_settings(request.caller_agent_id.as_deref()).map(|s| { + try_materialize_cc_settings(request.caller_agent_id.as_deref()).inspect(|s| { cmd.arg("--settings").arg(s.path()); - s }); let native_deny_wired = _cc_settings.is_some(); @@ -1090,18 +1086,18 @@ impl LlmDriver for ClaudeCodeDriver { // Bridge wiring (see `complete()` for full rationale). Guard kept // alive for the rest of the streaming function so the per-spawn // config file outlives the CC subprocess. - let _bridge_cfg = - self.try_build_bridge_mcp_config( + let _bridge_cfg = self + .try_build_bridge_mcp_config( request.caller_agent_id.as_deref(), request.allowed_tools.as_deref(), - ).map(|cfg| { + ) + .inspect(|cfg| { cmd.arg("--mcp-config").arg(cfg.path()); cmd.arg("--strict-mcp-config"); // Optional --debug — see complete() for rationale. if bridge_debug_enabled() { cmd.arg("--debug"); } - cfg }); let bridge_wired = _bridge_cfg.is_some(); let bridge_debug = bridge_wired && bridge_debug_enabled(); @@ -1110,9 +1106,8 @@ impl LlmDriver for ClaudeCodeDriver { // full rationale). Guard kept alive for the rest of the streaming // function so the settings file outlives the CC subprocess. let _cc_settings = - try_materialize_cc_settings(request.caller_agent_id.as_deref()).map(|s| { + try_materialize_cc_settings(request.caller_agent_id.as_deref()).inspect(|s| { cmd.arg("--settings").arg(s.path()); - s }); let native_deny_wired = _cc_settings.is_some(); @@ -1586,7 +1581,10 @@ mod tests { Some("/usr/local/bin/openfang-mcp-bridge") ); assert!( - server.pointer("/args").map(|v| v.is_array()).unwrap_or(false), + server + .pointer("/args") + .map(|v| v.is_array()) + .unwrap_or(false), "args must be a JSON array" ); @@ -1597,10 +1595,23 @@ mod tests { .pointer("/env") .and_then(|v| v.as_object()) .expect("env object missing"); - assert_eq!(env.len(), 3, "env must contain exactly socket/token/agent_id"); - assert_eq!(env.get(BRIDGE_SOCKET_ENV).and_then(|v| v.as_str()), Some("/home/user/.openfang/run/bridge.sock")); - assert_eq!(env.get(BRIDGE_TOKEN_ENV).and_then(|v| v.as_str()), Some("tok-abc")); - assert_eq!(env.get(BRIDGE_AGENT_ID_ENV).and_then(|v| v.as_str()), Some("agent-uuid-1234")); + assert_eq!( + env.len(), + 3, + "env must contain exactly socket/token/agent_id" + ); + assert_eq!( + env.get(BRIDGE_SOCKET_ENV).and_then(|v| v.as_str()), + Some("/home/user/.openfang/run/bridge.sock") + ); + assert_eq!( + env.get(BRIDGE_TOKEN_ENV).and_then(|v| v.as_str()), + Some("tok-abc") + ); + assert_eq!( + env.get(BRIDGE_AGENT_ID_ENV).and_then(|v| v.as_str()), + Some("agent-uuid-1234") + ); assert!( env.get(BRIDGE_ALLOWED_ENV).is_none(), "no allowlist supplied → OPENFANG_BRIDGE_ALLOWED must be absent so the bridge \ diff --git a/crates/openfang-runtime/src/subprocess_sandbox.rs b/crates/openfang-runtime/src/subprocess_sandbox.rs index 281e320af9..66b322503d 100644 --- a/crates/openfang-runtime/src/subprocess_sandbox.rs +++ b/crates/openfang-runtime/src/subprocess_sandbox.rs @@ -418,9 +418,26 @@ fn unwrap_wrapper_args<'a>(wrapper: &str, args: &'a [&'a str]) -> Result<&'a [&' } "sudo" => { const SUDO_CONSUMING: &[&str] = &[ - "-u", "-g", "-U", "-D", "-h", "-p", "-r", "-t", "-T", "-C", "--user", "--group", - "--other-user", "--chdir", "--host", "--prompt", "--role", "--type", - "--command-timeout", "--close-from", + "-u", + "-g", + "-U", + "-D", + "-h", + "-p", + "-r", + "-t", + "-T", + "-C", + "--user", + "--group", + "--other-user", + "--chdir", + "--host", + "--prompt", + "--role", + "--type", + "--command-timeout", + "--close-from", ]; while i < args.len() { let a = args[i]; @@ -555,7 +572,6 @@ fn extract_wrapper_binary_chain(segment: &str, depth: u32) -> Result Ok(chain) } - /// If the base command is a known shell wrapper, extract any inline script /// passed via -Command / -c / /c (or via PowerShell -EncodedCommand) and /// return the commands within it. @@ -1822,8 +1838,7 @@ mod tests { ..ExecPolicy::default() }; assert!( - validate_command_allowlist(r#"bash --init-file /tmp/evil -c "true""#, &policy) - .is_err() + validate_command_allowlist(r#"bash --init-file /tmp/evil -c "true""#, &policy).is_err() ); } @@ -1891,7 +1906,6 @@ mod tests { assert_eq!(cmds[0], "\u{4f60}\u{597d}"); } - // ── S9-08 wrapper-binary recursion & hard-deny ───────────────────── fn wrapper_policy() -> ExecPolicy { @@ -1900,8 +1914,14 @@ mod tests { ExecPolicy { mode: ExecSecurityMode::Allowlist, allowed_commands: vec![ - "env".into(), "sudo".into(), "nice".into(), "nohup".into(), - "timeout".into(), "ls".into(), "bash".into(), "python3".into(), + "env".into(), + "sudo".into(), + "nice".into(), + "nohup".into(), + "timeout".into(), + "ls".into(), + "bash".into(), + "python3".into(), ], ..ExecPolicy::default() } @@ -2003,8 +2023,8 @@ mod tests { allowed_commands: vec!["xargs".into(), "ls".into()], ..ExecPolicy::default() }; - let err = validate_command_allowlist("xargs ls", &p) - .expect_err("xargs must be hard-denied"); + let err = + validate_command_allowlist("xargs ls", &p).expect_err("xargs must be hard-denied"); assert!(err.contains("hard-denied"), "got: {err}"); } @@ -2027,8 +2047,8 @@ mod tests { allowed_commands: vec!["strace".into(), "ls".into()], ..ExecPolicy::default() }; - let err = validate_command_allowlist("strace ls", &p2) - .expect_err("strace must be hard-denied"); + let err = + validate_command_allowlist("strace ls", &p2).expect_err("strace must be hard-denied"); assert!(err.contains("hard-denied"), "got: {err}"); // Also via wrapper_policy: same outcome. assert!(validate_command_allowlist("strace ls", &p).is_err()); @@ -2039,12 +2059,20 @@ mod tests { let p = ExecPolicy { mode: ExecSecurityMode::Allowlist, allowed_commands: vec![ - "time".into(), "chroot".into(), "unshare".into(), "setsid".into(), - "stdbuf".into(), "flock".into(), "gdb".into(), "ls".into(), + "time".into(), + "chroot".into(), + "unshare".into(), + "setsid".into(), + "stdbuf".into(), + "flock".into(), + "gdb".into(), + "ls".into(), ], ..ExecPolicy::default() }; - for w in &["time", "chroot", "unshare", "setsid", "stdbuf", "flock", "gdb"] { + for w in &[ + "time", "chroot", "unshare", "setsid", "stdbuf", "flock", "gdb", + ] { let cmd = format!("{w} ls"); let err = validate_command_allowlist(&cmd, &p) .expect_err(&format!("{w} must be hard-denied")); diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index f5c200a726..c2b4686e76 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -43,7 +43,6 @@ pub const FS_SANDBOXED_TOOLS: &[&str] = &[ "apply_patch", ]; - /// Check if a tool name refers to a shell execution tool. /// /// Used to determine whether exec_policy settings should bypass the approval gate. diff --git a/crates/openfang-runtime/src/workspace_sandbox.rs b/crates/openfang-runtime/src/workspace_sandbox.rs index ae6725d064..ee1300f83e 100644 --- a/crates/openfang-runtime/src/workspace_sandbox.rs +++ b/crates/openfang-runtime/src/workspace_sandbox.rs @@ -63,9 +63,7 @@ pub(crate) fn is_sensitive_openfang_path(path: &Path) -> Option<&'static str> { "paired_devices.json" => Some("paired-devices"), // Tier 3: log exfil / recon "daemon.stderr.log" | "daemon.stdout.log" => Some("daemon-log"), - s if s.starts_with("daemon.stderr.log.") - || s.starts_with("daemon.stdout.log.") => - { + s if s.starts_with("daemon.stderr.log.") || s.starts_with("daemon.stdout.log.") => { Some("daemon-log") } _ => None, diff --git a/crates/openfang-types/src/bridge_auth.rs b/crates/openfang-types/src/bridge_auth.rs index 783074845c..e1b60d6230 100644 --- a/crates/openfang-types/src/bridge_auth.rs +++ b/crates/openfang-types/src/bridge_auth.rs @@ -58,9 +58,7 @@ impl Token { /// Decode from wire form. Rejects malformed or wrong-length input. pub fn from_hex(s: &str) -> Result { let bytes = hex::decode(s).map_err(|_| TokenParseError::Malformed)?; - let arr: [u8; TOKEN_LEN] = bytes - .try_into() - .map_err(|_| TokenParseError::WrongLength)?; + let arr: [u8; TOKEN_LEN] = bytes.try_into().map_err(|_| TokenParseError::WrongLength)?; Ok(Self(arr)) } From 2458280fbb8e0d597a5fab1aeb04c46752447041 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 15:06:58 -0700 Subject: [PATCH 040/105] chore(bridge): gate unix-only imports for windows clippy Windows clippy CI was failing with -D unused-imports because `tokio::io::BufReader` and `tracing_subscriber::EnvFilter` are only referenced inside the `#[cfg(unix)] async fn main`. The items they support were already gated; the imports were not. Add `#[cfg(unix)]` to both `use` lines so the lint matches usage on non-unix targets. --- crates/openfang-mcp-bridge/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 6f776838b2..8fdf216745 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -64,11 +64,13 @@ use openfang_mcp_bridge::{ }; #[cfg(unix)] use rmcp::{transport::stdio, ServiceExt}; +#[cfg(unix)] use tokio::io::BufReader; #[cfg(unix)] use tokio::net::UnixStream; #[cfg(unix)] use tokio::sync::{mpsc, oneshot, Mutex}; +#[cfg(unix)] use tracing_subscriber::EnvFilter; /// Env var carrying the parent agent id. Stub for ANAI-30; ANAI-31 derives From b6467a0a1da7a6722effd041adf93ce834e17b0f Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 15:07:06 -0700 Subject: [PATCH 041/105] chore(github): add pre-PR checks section to PR template Document the full local gate (fmt, clippy, test, audit) plus the cross-platform cfg-gating gotcha that bit us on #1205. Anyone opening a PR should run all of this locally first so CI feedback loops stay short. --- .github/pull_request_template.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4dff9c9c4..ecc31c3a69 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,10 +6,18 @@ +## Pre-PR checks (run locally before opening) + +Run the full gate locally before opening the PR — CI is strict and Windows clippy in particular catches things macOS/Linux clippy skips via `#[cfg]`. + +- [ ] `cargo fmt --all --check` +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] `cargo audit` (no new vulns; warnings reviewed) +- [ ] If touching cross-platform code: verify `#[cfg(unix)]` / `#[cfg(windows)]` gates on imports as well as items (Windows clippy fires `-D unused-imports` on imports only used by a gated `main`) + ## Testing -- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes -- [ ] `cargo test --workspace` passes - [ ] Live integration tested (if applicable) ## Security From a29441a3c0618b9e325d23cc929faf4bc15e4c77 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 15:55:23 -0700 Subject: [PATCH 042/105] chore(api): gate warn import for windows clippy `tracing::warn!` is only invoked from `#[cfg(unix)]` blocks in this file (signal-handler shutdown paths). On Windows, the bare `warn` import was flagged unused under `-D warnings`. Verified locally via cross-compile: cargo clippy --target x86_64-pc-windows-gnu --workspace \ --all-targets -- -D warnings --- crates/openfang-api/src/server.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 1844866aef..6969cd328a 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -15,7 +15,9 @@ use std::time::Instant; use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; -use tracing::{info, warn}; +use tracing::info; +#[cfg(unix)] +use tracing::warn; /// Daemon info written to `~/.openfang/daemon.json` so the CLI can find us. #[derive(serde::Serialize, serde::Deserialize)] From a6f9870b949fdfa8ff208f27bd872c2a7e91d42a Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 16:03:45 -0700 Subject: [PATCH 043/105] chore(scripts): add check-windows.sh cross-compile helper Automates the Windows cross-compile sanity check we've been running manually for the last few rounds of cfg-gate fixes. Verifies the x86_64-pc-windows-gnu rustup target + mingw-w64 are present, sets linker/CC env vars, then runs clippy (default), check, or build against the Windows target. Referenced from the PR template pre-PR checklist. --- .github/pull_request_template.md | 2 +- scripts/check-windows.sh | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100755 scripts/check-windows.sh diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ecc31c3a69..cb46392493 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ Run the full gate locally before opening the PR — CI is strict and Windows cli - [ ] `cargo clippy --workspace --all-targets -- -D warnings` - [ ] `cargo test --workspace` - [ ] `cargo audit` (no new vulns; warnings reviewed) -- [ ] If touching cross-platform code: verify `#[cfg(unix)]` / `#[cfg(windows)]` gates on imports as well as items (Windows clippy fires `-D unused-imports` on imports only used by a gated `main`) +- [ ] **Windows cross-check:** `scripts/check-windows.sh` (catches `#[cfg(unix)]`-gated import warnings that only fire on Windows CI). First run installs the `x86_64-pc-windows-gnu` rustup target; requires `mingw-w64` (macOS: `brew install mingw-w64`). ## Testing diff --git a/scripts/check-windows.sh b/scripts/check-windows.sh new file mode 100755 index 0000000000..9d3a4ca085 --- /dev/null +++ b/scripts/check-windows.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# check-windows.sh — cross-compile the workspace for Windows from a unix host +# to catch `#[cfg(unix)]`-gated import warnings (and similar Windows-only +# clippy/build errors) before CI does. +# +# What it does: +# 1. Verifies the `x86_64-pc-windows-gnu` rustup target is installed +# (installs it if missing). +# 2. Verifies the `x86_64-w64-mingw32-gcc` cross-compiler is on PATH +# (errors with install hint if not). +# 3. Runs `cargo clippy --workspace --all-targets -- -D warnings` +# against the Windows target. +# +# Usage: +# scripts/check-windows.sh # clippy (default) +# scripts/check-windows.sh check # cargo check only (faster) +# scripts/check-windows.sh build # full build +# +# Designed to be invoked from anywhere; it cd's to the repo root. + +set -euo pipefail + +TARGET="x86_64-pc-windows-gnu" +MODE="${1:-clippy}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +echo "==> Windows cross-compile check (target: $TARGET, mode: $MODE)" + +# ---- 1. rustup target ------------------------------------------------------ +if ! command -v rustup >/dev/null 2>&1; then + echo "error: rustup not found on PATH" >&2 + exit 1 +fi + +if ! rustup target list --installed | grep -q "^${TARGET}\$"; then + echo "==> Installing rustup target $TARGET" + rustup target add "$TARGET" +fi + +# ---- 2. mingw-w64 cross-compiler ------------------------------------------ +if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then + cat >&2 <&2 + exit 2 + ;; +esac From b5767437f451cdbbedee4cf0384bf9c41dac9644 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 16:23:08 -0700 Subject: [PATCH 044/105] runtime/claude_code: materialize images to tmpfile so the CLI's Read tool can view them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds image materialization in the claude_code driver: inbound image ContentBlocks are written to $HOME/.openfang/tmp/images/ so the Claude CLI can view them via its Read tool (it cannot fetch URLs or read in-memory bytes). Also introduces `ContentBlock::Image::source_url: Option` in openfang-types and threads it through every consumer: - openfang-channels/bridge.rs (sets source_url when downloading from URL) - openfang-api/openai_compat.rs + routes.rs (serde round-trip) - openfang-runtime drivers: anthropic, gemini, openai, vertex (pattern match exhaustiveness) - openfang-runtime: agent_loop, compactor (pattern match exhaustiveness) The `source_url` field is the load-bearing schema change for downstream work: later commits in this series consume it for cache lookup, and outbound Discord (separate PR) uses it to round-trip image URLs back to Discord without re-uploading bytes. No driver reads it on this branch yet — it is wired through serde and pattern matches only. --- crates/openfang-api/src/openai_compat.rs | 6 +- crates/openfang-api/src/routes.rs | 2 + crates/openfang-channels/src/bridge.rs | 72 +++++++- crates/openfang-runtime/src/agent_loop.rs | 1 + crates/openfang-runtime/src/compactor.rs | 1 + .../openfang-runtime/src/drivers/anthropic.rs | 2 +- .../src/drivers/claude_code.rs | 162 ++++++++++++++++-- crates/openfang-runtime/src/drivers/gemini.rs | 2 +- crates/openfang-runtime/src/drivers/openai.rs | 2 +- crates/openfang-runtime/src/drivers/vertex.rs | 2 +- crates/openfang-types/src/message.rs | 8 + 11 files changed, 244 insertions(+), 16 deletions(-) diff --git a/crates/openfang-api/src/openai_compat.rs b/crates/openfang-api/src/openai_compat.rs index b87d6893f2..2173c40ad4 100644 --- a/crates/openfang-api/src/openai_compat.rs +++ b/crates/openfang-api/src/openai_compat.rs @@ -216,7 +216,11 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec { .unwrap_or(parts[0]) .to_string(); let data = parts[1].to_string(); - Some(ContentBlock::Image { media_type, data }) + Some(ContentBlock::Image { + media_type, + data, + source_url: None, + }) } else { None } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 3f02c1ba7a..ed403f1b64 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -287,6 +287,7 @@ pub fn resolve_attachments( blocks.push(openfang_types::message::ContentBlock::Image { media_type: content_type, data: b64, + source_url: None, }); } Err(e) => { @@ -571,6 +572,7 @@ pub async fn get_agent_session( openfang_types::message::ContentBlock::Image { media_type, data, + .. } => { texts.push("[Image]".to_string()); // Persist image to upload dir so it can be diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 2043aeaa76..9cff60dd10 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -1688,7 +1688,14 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec assert_eq!(text, "hello"), + other => panic!("expected text caption block, got {other:?}"), + } + match &blocks[1] { + ContentBlock::Image { + source_url, + media_type, + .. + } => { + assert_eq!( + source_url.as_deref(), + Some(url.as_str()), + "source_url must round-trip the fetched URL" + ); + assert_eq!(media_type, "image/png"); + } + other => panic!("expected image block, got {other:?}"), + } + } } diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 2a7be017d9..bc563082d2 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -3637,6 +3637,7 @@ mod tests { ContentBlock::Image { media_type: "image/png".to_string(), data: "aGVsbG8=".to_string(), + source_url: None, } } diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index bda7b1edc8..6b01fc363b 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -1277,6 +1277,7 @@ mod tests { content: MessageContent::Blocks(vec![ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }]), ..Default::default() }, diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index 10a606e6a7..125d5c6797 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -695,7 +695,7 @@ fn convert_message(msg: &Message) -> ApiMessage { ContentBlock::Text { text, .. } => { Some(ApiContentBlock::Text { text: text.clone() }) } - ContentBlock::Image { media_type, data } => Some(ApiContentBlock::Image { + ContentBlock::Image { media_type, data, .. } => Some(ApiContentBlock::Image { source: ApiImageSource { source_type: "base64".to_string(), media_type: media_type.clone(), diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index ed9168e8a8..d6f8395773 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -11,13 +11,16 @@ use crate::bridge_auth::{SpawnGuard, TokenIssuer}; use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; use async_trait::async_trait; +use base64::Engine; use dashmap::DashMap; use openfang_types::agent::AgentId; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; -use std::path::PathBuf; use std::str::FromStr; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::Once; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; @@ -207,6 +210,110 @@ const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; /// Default subprocess timeout in seconds (5 minutes). const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; +/// TTL for materialized image tmpfiles (24 hours). Files older than this +/// are swept on driver init. +const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; + +/// One-shot guard so the TTL sweep only fires once per process. +static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); + +/// Resolve the directory used for materializing image attachments. +/// +/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang +/// install. Falls back to the OS temp dir when `$HOME` isn't set (which +/// shouldn't happen in our deployed daemon but is handled defensively). +fn image_tmp_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let mut p = PathBuf::from(home); + p.push(".openfang"); + p.push("tmp"); + p.push("images"); + p + } else { + let mut p = std::env::temp_dir(); + p.push("openfang-images"); + p + } +} + +/// Map a MIME type to a sensible filename extension. +fn ext_for_mime(media_type: &str) -> &'static str { + match media_type.to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/heic" => "heic", + "image/heif" => "heif", + "image/bmp" => "bmp", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +/// Decode the base64 image and write it to a content-addressed file under +/// `dir`. Idempotent: if a file with the same content hash already exists, +/// the existing path is returned without rewriting. Returns `None` on +/// decode or I/O failure (caller falls back to the textual placeholder). +fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.as_bytes()) + .ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = hasher.finalize(); + let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); + let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); + let path = dir.join(filename); + if path.exists() { + return Some(path); + } + if let Err(e) = std::fs::create_dir_all(dir) { + warn!(dir = ?dir, error = %e, "failed to create claude_code image tmp dir"); + return None; + } + if let Err(e) = std::fs::write(&path, &bytes) { + warn!(path = ?path, error = %e, "failed to write claude_code image tmpfile"); + return None; + } + Some(path) +} + +/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: +/// any error is logged at debug and the sweep moves on. +fn sweep_old_image_tmpfiles(dir: &Path) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); + return; + } + }; + let now = std::time::SystemTime::now(); + let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); + let mut removed = 0u32; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + if let Ok(age) = now.duration_since(modified) { + if age > ttl { + if let Err(e) = std::fs::remove_file(&path) { + debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); + } else { + removed += 1; + } + } + } + } + if removed > 0 { + info!(removed, "swept stale claude_code image tmpfiles"); + } +} + /// LLM driver that delegates to the Claude Code CLI. pub struct ClaudeCodeDriver { cli_path: String, @@ -248,6 +355,12 @@ impl ClaudeCodeDriver { ); } + // Best-effort sweep of stale image tmpfiles, once per process. + IMAGE_TMP_SWEEP_ONCE.call_once(|| { + let dir = image_tmp_dir(); + std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); + }); + Self { cli_path: cli_path .filter(|s| !s.is_empty()) @@ -320,6 +433,7 @@ impl ClaudeCodeDriver { /// attachment, but it knows the attachment exists and can acknowledge /// it coherently instead of confabulating. fn build_prompt(request: &CompletionRequest) -> String { + let tmp_dir = image_tmp_dir(); let mut parts = Vec::new(); for msg in &request.messages { @@ -328,7 +442,7 @@ impl ClaudeCodeDriver { Role::Assistant => "Assistant", Role::System => "System", }; - let rendered = Self::render_content(&msg.content); + let rendered = Self::render_content(&msg.content, Some(&tmp_dir)); if !rendered.is_empty() { parts.push(format!("[{role_label}]\n{rendered}")); } @@ -339,12 +453,17 @@ impl ClaudeCodeDriver { /// Render message content for the text-only CLI prompt. /// - /// Text blocks pass through verbatim. Image blocks are rendered as - /// `[attachment: image, ~N KB — not viewable on this - /// provider]` so the model receives a positive signal that an + /// Text blocks pass through verbatim. Image blocks are materialized to + /// an on-disk tmpfile (when `image_dir` is provided) so the model can + /// view them via the CLI's `Read` tool — Claude Code is multimodal and + /// will load the file as native image content. We render a directive + /// telling the model exactly which path to read, plus the original + /// `source_url` (e.g. Discord CDN) when known. If materialization + /// fails or `image_dir` is `None` (test path), we fall back to the + /// legacy textual placeholder so the model at least knows an /// attachment arrived. ToolUse/ToolResult/Thinking are omitted — /// the CLI manages its own tool loop. - fn render_content(content: &MessageContent) -> String { + fn render_content(content: &MessageContent, image_dir: Option<&Path>) -> String { match content { MessageContent::Text(s) => s.clone(), MessageContent::Blocks(blocks) => blocks @@ -357,11 +476,28 @@ impl ClaudeCodeDriver { Some(text.clone()) } } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { + media_type, + data, + source_url, + } => { // base64 → ~3/4 the length in decoded bytes. let approx_kb = (data.len().saturating_mul(3) / 4) / 1024; + let url_suffix = match source_url { + Some(u) => format!(" (original: {u})"), + None => String::new(), + }; + if let Some(dir) = image_dir { + if let Some(path) = materialize_image(media_type, data, dir) { + return Some(format!( + "[attachment: {media_type} image, ~{approx_kb} KB — view with the Read tool at {path}{url_suffix}]", + path = path.display() + )); + } + } + // Fallback: at least surface the URL if we have one. Some(format!( - "[attachment: {media_type} image, ~{approx_kb} KB — not viewable on this provider]" + "[attachment: {media_type} image, ~{approx_kb} KB — not viewable on this provider{url_suffix}]" )) } ContentBlock::ToolUse { .. } @@ -1408,6 +1544,7 @@ mod tests { ContentBlock::Image { media_type: "image/png".to_string(), data: fake_b64, + source_url: None, }, ]), ..Default::default() @@ -1427,9 +1564,13 @@ mod tests { prompt.contains("[attachment: image/png image"), "image rendered as synthetic attachment marker, got: {prompt}" ); + // Either materialized to a tmpfile (preferred) or fell back to + // the legacy "not viewable" placeholder. Both are acceptable + // outcomes for this test; we just need the marker to be emitted. assert!( - prompt.contains("not viewable on this provider"), - "marker explains the limitation, got: {prompt}" + prompt.contains("view with the Read tool at") + || prompt.contains("not viewable on this provider"), + "marker either points at a tmpfile or explains the limitation, got: {prompt}" ); } @@ -1444,6 +1585,7 @@ mod tests { content: MessageContent::Blocks(vec![ContentBlock::Image { media_type: "image/jpeg".to_string(), data: "Zm9v".to_string(), + source_url: Some("https://cdn.example/foo.jpg".to_string()), }]), ..Default::default() }], diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index f09d1ec044..4a9762d41d 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -298,7 +298,7 @@ fn convert_messages( thought_signature, }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(GeminiPart::InlineData { inline_data: GeminiInlineData { mime_type: media_type.clone(), diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 73210f2757..4625907ff3 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -525,7 +525,7 @@ impl LlmDriver for OpenAIDriver { ContentBlock::Text { text, .. } => { parts.push(OaiContentPart::Text { text: text.clone() }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(OaiContentPart::ImageUrl { image_url: OaiImageUrl { url: format!("data:{media_type};base64,{data}"), diff --git a/crates/openfang-runtime/src/drivers/vertex.rs b/crates/openfang-runtime/src/drivers/vertex.rs index 9f04841634..58d3705c6c 100644 --- a/crates/openfang-runtime/src/drivers/vertex.rs +++ b/crates/openfang-runtime/src/drivers/vertex.rs @@ -356,7 +356,7 @@ fn convert_messages( }, }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(VertexPart::InlineData { inline_data: VertexInlineData { mime_type: media_type.clone(), diff --git a/crates/openfang-types/src/message.rs b/crates/openfang-types/src/message.rs index ba5e93438d..028035fda7 100644 --- a/crates/openfang-types/src/message.rs +++ b/crates/openfang-types/src/message.rs @@ -102,6 +102,12 @@ pub enum ContentBlock { media_type: String, /// Base64-encoded image data. data: String, + /// Original source URL (e.g. Discord CDN), if the image was + /// materialized from a remote attachment. Preserved alongside + /// `data` so text-only drivers can reference the URL and + /// vision-capable drivers retain it for diagnostics. + #[serde(default, skip_serializing_if = "Option::is_none")] + source_url: Option, }, /// A tool use request from the assistant. #[serde(rename = "tool_use")] @@ -403,6 +409,7 @@ mod tests { let block = ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }; let json = serde_json::to_value(&block).unwrap(); assert_eq!(json["type"], "image"); @@ -597,6 +604,7 @@ mod tests { ContentBlock::Image { media_type: "image/jpeg".to_string(), data: "base64data".to_string(), + source_url: None, }, ]; let msg = Message::user_with_blocks(blocks); From 404b08a0b99ede3de461dab9db6c70c57fabf0a1 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 17:09:20 -0700 Subject: [PATCH 045/105] fix(runtime/claude_code): atomically publish materialized image tmpfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize_image` previously wrote bytes directly to the content-addressed destination via `fs::write`, which truncates-then-writes non-atomically. Two concurrent renders of the same image (or a re-render racing a Read tool invocation) could produce a torn, partially-written file readable by the CLI's Read tool — a real risk under load now that file-sharing is a first-class feature. Switch to write-tmp + rename(2): write the decoded bytes to a unique sibling tmpfile (suffixed with pid + nanos), then atomically rename into the content-addressed destination. rename(2) is atomic on the same filesystem, so readers either see the full file or nothing. Loser of a race still rename-replaces with byte-identical content. Orphan .tmp files from crashed processes are reaped by the existing 24h TTL sweep (mtime-based). --- .../src/drivers/claude_code.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index d6f8395773..f135b0b6a6 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -272,8 +272,28 @@ fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option Date: Sat, 2 May 2026 17:09:51 -0700 Subject: [PATCH 046/105] fix(runtime/claude_code): pass --add-dir for materialized image tmpfiles The CLI's Read tool refuses paths outside the agent's working directory unless explicitly granted via --add-dir (or unless --dangerously-skip-permissions is set, which we don't want to rely on as the only escape hatch). Materialized images live under $HOME/.openfang/tmp/images/, which is outside the agent workspace, so without --add-dir the materialization is a dead-end whenever skip_permissions is false. Append --add-dir to both the non-streaming and streaming Command builders. The directory is per-user and content-addressed, so the grant is narrow and idempotent. --- crates/openfang-runtime/src/drivers/claude_code.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index f135b0b6a6..d6898514f3 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -1021,6 +1021,13 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--settings").arg(s.path()); }); let native_deny_wired = _cc_settings.is_some(); + // Grant the CLI's Read tool access to our image tmp dir, which lives + // outside the agent's workspace cwd. Without --add-dir the CLI would + // refuse Read on `$HOME/.openfang/tmp/images/*` (unless + // --dangerously-skip-permissions is set) and the materialization would + // be a dead-end. Cheap and idempotent — the dir is per-user and + // content-addressed. + cmd.arg("--add-dir").arg(image_tmp_dir()); Self::apply_env_filter(&mut cmd); @@ -1266,6 +1273,8 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--settings").arg(s.path()); }); let native_deny_wired = _cc_settings.is_some(); + // Same image-tmp-dir grant as the non-streaming path; see complete(). + cmd.arg("--add-dir").arg(image_tmp_dir()); Self::apply_env_filter(&mut cmd); From 70f257311b18a8fec2add41159b161e4af686bcd Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 17:12:17 -0700 Subject: [PATCH 047/105] refactor(runtime): extract image_cache module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the content-addressed image tmpfile cache out of the Claude Code driver and into a sibling module so the upcoming outbound Discord file-sharing path can reuse the same cache. No behavior change — the extracted helpers (image_tmp_dir, ext_for_mime, materialize_image, sweep_old_image_tmpfiles, IMAGE_TMP_TTL_SECS, sweep guard) are byte-identical to the previous private impl. The driver now imports image_tmp_dir / materialize_image / spawn_sweep_once from crate::image_cache. The new module is publicly exported so producers outside the runtime crate (channel adapters) can resolve materialized paths from base64 image blocks before forwarding them to outbound transports. --- .../src/drivers/claude_code.rs | 136 +-------------- crates/openfang-runtime/src/image_cache.rs | 160 ++++++++++++++++++ crates/openfang-runtime/src/lib.rs | 1 + 3 files changed, 167 insertions(+), 130 deletions(-) create mode 100644 crates/openfang-runtime/src/image_cache.rs diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index d6898514f3..fb18296498 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -9,18 +9,16 @@ //! hung CLI processes from blocking agents indefinitely. use crate::bridge_auth::{SpawnGuard, TokenIssuer}; +use crate::image_cache::{image_tmp_dir, materialize_image, spawn_sweep_once}; use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; use async_trait::async_trait; -use base64::Engine; use dashmap::DashMap; use openfang_types::agent::AgentId; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; use std::str::FromStr; -use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::Once; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; @@ -210,129 +208,10 @@ const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; /// Default subprocess timeout in seconds (5 minutes). const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; -/// TTL for materialized image tmpfiles (24 hours). Files older than this -/// are swept on driver init. -const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; - -/// One-shot guard so the TTL sweep only fires once per process. -static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); - -/// Resolve the directory used for materializing image attachments. -/// -/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang -/// install. Falls back to the OS temp dir when `$HOME` isn't set (which -/// shouldn't happen in our deployed daemon but is handled defensively). -fn image_tmp_dir() -> PathBuf { - if let Ok(home) = std::env::var("HOME") { - let mut p = PathBuf::from(home); - p.push(".openfang"); - p.push("tmp"); - p.push("images"); - p - } else { - let mut p = std::env::temp_dir(); - p.push("openfang-images"); - p - } -} - -/// Map a MIME type to a sensible filename extension. -fn ext_for_mime(media_type: &str) -> &'static str { - match media_type.to_ascii_lowercase().as_str() { - "image/png" => "png", - "image/jpeg" | "image/jpg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - "image/heic" => "heic", - "image/heif" => "heif", - "image/bmp" => "bmp", - "image/svg+xml" => "svg", - _ => "bin", - } -} - -/// Decode the base64 image and write it to a content-addressed file under -/// `dir`. Idempotent: if a file with the same content hash already exists, -/// the existing path is returned without rewriting. Returns `None` on -/// decode or I/O failure (caller falls back to the textual placeholder). -fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { - let bytes = base64::engine::general_purpose::STANDARD - .decode(data.as_bytes()) - .ok()?; - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let hash = hasher.finalize(); - let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); - let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); - let path = dir.join(filename); - if path.exists() { - return Some(path); - } - if let Err(e) = std::fs::create_dir_all(dir) { - warn!(dir = ?dir, error = %e, "failed to create claude_code image tmp dir"); - return None; - } - // Atomic publish: write to a unique tmp sibling, then rename into place. - // Two concurrent renders of the same image each write their own tmpfile; - // the rename(2) is atomic on the same filesystem, so the Read tool never - // sees a torn or partially-written file. If the destination already exists - // by the time we rename (loser of a race), the rename still succeeds - // (POSIX replaces) — and the contents are identical anyway by construction. - let tmp_path = dir.join(format!( - "{hex}.{pid}.{nanos}.tmp", - pid = std::process::id(), - nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0), - )); - if let Err(e) = std::fs::write(&tmp_path, &bytes) { - warn!(path = ?tmp_path, error = %e, "failed to write claude_code image tmpfile"); - return None; - } - if let Err(e) = std::fs::rename(&tmp_path, &path) { - warn!(from = ?tmp_path, to = ?path, error = %e, "failed to rename claude_code image tmpfile into place"); - // Best-effort cleanup of the orphan tmpfile. - let _ = std::fs::remove_file(&tmp_path); - return None; - } - Some(path) -} - -/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: -/// any error is logged at debug and the sweep moves on. -fn sweep_old_image_tmpfiles(dir: &Path) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) => { - debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); - return; - } - }; - let now = std::time::SystemTime::now(); - let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); - let mut removed = 0u32; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(meta) = entry.metadata() else { continue }; - if !meta.is_file() { - continue; - } - let Ok(modified) = meta.modified() else { continue }; - if let Ok(age) = now.duration_since(modified) { - if age > ttl { - if let Err(e) = std::fs::remove_file(&path) { - debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); - } else { - removed += 1; - } - } - } - } - if removed > 0 { - info!(removed, "swept stale claude_code image tmpfiles"); - } -} +// Image materialization helpers (image_tmp_dir, ext_for_mime, +// materialize_image, sweep_old_image_tmpfiles, TTL constants, sweep guard) +// live in crate::image_cache so the outbound file-sharing path can reuse +// the same content-addressed cache without a circular dep on this driver. /// LLM driver that delegates to the Claude Code CLI. pub struct ClaudeCodeDriver { @@ -376,10 +255,7 @@ impl ClaudeCodeDriver { } // Best-effort sweep of stale image tmpfiles, once per process. - IMAGE_TMP_SWEEP_ONCE.call_once(|| { - let dir = image_tmp_dir(); - std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); - }); + spawn_sweep_once(); Self { cli_path: cli_path diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs new file mode 100644 index 0000000000..f6e998449d --- /dev/null +++ b/crates/openfang-runtime/src/image_cache.rs @@ -0,0 +1,160 @@ +//! Content-addressed image tmpfile cache. +//! +//! Decodes base64 image payloads (the `ContentBlock::Image` shape used by +//! all LLM drivers) and writes them to a content-addressed file under +//! `$HOME/.openfang/tmp/images/` so out-of-process consumers — initially +//! the Claude Code CLI's Read tool, soon the outbound Discord bridge — +//! can reach the bytes by path. +//! +//! Originally lived inside `drivers/claude_code.rs`; lifted here so the +//! outbound file-sharing path can reuse the same cache without a circular +//! dep on the driver crate. Behavior is byte-identical to the previous +//! private implementation. +//! +//! Properties: +//! - **Idempotent.** Filename is the first 64 bits of SHA-256(bytes), so +//! re-rendering the same image hits the cache. +//! - **Atomic publish.** Bytes are written to a unique sibling tmpfile +//! then `rename(2)`-d into place; readers never see a torn file. +//! - **Time-bounded.** A best-effort sweep on first call (per process) +//! removes files older than [`IMAGE_TMP_TTL_SECS`]. + +use base64::Engine; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::Once; +use tracing::{debug, info, warn}; + +/// TTL for materialized image tmpfiles (24 hours). Files older than this +/// are swept on first use. +pub const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; + +/// One-shot guard so the TTL sweep only fires once per process. +static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); + +/// Resolve the directory used for materializing image attachments. +/// +/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang +/// install. Falls back to the OS temp dir when `$HOME` isn't set (which +/// shouldn't happen in our deployed daemon but is handled defensively). +pub fn image_tmp_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let mut p = PathBuf::from(home); + p.push(".openfang"); + p.push("tmp"); + p.push("images"); + p + } else { + let mut p = std::env::temp_dir(); + p.push("openfang-images"); + p + } +} + +/// Map a MIME type to a sensible filename extension. +pub fn ext_for_mime(media_type: &str) -> &'static str { + match media_type.to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/heic" => "heic", + "image/heif" => "heif", + "image/bmp" => "bmp", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +/// Decode the base64 image and write it to a content-addressed file under +/// `dir`. Idempotent: if a file with the same content hash already exists, +/// the existing path is returned without rewriting. Returns `None` on +/// decode or I/O failure (caller falls back to a textual placeholder). +pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.as_bytes()) + .ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = hasher.finalize(); + let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); + let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); + let path = dir.join(filename); + if path.exists() { + return Some(path); + } + if let Err(e) = std::fs::create_dir_all(dir) { + warn!(dir = ?dir, error = %e, "failed to create openfang image tmp dir"); + return None; + } + // Atomic publish: write to a unique tmp sibling, then rename into place. + // Two concurrent renders of the same image each write their own tmpfile; + // the rename(2) is atomic on the same filesystem, so consumers never see + // a torn or partially-written file. If the destination already exists by + // the time we rename (loser of a race), the rename still succeeds (POSIX + // replaces) — and the contents are identical anyway by construction. + let tmp_path = dir.join(format!( + "{hex}.{pid}.{nanos}.tmp", + pid = std::process::id(), + nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + )); + if let Err(e) = std::fs::write(&tmp_path, &bytes) { + warn!(path = ?tmp_path, error = %e, "failed to write openfang image tmpfile"); + return None; + } + if let Err(e) = std::fs::rename(&tmp_path, &path) { + warn!(from = ?tmp_path, to = ?path, error = %e, "failed to rename openfang image tmpfile into place"); + // Best-effort cleanup of the orphan tmpfile. + let _ = std::fs::remove_file(&tmp_path); + return None; + } + Some(path) +} + +/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: +/// any error is logged at debug and the sweep moves on. +pub fn sweep_old_image_tmpfiles(dir: &Path) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); + return; + } + }; + let now = std::time::SystemTime::now(); + let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); + let mut removed = 0u32; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + if let Ok(age) = now.duration_since(modified) { + if age > ttl { + if let Err(e) = std::fs::remove_file(&path) { + debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); + } else { + removed += 1; + } + } + } + } + if removed > 0 { + info!(removed, "swept stale openfang image tmpfiles"); + } +} + +/// Spawn the once-per-process TTL sweep in a background thread. Safe to +/// call from any number of driver inits — the [`Once`] guard ensures only +/// the first call does work. +pub fn spawn_sweep_once() { + IMAGE_TMP_SWEEP_ONCE.call_once(|| { + let dir = image_tmp_dir(); + std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); + }); +} diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index 3c3aa1a022..8a17bef0ea 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -27,6 +27,7 @@ pub mod embedding; pub mod graceful_shutdown; pub mod hooks; pub mod host_functions; +pub mod image_cache; pub mod image_gen; pub mod kernel_handle; pub mod link_understanding; From 75ff40a8a08618841ae524cc27f5f779eabb34bb Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 3 May 2026 00:26:52 -0700 Subject: [PATCH 048/105] fix(runtime/image_cache): refresh mtime on cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize_image` is content-addressed and idempotent: a re-render of the same image returns the existing path without rewriting. As a side effect, the tmpfile's mtime never advanced past its original write — so the 24h TTL sweep, which gates on `meta.modified()`, could GC a tmpfile still actively referenced by an in-scope ContentBlock::Image in a long-running conversation. Refresh mtime via `File::set_modified(SystemTime::now())` (futimens on Unix) on every cache hit. Read-only fd is sufficient: futimens only requires file ownership, not write access. Best-effort: any failure is debug-logged and the cached path is returned anyway — worst case is the prior 24h-GC behavior. Tests: cache-hit refreshes mtime and survives a sweep that would otherwise GC the file; companion test confirms the sweep does remove genuinely stale files. --- crates/openfang-runtime/src/image_cache.rs | 105 +++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs index f6e998449d..1cda8bc719 100644 --- a/crates/openfang-runtime/src/image_cache.rs +++ b/crates/openfang-runtime/src/image_cache.rs @@ -81,6 +81,17 @@ pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option Option std::io::Result<()> { + let f = std::fs::File::open(path)?; + f.set_modified(std::time::SystemTime::now()) +} + /// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: /// any error is logged at debug and the sweep moves on. pub fn sweep_old_image_tmpfiles(dir: &Path) { @@ -158,3 +179,87 @@ pub fn spawn_sweep_once() { std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); }); } + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use std::time::{Duration, SystemTime}; + + /// A 1×1 transparent PNG, base64-encoded. Tiny enough to keep tests fast. + const TINY_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + + #[test] + fn materialize_image_refreshes_mtime_on_cache_hit() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + // First call materializes. + let path = materialize_image("image/png", TINY_PNG_B64, dir) + .expect("first materialization should succeed"); + assert!(path.exists()); + + // Backdate mtime to ~25 hours ago — past IMAGE_TMP_TTL_SECS. + let stale = SystemTime::now() - Duration::from_secs(IMAGE_TMP_TTL_SECS + 3600); + let f = std::fs::File::open(&path).unwrap(); + f.set_modified(stale).unwrap(); + drop(f); + let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap(); + + // Second call should hit cache AND refresh mtime. + let path2 = materialize_image("image/png", TINY_PNG_B64, dir) + .expect("cache hit should return Some"); + assert_eq!(path, path2); + let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert!( + mtime_after > mtime_before, + "mtime should be refreshed on cache hit (before={mtime_before:?}, after={mtime_after:?})" + ); + + // And the now-touched file must NOT be GC'd by a sweep that + // would have caught the stale mtime. + sweep_old_image_tmpfiles(dir); + assert!( + path.exists(), + "refreshed tmpfile should survive the TTL sweep" + ); + } + + #[test] + fn sweep_removes_stale_tmpfiles() { + // Sanity check that the sweep actually GCs old files — pairs with + // the test above to prove the refresh is what saves the file. + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let path = materialize_image("image/png", TINY_PNG_B64, dir).unwrap(); + + let stale = SystemTime::now() - Duration::from_secs(IMAGE_TMP_TTL_SECS + 3600); + let f = std::fs::File::open(&path).unwrap(); + f.set_modified(stale).unwrap(); + drop(f); + + sweep_old_image_tmpfiles(dir); + assert!(!path.exists(), "stale tmpfile should have been swept"); + } + + #[test] + fn ext_for_mime_known_and_unknown() { + assert_eq!(ext_for_mime("image/png"), "png"); + assert_eq!(ext_for_mime("IMAGE/JPEG"), "jpg"); + assert_eq!(ext_for_mime("image/webp"), "webp"); + assert_eq!(ext_for_mime("application/octet-stream"), "bin"); + } + + #[test] + fn materialize_image_rejects_invalid_base64() { + let tmp = tempfile::tempdir().unwrap(); + assert!(materialize_image("image/png", "!!!not-base64!!!", tmp.path()).is_none()); + } + + // Force-reference base64 engine to keep imports tidy in case someone + // refactors and the const is the only consumer. + #[allow(dead_code)] + fn _b64_compile_check() { + let _ = base64::engine::general_purpose::STANDARD.decode(TINY_PNG_B64); + } +} From 6d56975886c0707de1c0fb3b363a4e685f56a2b6 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 3 May 2026 00:25:30 -0700 Subject: [PATCH 049/105] fix(runtime/compactor): preserve http(s) source_url across compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentBlock::Image was being stringified to `[Image: {mime}]`, silently dropping the `source_url` populated by the inbound Discord path. That field exists so the outbound path (PR-C) can re-fetch the original CDN-hosted image and re-attach it post-compaction — without it, every compaction event quietly severed an image from its CDN origin. Emit `[Image: {mime} @ {url}]` when `source_url` is `http://` or `https://`. `file://` (local tmpfile materialization) and any other schemes fall back to the legacy mime-only form: those are internal and must not leak into compacted summaries that may be persisted, logged, or shipped across processes. Tests cover all four arms (https, http, file://, None). --- crates/openfang-runtime/src/compactor.rs | 96 +++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 6b01fc363b..600e00a11c 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -400,8 +400,31 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S conversation_text .push_str(&format!("[Tool result ({status}): {preview}]\n\n")); } - ContentBlock::Image { media_type, .. } => { - conversation_text.push_str(&format!("[Image: {media_type}]\n\n")); + ContentBlock::Image { + media_type, + source_url, + .. + } => { + // Preserve the original CDN URL across compaction so the + // outbound Discord path (PR-C) can re-attach the image by + // re-fetching it. Only http(s) URLs are exposed: local + // `file://` tmpfile paths are an internal materialization + // detail and shouldn't leak into compacted summaries that + // may be persisted, logged, or sent across processes. + match source_url.as_deref() { + Some(url) + if url.starts_with("http://") + || url.starts_with("https://") => + { + conversation_text.push_str(&format!( + "[Image: {media_type} @ {url}]\n\n" + )); + } + _ => { + conversation_text + .push_str(&format!("[Image: {media_type}]\n\n")); + } + } } ContentBlock::Thinking { .. } => {} ContentBlock::RedactedThinking { .. } => {} @@ -1291,6 +1314,75 @@ mod tests { assert!(text.contains("[Image: image/png]")); } + #[test] + fn test_build_conversation_text_image_source_url_https() { + // https:// CDN URL is exposed post-compaction so the outbound path + // can re-fetch the image. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/png".to_string(), + data: "base64data".to_string(), + source_url: Some("https://cdn.discordapp.com/attachments/x/y.png".to_string()), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/png @ https://cdn.discordapp.com/attachments/x/y.png]"), + "https source_url should be preserved, got: {text}" + ); + } + + #[test] + fn test_build_conversation_text_image_source_url_http() { + // Plain http (rare but valid) is also exposed. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/jpeg".to_string(), + data: "base64data".to_string(), + source_url: Some("http://example.com/foo.jpg".to_string()), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/jpeg @ http://example.com/foo.jpg]"), + "http source_url should be preserved, got: {text}" + ); + } + + #[test] + fn test_build_conversation_text_image_source_url_file_falls_back() { + // file:// URLs (local tmpfile materialization) MUST NOT leak into + // compacted summaries — fall back to the legacy mime-only form. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/png".to_string(), + data: "base64data".to_string(), + source_url: Some( + "file:///Users/x/.openfang/tmp/images/abc.png".to_string(), + ), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/png]"), + "file:// source_url should fall back to legacy form, got: {text}" + ); + assert!( + !text.contains("file://"), + "file:// path must not leak post-compaction, got: {text}" + ); + assert!( + !text.contains(".openfang"), + "local tmpfile path must not leak post-compaction, got: {text}" + ); + } + #[test] fn test_build_conversation_text_truncates_oversized() { let config = CompactionConfig { From dca53a14a4ea9f0fe22efe4abd70222158a0e94a Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 12 May 2026 16:01:39 -0700 Subject: [PATCH 050/105] fix(runtime/image_cache): add USERPROFILE fallback in image_tmp_dir for Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the HOME → USERPROFILE convention used elsewhere in the kernel (see crates/openfang-runtime/src/drivers/mod.rs:429). Falls back to std::env::temp_dir() only when neither is set. Addresses reviewer feedback on PR #1151. --- crates/openfang-runtime/src/image_cache.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs index 1cda8bc719..ccce6c470f 100644 --- a/crates/openfang-runtime/src/image_cache.rs +++ b/crates/openfang-runtime/src/image_cache.rs @@ -34,11 +34,15 @@ static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); /// Resolve the directory used for materializing image attachments. /// -/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang -/// install. Falls back to the OS temp dir when `$HOME` isn't set (which -/// shouldn't happen in our deployed daemon but is handled defensively). +/// Lives under `$HOME/.openfang/tmp/images` (or `%USERPROFILE%\.openfang\tmp\images` +/// on Windows) so it travels with the OpenFang install. Falls back to the OS +/// temp dir when neither `$HOME` nor `%USERPROFILE%` is set (which shouldn't +/// happen in our deployed daemon but is handled defensively). +/// +/// The `HOME` → `USERPROFILE` order matches the convention used elsewhere in +/// the kernel (see `crates/openfang-runtime/src/drivers/mod.rs`). pub fn image_tmp_dir() -> PathBuf { - if let Ok(home) = std::env::var("HOME") { + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { let mut p = PathBuf::from(home); p.push(".openfang"); p.push("tmp"); From 0bb46dfea317cbea9c93e1e86d7b479d7e84e4c2 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 12 May 2026 16:04:24 -0700 Subject: [PATCH 051/105] fix(runtime/compactor): use Default for Message in source_url tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream added msg_id/provider_msg_id fields to Message after this branch forked. Switch to ..Default::default() in the three source_url test cases to match the kernel-wide pattern and stay forward-compatible. No behavior change — these are test-only initializers. --- crates/openfang-runtime/src/compactor.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 600e00a11c..70a1160251 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -1326,6 +1326,7 @@ mod tests { data: "base64data".to_string(), source_url: Some("https://cdn.discordapp.com/attachments/x/y.png".to_string()), }]), + ..Default::default() }]; let text = build_conversation_text(&messages, &config); assert!( @@ -1345,6 +1346,7 @@ mod tests { data: "base64data".to_string(), source_url: Some("http://example.com/foo.jpg".to_string()), }]), + ..Default::default() }]; let text = build_conversation_text(&messages, &config); assert!( @@ -1367,6 +1369,7 @@ mod tests { "file:///Users/x/.openfang/tmp/images/abc.png".to_string(), ), }]), + ..Default::default() }]; let text = build_conversation_text(&messages, &config); assert!( From dadc6f12051d8e7b64d290f0acbfc554b49bc7e9 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 23:33:36 -0700 Subject: [PATCH 052/105] feat(mcp-bridge): add upstream-forwarding protocol variants (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the wire-protocol surface for daemon→bridge forwarding of upstream MCP tool calls (Linear, Notion, etc.) advertised via config.toml. This commit is types-and-tests only; no caller wires the new variants yet and no behavior changes. New types in openfang_mcp_bridge::protocol: - ListUpstreamRequest { request_id } - UpstreamToolDef { name, server, description?, input_schema } - UpstreamListResponse { request_id, result } - UpstreamListResult::{Ok,Error} New Frame variants (internal tag "type", snake_case): - Frame::ListUpstream(ListUpstreamRequest) tag: list_upstream - Frame::UpstreamList(UpstreamListResponse) tag: upstream_list Identity binding: ListUpstreamRequest carries no agent_id; the daemon resolves the caller from the authenticated Hello token (consistent with the hardened CallRequest path post-ANAI-31). Per-agent gating is enforced server-side against `agent.toml mcp_servers`. Naming: forwarded tools use the same `mcp_{server}_{tool}` namespace already produced at MCP discovery in openfang-runtime/src/mcp.rs, so the bridge surfaces them verbatim and the daemon dispatcher can route on prefix. Forward-compat: adding variants is additive on the wire. An older peer sees the new tag and closes the connection with a clean serde decode error (covered by `frame_unknown_variant_is_rejected_cleanly`); we ship daemon and bridge from the same workspace, so version skew here would be a build-system error, not a runtime concern. Tests: 5 added, all green. - frame_roundtrip_list_upstream_request - frame_roundtrip_upstream_list_ok / _empty / _error - upstream_tool_def_omits_none_description - frame_unknown_variant_is_rejected_cleanly Depends on #1205 (feat/bridge-tool-surface-v2). Followup commits will wire daemon dispatch, bridge surface, and the discovery-time namespace-collision check. Refs: projects/openfang-fork/mcp-upstream-forwarding-design.md --- crates/openfang-mcp-bridge/src/protocol.rs | 183 +++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/openfang-mcp-bridge/src/protocol.rs b/crates/openfang-mcp-bridge/src/protocol.rs index cadb8d321b..0679f41935 100644 --- a/crates/openfang-mcp-bridge/src/protocol.rs +++ b/crates/openfang-mcp-bridge/src/protocol.rs @@ -109,6 +109,65 @@ pub enum CallResult { Error { message: String }, } +/// Bridge → daemon: request the list of upstream MCP tools the calling +/// agent is permitted to invoke. +/// +/// Sent once per session, immediately after a successful [`Hello`]/[`HelloAck::Ok`] +/// handshake. The daemon answers with [`UpstreamListResponse`]. +/// +/// Identity is taken from the authenticated [`Hello::token`] (resolved +/// server-side to an `agent_id`); the bridge does not name the agent in +/// this frame. Per-agent gating is enforced server-side against the +/// agent's `agent.toml mcp_servers` allowlist. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListUpstreamRequest { + /// Caller-assigned correlation id. Daemon echoes in + /// [`UpstreamListResponse::request_id`]. + pub request_id: u64, +} + +/// One forwarded upstream MCP tool, as advertised to the bridge. +/// +/// The `name` is already namespaced (`mcp_{server}_{tool}`) by the daemon +/// at MCP-discovery time; the bridge surfaces this name verbatim in its +/// own `tools/list` and routes invocations of the same name back across +/// the IPC. +/// +/// `input_schema` is the upstream server's JSON Schema for the tool, passed +/// through opaquely — the daemon does not validate it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpstreamToolDef { + /// Already-namespaced tool name (e.g. `mcp_linear_getteams`). + pub name: String, + /// Logical server name from `config.toml` (e.g. `linear`). Used by + /// the bridge for grouping/debug; not parsed from `name` on receive. + pub server: String, + /// Human-readable description, forwarded from the upstream MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Opaque JSON Schema for the tool's input. Forwarded verbatim. + pub input_schema: serde_json::Value, +} + +/// Daemon → bridge: response to [`ListUpstreamRequest`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpstreamListResponse { + pub request_id: u64, + pub result: UpstreamListResult, +} + +/// Outcome of an upstream tool-list dispatch. +/// +/// `Error` is reserved for protocol-layer failures (agent identity not +/// resolvable, registry unavailable). An agent that simply has no upstream +/// servers configured receives `Ok { tools: vec![] }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum UpstreamListResult { + Ok { tools: Vec }, + Error { message: String }, +} + /// Top-level frame type, for connections that may carry multiple message kinds. /// /// At present we only multiplex Hello/HelloAck on connection start and @@ -122,6 +181,15 @@ pub enum Frame { HelloAck(HelloAck), Call(CallRequest), Response(CallResponse), + /// Bridge → daemon: request the per-agent upstream MCP tool list. + /// + /// Added in protocol v1 as a non-breaking variant; older builds that + /// predate this variant reject the frame as an unknown tag and close + /// the connection. Daemon and bridge ship from the same workspace, + /// so version skew here would indicate a build-system error. + ListUpstream(ListUpstreamRequest), + /// Daemon → bridge: response to [`Frame::ListUpstream`]. + UpstreamList(UpstreamListResponse), } #[cfg(feature = "ipc-codec")] @@ -221,4 +289,119 @@ mod tests { assert!(s.contains("rejected")); assert!(s.contains("bad token")); } + + #[test] + fn frame_roundtrip_list_upstream_request() { + let frame = Frame::ListUpstream(ListUpstreamRequest { request_id: 99 }); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains("list_upstream")); + let back: Frame = serde_json::from_str(&json).unwrap(); + match back { + Frame::ListUpstream(r) => assert_eq!(r.request_id, 99), + _ => panic!("wrong variant"), + } + } + + #[test] + fn frame_roundtrip_upstream_list_ok() { + let frame = Frame::UpstreamList(UpstreamListResponse { + request_id: 99, + result: UpstreamListResult::Ok { + tools: vec![UpstreamToolDef { + name: "mcp_linear_getteams".into(), + server: "linear".into(), + description: Some("List Linear teams".into()), + input_schema: serde_json::json!({ "type": "object" }), + }], + }, + }); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains("upstream_list")); + assert!(json.contains("mcp_linear_getteams")); + let back: Frame = serde_json::from_str(&json).unwrap(); + match back { + Frame::UpstreamList(r) => { + assert_eq!(r.request_id, 99); + match r.result { + UpstreamListResult::Ok { tools } => { + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].server, "linear"); + } + _ => panic!("wrong result variant"), + } + } + _ => panic!("wrong frame variant"), + } + } + + #[test] + fn frame_roundtrip_upstream_list_empty() { + // Agent with no upstream MCP servers configured receives an empty + // Ok list, not an Error. Guards against the bridge treating + // "no servers" as a fatal handshake failure. + let frame = Frame::UpstreamList(UpstreamListResponse { + request_id: 1, + result: UpstreamListResult::Ok { tools: vec![] }, + }); + let json = serde_json::to_string(&frame).unwrap(); + let back: Frame = serde_json::from_str(&json).unwrap(); + if let Frame::UpstreamList(r) = back { + match r.result { + UpstreamListResult::Ok { tools } => assert!(tools.is_empty()), + _ => panic!("wrong result variant"), + } + } else { + panic!("wrong frame variant"); + } + } + + #[test] + fn frame_roundtrip_upstream_list_error() { + let frame = Frame::UpstreamList(UpstreamListResponse { + request_id: 7, + result: UpstreamListResult::Error { + message: "agent identity not resolvable".into(), + }, + }); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains("error")); + let back: Frame = serde_json::from_str(&json).unwrap(); + if let Frame::UpstreamList(r) = back { + match r.result { + UpstreamListResult::Error { message } => { + assert!(message.contains("not resolvable")); + } + _ => panic!("wrong result variant"), + } + } else { + panic!("wrong frame variant"); + } + } + + #[test] + fn upstream_tool_def_omits_none_description() { + let def = UpstreamToolDef { + name: "mcp_notion_search".into(), + server: "notion".into(), + description: None, + input_schema: serde_json::json!({}), + }; + let json = serde_json::to_string(&def).unwrap(); + // skip_serializing_if drops the field entirely when None. + assert!(!json.contains("description")); + // And it round-trips back as None. + let back: UpstreamToolDef = serde_json::from_str(&json).unwrap(); + assert!(back.description.is_none()); + } + + #[test] + fn frame_unknown_variant_is_rejected_cleanly() { + // Forward-compat guard: a frame with an unknown `type` tag must + // produce a serde error, not a panic. The IPC reader maps this + // to an io::Error and closes the connection — but the test here + // is just that decode fails without unwinding. + let json = r#"{"type":"future_kind","payload":{}}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err(), "expected decode failure on unknown tag"); + } } From 5d33a4a8140a8854490edefd9425cc019f4274bc Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 19 May 2026 23:51:06 -0700 Subject: [PATCH 053/105] feat(bridge-ipc): daemon-side upstream MCP dispatch + list_upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the daemon half of the upstream MCP forwarding path (design doc §5.2–5.4): - `handle_list_upstream`: walks `kernel.mcp_connections`, returns tools for servers on the agent's `mcp_servers` allowlist. - `dispatch_upstream_mcp_call`: routes `mcp_{server}_{tool}` invocations through the kernel's MCP registry, bypassing `execute_tool` and the static `ALLOWED_TOOLS` gate. - Request loop now multiplexes `Frame::Call` and `Frame::ListUpstream` without state coupling. Security gates baked in (per design doc §6): - **Hardened-token lane only.** Both new paths refuse the legacy self-claimed `agent_id` lane outright — upstream MCP servers can carry secrets and we need a daemon-issued identity to authorize against. - **Per-agent server allowlist with default-deny.** `entry.manifest.mcp_servers = []` means *no* upstream tools for the bridge path — deliberate semantic departure from the in-process MCP path's historical `[] = all` convention. Convergence on default-deny everywhere tracked as follow-up. - **Truncation is explicit.** Results exceeding the frame budget come back as `is_error=true` with a truncation marker, never silently dropped. - **Hyphen-safe server matching.** Uses `extract_mcp_server_from_known` against the agent's allowlist so servers like `bocha-search` resolve correctly. Tests: - `ipc_list_upstream_roundtrip` — wire round-trip of the new variants, plus a follow-up Call to confirm request-loop state isn't tainted between message kinds. - `ipc_mcp_call_through_twin_returns_canned_ok` — exercises the production early-branch shape (mcp_* bypasses ALLOWED_TOOLS). - Test twin extended to handle `ListUpstream` and mirror the production `is_mcp_tool` early branch. No behavior change for the built-in tool surface: existing 17 `bridge_ipc::tests` (15 prior + 2 new) all pass. Refs design doc: projects/openfang-fork/mcp-upstream-forwarding-design.md --- crates/openfang-api/src/bridge_ipc.rs | 638 +++++++++++++++++++++++--- 1 file changed, 579 insertions(+), 59 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 54ba9d25c1..d1477a1513 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -40,9 +40,11 @@ use crate::bridge_auth::BridgeAuthority; use openfang_kernel::OpenFangKernel; use openfang_mcp_bridge::protocol::{ - codec, CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, - SOCKET_RELATIVE_PATH, + codec, CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, ListUpstreamRequest, + UpstreamListResponse, UpstreamListResult, UpstreamToolDef, MAX_FRAME_BYTES, + PROTOCOL_VERSION, SOCKET_RELATIVE_PATH, }; +use openfang_runtime::mcp::{extract_mcp_server_from_known, is_mcp_tool}; use openfang_types::agent::AgentId; use openfang_types::bridge_auth::Token; use std::path::{Path, PathBuf}; @@ -322,40 +324,60 @@ async fn handle_connection( Err(e) => return Err(e), }; - let call = match frame { - Frame::Call(c) => c, + match frame { + Frame::Call(call) => { + info!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %call.agent_id, + "bridge IPC: dispatching call" + ); + + let result = dispatch_call(&call, &kernel, identity.agent_id.as_ref()).await; + let result_kind = match &result { + CallResult::Ok { + is_error: false, .. + } => "ok", + CallResult::Ok { is_error: true, .. } => "tool_error", + CallResult::Error { .. } => "dispatch_error", + }; + info!( + request_id = call.request_id, + tool = %call.tool_name, + outcome = result_kind, + "bridge IPC: call complete" + ); + let response = Frame::Response(CallResponse { + request_id: call.request_id, + result, + }); + codec::write_frame(&mut write_half, &response).await?; + } + Frame::ListUpstream(req) => { + info!( + request_id = req.request_id, + "bridge IPC: dispatching list_upstream" + ); + let response = + handle_list_upstream(&req, &kernel, identity.agent_id.as_ref()).await; + let outcome = match &response.result { + UpstreamListResult::Ok { tools } => { + format!("ok({} tools)", tools.len()) + } + UpstreamListResult::Error { .. } => "error".to_string(), + }; + info!( + request_id = response.request_id, + outcome = %outcome, + "bridge IPC: list_upstream complete" + ); + codec::write_frame(&mut write_half, &Frame::UpstreamList(response)).await?; + } other => { warn!(?other, "bridge IPC: unexpected frame in request loop"); continue; } - }; - - info!( - request_id = call.request_id, - tool = %call.tool_name, - agent = %call.agent_id, - "bridge IPC: dispatching call" - ); - - let result = dispatch_call(&call, &kernel, identity.agent_id.as_ref()).await; - let result_kind = match &result { - CallResult::Ok { - is_error: false, .. - } => "ok", - CallResult::Ok { is_error: true, .. } => "tool_error", - CallResult::Error { .. } => "dispatch_error", - }; - info!( - request_id = call.request_id, - tool = %call.tool_name, - outcome = result_kind, - "bridge IPC: call complete" - ); - let response = Frame::Response(CallResponse { - request_id: call.request_id, - result, - }); - codec::write_frame(&mut write_half, &response).await?; + } } } @@ -378,6 +400,16 @@ async fn dispatch_call( kernel: &Arc, authenticated_agent_id: Option<&AgentId>, ) -> CallResult { + // --- Early branch: upstream MCP tool (mcp_*) --------------------------- + // Upstream MCP tools are not in `ALLOWED_TOOLS` and have a separate + // dispatch path: per-agent server allowlist (agent.toml `mcp_servers`, + // default-deny on empty), hardened-token lane only, and direct dispatch + // into `kernel.mcp_connections` rather than `execute_tool`. See + // `dispatch_upstream_mcp_call` for the gates. + if is_mcp_tool(&call.tool_name) { + return dispatch_upstream_mcp_call(call, kernel, authenticated_agent_id).await; + } + // --- Gate 1: static bridge-surface allowlist ---------------------------- // The hard ceiling on what the bridge will ever dispatch. Independent of // any agent's per-agent surface; an unknown tool never reaches identity @@ -589,6 +621,320 @@ async fn dispatch_call( } } +/// Dispatch an upstream MCP tool call (`mcp_{server}_{tool}`) over the +/// kernel's `mcp_connections` registry. +/// +/// Distinct from [`dispatch_call`]'s built-in tool path: +/// +/// - **Hardened-token lane only.** Refuses the legacy +/// self-claimed-`agent_id` lane. Upstream MCP servers can carry +/// secrets (OAuth tokens, page contents, Linear issue bodies) and +/// the legacy lane has no daemon-issued identity to authorize +/// against — fail closed. The hardened lane is the only entry to +/// this surface for v1. +/// +/// - **Per-agent server allowlist with default-deny.** Reads +/// `entry.manifest.mcp_servers` from the registry and rejects any +/// tool whose server prefix is not on the list. **Empty list means +/// no servers allowed** for the bridge path — this is a deliberate +/// semantic departure from the in-process MCP path +/// (`tool_runner.rs`), which historically treated `[]` as +/// "all servers". v1 ships the new semantic for the bridge only; +/// convergence is tracked as follow-up. See design doc §5.4. +/// +/// - **Direct dispatch into `kernel.mcp_connections`.** Bypasses +/// `execute_tool` entirely; the runtime's MCP routing already does +/// what we need, and routing through `execute_tool` would require +/// adding every namespaced tool to its allowlist parameter. +/// +/// Truncation: results larger than the response frame budget are +/// returned with `is_error=true` and an explicit truncation marker +/// rather than silently dropped. Per design doc §6 mitigation table. +async fn dispatch_upstream_mcp_call( + call: &CallRequest, + kernel: &Arc, + authenticated_agent_id: Option<&AgentId>, +) -> CallResult { + // Refuse the legacy lane outright. Upstream MCP forwarding is + // hardened-path-only in v1. + let authed = match authenticated_agent_id { + Some(a) => a, + None => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + claimed = %call.agent_id, + "bridge IPC: refusing upstream MCP call on legacy token lane" + ); + return CallResult::Error { + message: "upstream MCP tools require a daemon-issued (hex) auth token; legacy lane refused" + .to_string(), + }; + } + }; + + let resolved_agent_id = *authed; + let resolved_agent_id_string = resolved_agent_id.to_string(); + + // Log a mismatch but trust the authenticated identity, consistent + // with the built-in path in `dispatch_call`. + if resolved_agent_id_string != call.agent_id { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + claimed = %call.agent_id, + authenticated = %resolved_agent_id_string, + "bridge IPC (upstream MCP): claimed agent_id disagrees with authenticated identity; using authenticated identity" + ); + } + + let entry = match kernel.registry.get(resolved_agent_id) { + Some(e) => e, + None => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + "bridge IPC (upstream MCP): no registry entry for resolved agent" + ); + return CallResult::Error { + message: format!( + "agent '{resolved_agent_id_string}' has no registry entry; refusing upstream MCP call" + ), + }; + } + }; + + // Default-deny: empty `mcp_servers` → no upstream tools allowed. + // This is the bridge-path semantic; the in-process path's + // `[] = all` convention is left undisturbed for now (see design + // doc §5.4). + if entry.manifest.mcp_servers.is_empty() { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + "bridge IPC (upstream MCP): agent has no mcp_servers allowlist; refusing" + ); + return CallResult::Error { + message: format!( + "agent '{resolved_agent_id_string}' has no MCP servers allowlisted (set `mcp_servers` in agent.toml)" + ), + }; + } + + // Match the tool's server prefix against the allowlist. Use the + // `from_known` helper so server names containing hyphens + // (normalized to underscores in tool names) match correctly. + let allowlist: Vec<&str> = entry + .manifest + .mcp_servers + .iter() + .map(|s| s.as_str()) + .collect(); + let server_name = match extract_mcp_server_from_known(&call.tool_name, &allowlist) { + Some(name) => name.to_string(), + None => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + allowlist = ?entry.manifest.mcp_servers, + "bridge IPC (upstream MCP): tool's server prefix not in agent allowlist" + ); + return CallResult::Error { + message: format!( + "upstream MCP tool '{}' is not allowlisted for agent '{}' (allowed servers: {:?})", + call.tool_name, resolved_agent_id_string, entry.manifest.mcp_servers + ), + }; + } + }; + + // Dispatch into the kernel's MCP registry. Hold the lock just + // long enough to issue the call; rmcp's `call_tool` awaits a + // network round-trip, so this *does* serialize concurrent calls + // against the same connection set. That matches the in-process + // path in `tool_runner.rs` and is acceptable for v1 — Linear / + // Notion calls are not hot-path. Revisit when latency complaints + // arrive. + let result_text = { + let mut conns = kernel.mcp_connections.lock().await; + let conn = conns + .iter_mut() + .find(|c| c.name() == server_name); + let conn = match conn { + Some(c) => c, + None => { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + server = %server_name, + "bridge IPC (upstream MCP): server allowlisted but not connected" + ); + return CallResult::Error { + message: format!( + "MCP server '{server_name}' is allowlisted for agent '{resolved_agent_id_string}' but not currently connected" + ), + }; + } + }; + conn.call_tool(&call.tool_name, &call.args).await + }; + + match result_text { + Ok(content) => { + // Truncation: keep the framed response well under + // MAX_FRAME_BYTES so the JSON envelope (request_id, + // result tag, is_error, escapes) fits comfortably. + // Margin is conservative on purpose. + const CONTENT_BUDGET: usize = MAX_FRAME_BYTES.saturating_sub(16 * 1024); + if content.len() > CONTENT_BUDGET { + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + bytes = content.len(), + budget = CONTENT_BUDGET, + "bridge IPC (upstream MCP): truncating oversized result" + ); + let mut truncated: String = content.chars().take(CONTENT_BUDGET / 4).collect(); + truncated.push_str( + " + +[openfang: upstream MCP result truncated — exceeded bridge frame budget]", + ); + CallResult::Ok { + content: truncated, + is_error: true, + } + } else { + CallResult::Ok { + content, + is_error: false, + } + } + } + Err(e) => { + // Distinguish runtime tool errors from dispatch failures + // the same way the built-in path does: a tool that ran + // and reported error is `Ok { is_error: true }`; we + // can't easily tell the difference from rmcp's surface + // here, so we surface as `Ok { is_error: true }` to let + // the model see the message rather than killing the + // call frame. + warn!( + request_id = call.request_id, + tool = %call.tool_name, + agent = %resolved_agent_id_string, + error = %e, + "bridge IPC (upstream MCP): tool call failed" + ); + CallResult::Ok { + content: format!("upstream MCP tool call failed: {e}"), + is_error: true, + } + } + } +} + +/// Handle a `ListUpstream` request: enumerate the upstream MCP tools the +/// authenticated agent is allowed to invoke. +/// +/// Same security model as [`dispatch_upstream_mcp_call`]: +/// - Hardened-token lane only. +/// - Per-agent `mcp_servers` allowlist with default-deny. +/// - Empty allowlist → empty tool list (not an error; the bridge will +/// simply advertise no upstream tools to Claude Code). +async fn handle_list_upstream( + req: &ListUpstreamRequest, + kernel: &Arc, + authenticated_agent_id: Option<&AgentId>, +) -> UpstreamListResponse { + let authed = match authenticated_agent_id { + Some(a) => a, + None => { + warn!( + request_id = req.request_id, + "bridge IPC: refusing list_upstream on legacy token lane" + ); + return UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Error { + message: "upstream MCP listing requires a daemon-issued (hex) auth token; legacy lane refused" + .to_string(), + }, + }; + } + }; + + let resolved_agent_id = *authed; + let resolved_agent_id_string = resolved_agent_id.to_string(); + + let entry = match kernel.registry.get(resolved_agent_id) { + Some(e) => e, + None => { + warn!( + request_id = req.request_id, + agent = %resolved_agent_id_string, + "bridge IPC: list_upstream — no registry entry for resolved agent" + ); + return UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Error { + message: format!( + "agent '{resolved_agent_id_string}' has no registry entry" + ), + }, + }; + } + }; + + // Empty allowlist → empty list (advertise nothing). This is the + // natural representation of `mcp_servers = []` with the new + // default-deny semantic. + if entry.manifest.mcp_servers.is_empty() { + return UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Ok { tools: Vec::new() }, + }; + } + + let allowlist: std::collections::HashSet<&str> = entry + .manifest + .mcp_servers + .iter() + .map(|s| s.as_str()) + .collect(); + + let conns = kernel.mcp_connections.lock().await; + let mut tools: Vec = Vec::new(); + for conn in conns.iter() { + let server_name = conn.name(); + if !allowlist.contains(server_name) { + continue; + } + for tool in conn.tools() { + tools.push(UpstreamToolDef { + name: tool.name.clone(), + server: server_name.to_string(), + description: if tool.description.is_empty() { + None + } else { + Some(tool.description.clone()) + }, + input_schema: tool.input_schema.clone(), + }); + } + } + + UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Ok { tools }, + } +} + /// Authenticate the bridge's Hello against the daemon's [`BridgeAuthority`]. /// /// Decision tree: @@ -809,37 +1155,211 @@ mod tests { Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), Err(e) => return Err(e), }; - let call = match frame { - Frame::Call(c) => c, - _ => continue, - }; - - // Mirror production allowlist logic. - let result = if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { - CallResult::Error { - message: format!( - "tool '{}' not in bridge allowlist (permitted: {:?})", - call.tool_name, ALLOWED_TOOLS - ), + match frame { + Frame::Call(call) => { + // Mirror production allowlist + mcp_* early-branch logic. + let result = if openfang_runtime::mcp::is_mcp_tool(&call.tool_name) { + // Twin can't reach real mcp_connections; canned + // upstream-style ok lets list+invoke round-trip in tests. + CallResult::Ok { + content: format!("[test-twin canned upstream ok for {}]", call.tool_name), + is_error: false, + } + } else if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { + CallResult::Error { + message: format!( + "tool '{}' not in bridge allowlist (permitted: {:?})", + call.tool_name, ALLOWED_TOOLS + ), + } + } else { + // Canned Ok stand-in for `execute_tool` — kernel-free tests + // can't exercise the real dispatch path. + CallResult::Ok { + content: format!("[test-twin canned ok for {}]", call.tool_name), + is_error: false, + } + }; + + codec::write_frame( + &mut write_half, + &Frame::Response(CallResponse { + request_id: call.request_id, + result, + }), + ) + .await?; } - } else { - // Canned Ok stand-in for `execute_tool` — kernel-free tests - // can't exercise the real dispatch path. - CallResult::Ok { - content: format!("[test-twin canned ok for {}]", call.tool_name), - is_error: false, + Frame::ListUpstream(req) => { + // Canned upstream-tools list. Real handler walks + // `kernel.mcp_connections`; twin returns a fixed + // shape so wire round-trip can be exercised. + let response = UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Ok { + tools: vec![UpstreamToolDef { + name: "mcp_twinsrv_ping".to_string(), + server: "twinsrv".to_string(), + description: Some("canned twin tool".to_string()), + input_schema: serde_json::json!({"type": "object"}), + }], + }, + }; + codec::write_frame( + &mut write_half, + &Frame::UpstreamList(response), + ) + .await?; } - }; + _ => continue, + } + } + } - codec::write_frame( - &mut write_half, - &Frame::Response(CallResponse { - request_id: call.request_id, - result, - }), - ) - .await?; + #[tokio::test] + async fn ipc_list_upstream_roundtrip() { + // End-to-end: handshake, send ListUpstream, expect UpstreamList + // response with the twin's canned tool. Locks the wire shape of + // the new variants and ensures the request loop dispatches them + // alongside CallRequest without breaking the existing path. + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + let authority = BridgeAuthority::new(); + let agent_id = AgentId::new(); + let guard = authority.issue(agent_id); + let presented_token = guard.token().to_hex(); + + let server_authority = authority.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + handle_connection_test_twin(stream, server_authority) + .await + .unwrap(); + }); + + let mut client = ClientStream::connect(&sock).await.unwrap(); + let (cr, mut cw) = client.split(); + let mut cr = BufReader::new(cr); + + let hello = Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: presented_token, + bridge_version: "test".into(), + }); + codec::write_frame(&mut cw, &hello).await.unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::HelloAck(HelloAck::Ok { .. }) => {} + other => panic!("expected HelloAck::Ok, got {other:?}"), + } + + codec::write_frame( + &mut cw, + &Frame::ListUpstream(ListUpstreamRequest { request_id: 42 }), + ) + .await + .unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::UpstreamList(UpstreamListResponse { + request_id: 42, + result: UpstreamListResult::Ok { tools }, + }) => { + assert_eq!(tools.len(), 1, "twin advertises one canned tool"); + assert_eq!(tools[0].name, "mcp_twinsrv_ping"); + assert_eq!(tools[0].server, "twinsrv"); + } + other => panic!("unexpected response to ListUpstream: {other:?}"), + } + + // Confirm the request loop still handles a Call after a + // ListUpstream (no state corruption between message kinds). + codec::write_frame( + &mut cw, + &Frame::Call(CallRequest { + request_id: 43, + agent_id: agent_id.to_string(), + tool_name: "file_read".into(), + args: serde_json::json!({"path": "x"}), + }), + ) + .await + .unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::Response(CallResponse { + request_id: 43, + result: CallResult::Ok { is_error: false, .. }, + }) => {} + other => panic!("unexpected response after ListUpstream: {other:?}"), } + + drop(client); + server.await.unwrap(); + drop(guard); + assert_eq!(authority.live_spawn_count(), 0); + } + + #[tokio::test] + async fn ipc_mcp_call_through_twin_returns_canned_ok() { + // The twin's mcp_* branch returns a canned ok rather than the + // allowlist error, exercising the production early-branch shape: + // mcp_* tools bypass the static ALLOWED_TOOLS gate. + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + let authority = BridgeAuthority::new(); + let agent_id = AgentId::new(); + let guard = authority.issue(agent_id); + let presented_token = guard.token().to_hex(); + + let server_authority = authority.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + handle_connection_test_twin(stream, server_authority) + .await + .unwrap(); + }); + + let mut client = ClientStream::connect(&sock).await.unwrap(); + let (cr, mut cw) = client.split(); + let mut cr = BufReader::new(cr); + + let hello = Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: presented_token, + bridge_version: "test".into(), + }); + codec::write_frame(&mut cw, &hello).await.unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::HelloAck(HelloAck::Ok { .. }) => {} + other => panic!("expected HelloAck::Ok, got {other:?}"), + } + + codec::write_frame( + &mut cw, + &Frame::Call(CallRequest { + request_id: 7, + agent_id: agent_id.to_string(), + tool_name: "mcp_linear_getteams".into(), + args: serde_json::json!({}), + }), + ) + .await + .unwrap(); + match codec::read_frame(&mut cr).await.unwrap() { + Frame::Response(CallResponse { + request_id: 7, + result: CallResult::Ok { content, is_error: false }, + }) => { + assert!(content.contains("mcp_linear_getteams")); + } + other => panic!("unexpected response to mcp_* call: {other:?}"), + } + + drop(client); + server.await.unwrap(); + drop(guard); } #[test] From 81801918a7afbd730150cafff8dc6c00f53561f4 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 20 May 2026 00:01:53 -0700 Subject: [PATCH 054/105] feat(mcp-bridge): list_upstream handshake + mcp_* tool advertise & dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge-side plumbing for upstream MCP forwarding (commit #3 of 4). Lib: - `ToolDispatcher` gains a default `upstream_tools()` method returning the cached upstream MCP tool catalog. Backward-compatible default impl for existing dispatchers. - `Bridge::permitted_tools()` appends upstream tools to the built-in surface. Built-ins remain gated by `allowed_tools()` (`OPENFANG_BRIDGE_ALLOWED` / `DEFAULT_ALLOWED`); upstream tools are ungated here — server-side `mcp_servers` gating already ran when the daemon answered `ListUpstream`. - `Bridge::call_tool()` accepts a call if the tool is either a permitted built-in OR a `mcp_*` name that was advertised in the upstream cache. - Helper `upstream_def_to_tool()` converts the wire-format `UpstreamToolDef` to an rmcp `Tool`, with empty-object schema fallback for non-object input schemas. Bin: - `main()` performs a one-shot `ListUpstream` round-trip immediately after `handshake()`, while still owning the stream. Failures are logged and downgraded to "no upstream surface this session" — the bridge stays alive with built-ins only. - `list_upstream()` helper factored alongside `handshake()`. - `spawn_ipc_actor()` takes the cached `Vec` and stores it on `IpcDispatcher`. - `IpcDispatcher::call()` mirrors the bridge-side gate: allowed built-in OR advertised upstream `mcp_*` name. Anything else is refused locally with `NotPermitted` — early-exit hygiene; daemon remains the source of truth. Tests: - lib: `permitted_tools_appends_upstream_after_builtins`, `is_advertised_upstream_matches_only_advertised_names`. - bin: `list_upstream_handshake_and_mcp_dispatch` (end-to-end fake daemon → handshake → ListUpstream → mcp_* round-trip + unadvertised rejection), `list_upstream_propagates_error_result`. Verification: - `cargo check -p openfang-mcp-bridge` clean. - `cargo check -p openfang-api` clean (no trait-method exhaustiveness regressions on the daemon side). - `cargo test -p openfang-mcp-bridge --lib --bins` → 16/16 green (13 lib + 3 bin). No behavior change for agents without upstream MCP servers configured — they get `Ok { tools: vec![] }` from the daemon and the bridge continues exactly as before. --- crates/openfang-mcp-bridge/src/lib.rs | 159 ++++++++++++++-- crates/openfang-mcp-bridge/src/main.rs | 254 ++++++++++++++++++++++++- 2 files changed, 398 insertions(+), 15 deletions(-) diff --git a/crates/openfang-mcp-bridge/src/lib.rs b/crates/openfang-mcp-bridge/src/lib.rs index 4faf336417..8428ad669c 100644 --- a/crates/openfang-mcp-bridge/src/lib.rs +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -41,6 +41,8 @@ use std::sync::Arc; use rmcp::{model::*, service::RequestContext, ErrorData as McpError, ServerHandler}; +use crate::protocol::UpstreamToolDef; + /// Narrow seam between the bridge and the OpenFang runtime. /// /// The runtime (or, in the real topology, an IPC-backed adapter that talks @@ -66,6 +68,19 @@ pub trait ToolDispatcher: Send + Sync { /// it from `agent.toml`. fn allowed_tools(&self) -> Vec; + /// Forwarded upstream MCP tools the daemon told us this agent may + /// invoke (from `agent.toml mcp_servers`, resolved server-side at + /// list-upstream time). Default: none. + /// + /// Names are already namespaced (`mcp_{server}_{tool}`) and are + /// advertised by the bridge in addition to the built-in surface + /// without going through [`Self::allowed_tools`] — that field gates + /// the OpenFang built-in slice; upstream gating is handled + /// server-side by the daemon against the agent's MCP allowlist. + fn upstream_tools(&self) -> Vec { + Vec::new() + } + /// Invoke a tool by name with a JSON argument blob. The dispatcher is /// responsible for capability enforcement; the bridge MUST NOT assume the /// caller is trusted. @@ -475,14 +490,47 @@ impl Bridge { } /// Tools the bridge will both advertise and accept calls for, given the - /// dispatcher's allowed set. + /// dispatcher's allowed set, plus any daemon-forwarded upstream MCP tools. fn permitted_tools(&self) -> Vec { let allowed = self.dispatcher.allowed_tools(); - built_in_tools() + let mut tools: Vec = built_in_tools() .into_iter() .filter(|t| allowed.iter().any(|a| a.as_str() == t.name.as_ref())) - .collect() + .collect(); + // Append upstream MCP tools. NOT gated by `allowed_tools()` — + // server-side `agent.toml mcp_servers` gating already happened + // when the daemon answered `ListUpstream`. Name collisions with + // built-ins are refused at MCP discovery in the daemon, so we + // trust the names here. + for def in self.dispatcher.upstream_tools() { + tools.push(upstream_def_to_tool(def)); + } + tools } + + /// True if `name` is among the advertised upstream MCP tools. + fn is_advertised_upstream(&self, name: &str) -> bool { + self.dispatcher + .upstream_tools() + .into_iter() + .any(|t| t.name == name) + } +} + +/// Convert a daemon-forwarded upstream MCP tool definition into the +/// rmcp `Tool` shape advertised on the MCP wire. Description defaults +/// to an empty string when absent; input schema is taken verbatim, or +/// substituted with an empty object if the upstream sent a non-object. +fn upstream_def_to_tool(def: UpstreamToolDef) -> Tool { + let schema_map = match def.input_schema { + serde_json::Value::Object(m) => m, + _ => serde_json::Map::new(), + }; + Tool::new( + def.name, + def.description.unwrap_or_default(), + std::sync::Arc::new(schema_map), + ) } impl ServerHandler for Bridge { @@ -518,10 +566,21 @@ impl ServerHandler for Bridge { .map(serde_json::Value::Object) .unwrap_or(serde_json::Value::Null); - // Defense-in-depth: re-check the allowlist before crossing the seam. - // The dispatcher will enforce again; that's intentional. + // Defense-in-depth: re-check before crossing the seam. The + // dispatcher will enforce again; that's intentional. + // + // Two paths: + // - Built-in tools: must appear in `allowed_tools()` (the + // `OPENFANG_BRIDGE_ALLOWED` / `DEFAULT_ALLOWED` gate). + // - Upstream `mcp_*` tools: must have been advertised by + // the daemon at list-upstream time. The daemon also + // enforces `agent.toml mcp_servers` server-side on + // dispatch. let allowed = self.dispatcher.allowed_tools(); - if !allowed.iter().any(|a| a == tool_name) { + let is_builtin_allowed = allowed.iter().any(|a| a == tool_name); + let is_advertised_upstream = + tool_name.starts_with("mcp_") && self.is_advertised_upstream(tool_name); + if !is_builtin_allowed && !is_advertised_upstream { return Ok(CallToolResult::error(vec![Content::text(format!( "tool '{tool_name}' not permitted for this agent" ))])); @@ -563,9 +622,21 @@ mod tests { struct StubDispatcher { agent: String, allowed: Vec, + upstream: Vec, canned: DispatchOk, } + impl StubDispatcher { + fn new(agent: &str, allowed: Vec, canned: DispatchOk) -> Self { + Self { + agent: agent.into(), + allowed, + upstream: Vec::new(), + canned, + } + } + } + #[async_trait::async_trait] impl ToolDispatcher for StubDispatcher { fn agent_id(&self) -> &str { @@ -574,6 +645,9 @@ mod tests { fn allowed_tools(&self) -> Vec { self.allowed.clone() } + fn upstream_tools(&self) -> Vec { + self.upstream.clone() + } async fn call( &self, _tool_name: &str, @@ -615,16 +689,16 @@ mod tests { #[test] fn permitted_tools_intersects_with_dispatcher_allowed() { - let bridge = Bridge::new(Arc::new(StubDispatcher { - agent: "a".into(), + let bridge = Bridge::new(Arc::new(StubDispatcher::new( + "a", // Dispatcher permits only file_read of the built-in slice; // not_a_real_tool is unknown to the bridge and must be ignored. - allowed: vec!["file_read".into(), "not_a_real_tool".into()], - canned: DispatchOk { + vec!["file_read".into(), "not_a_real_tool".into()], + DispatchOk { content: String::new(), is_error: false, }, - })); + ))); let names: Vec = bridge .permitted_tools() .into_iter() @@ -632,4 +706,67 @@ mod tests { .collect(); assert_eq!(names, vec!["file_read".to_string()]); } + + #[test] + fn permitted_tools_appends_upstream_after_builtins() { + let mut stub = StubDispatcher::new( + "a", + vec!["file_read".into()], + DispatchOk { + content: String::new(), + is_error: false, + }, + ); + stub.upstream = vec![ + UpstreamToolDef { + name: "mcp_linear_getteams".into(), + server: "linear".into(), + description: Some("List teams".into()), + input_schema: serde_json::json!({"type":"object"}), + }, + UpstreamToolDef { + name: "mcp_notion_search".into(), + server: "notion".into(), + description: None, + input_schema: serde_json::json!({}), + }, + ]; + let bridge = Bridge::new(Arc::new(stub)); + let names: Vec = bridge + .permitted_tools() + .into_iter() + .map(|t| t.name.into_owned()) + .collect(); + // Built-in first, then upstream, preserving order. + assert_eq!( + names, + vec![ + "file_read".to_string(), + "mcp_linear_getteams".to_string(), + "mcp_notion_search".to_string(), + ], + ); + } + + #[test] + fn is_advertised_upstream_matches_only_advertised_names() { + let mut stub = StubDispatcher::new( + "a", + vec![], + DispatchOk { + content: String::new(), + is_error: false, + }, + ); + stub.upstream = vec![UpstreamToolDef { + name: "mcp_linear_getteams".into(), + server: "linear".into(), + description: None, + input_schema: serde_json::json!({}), + }]; + let bridge = Bridge::new(Arc::new(stub)); + assert!(bridge.is_advertised_upstream("mcp_linear_getteams")); + assert!(!bridge.is_advertised_upstream("mcp_linear_unknown")); + assert!(!bridge.is_advertised_upstream("file_read")); + } } diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 8fdf216745..3d3006ce80 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -57,8 +57,8 @@ use anyhow::{anyhow, bail, Context, Result}; #[cfg(unix)] use openfang_mcp_bridge::{ protocol::{ - codec, CallRequest, CallResult, Frame, Hello, HelloAck, PROTOCOL_VERSION, SOCKET_ENV_VAR, - TOKEN_ENV_VAR, + codec, CallRequest, CallResult, Frame, Hello, HelloAck, ListUpstreamRequest, + UpstreamListResult, UpstreamToolDef, PROTOCOL_VERSION, SOCKET_ENV_VAR, TOKEN_ENV_VAR, }, Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, DEFAULT_ALLOWED, }; @@ -126,8 +126,27 @@ async fn main() -> Result<()> { handshake(&mut stream, &token).await?; + // --- List upstream MCP tools (best-effort) --- + // + // Round-trip happens here, with sole ownership of the stream, so + // it is sequenced before the actor takes the read/write halves. + // A protocol-level error from the daemon (agent identity not + // resolvable, registry unavailable) is logged but does NOT abort + // the bridge: built-in tools still work; the agent simply sees + // no `mcp_*` surface this session. + let upstream_tools = list_upstream(&mut stream).await.unwrap_or_else(|e| { + tracing::warn!(error = %e, "list_upstream failed; bridge continues without upstream MCP surface"); + Vec::new() + }); + tracing::info!(count = upstream_tools.len(), "upstream MCP tools advertised"); + // --- Spawn IPC actor --- - let dispatcher = spawn_ipc_actor(stream, agent_id.clone(), allowed_tools.clone()); + let dispatcher = spawn_ipc_actor( + stream, + agent_id.clone(), + allowed_tools.clone(), + upstream_tools, + ); // --- Run MCP server over stdio --- let service = Bridge::new(Arc::new(dispatcher)) @@ -169,6 +188,36 @@ async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { } } +/// Bridge → daemon: ask for the per-agent upstream MCP tool list. +/// +/// Sent once, immediately after a successful handshake while the +/// caller still owns the stream end-to-end. Returns the advertised +/// upstream tools (possibly empty) on success, or an error that the +/// caller may downgrade to "no upstream surface this session". +#[cfg(unix)] +async fn list_upstream(stream: &mut UnixStream) -> Result> { + let (read_half, mut write_half) = stream.split(); + let mut read_half = BufReader::new(read_half); + + let req = Frame::ListUpstream(ListUpstreamRequest { request_id: 0 }); + codec::write_frame(&mut write_half, &req) + .await + .context("write ListUpstream")?; + + match codec::read_frame(&mut read_half) + .await + .context("read UpstreamList")? + { + Frame::UpstreamList(resp) => match resp.result { + UpstreamListResult::Ok { tools } => Ok(tools), + UpstreamListResult::Error { message } => { + Err(anyhow!("daemon refused list_upstream: {message}")) + } + }, + other => Err(anyhow!("expected UpstreamList, got {other:?}")), + } +} + /// One pending request: the slot the actor will fill when its response frame /// arrives over the wire. #[cfg(unix)] @@ -188,6 +237,11 @@ struct IpcRequest { pub struct IpcDispatcher { agent_id: String, allowed: Vec, + /// Cached snapshot of upstream MCP tools advertised by the daemon + /// at session start (one-shot `ListUpstream` round-trip). Refreshes + /// require restarting the bridge — by design for now, since the + /// daemon also restarts agents on `agent.toml mcp_servers` changes. + upstream: Vec, tx: mpsc::Sender, next_id: AtomicU64, } @@ -203,12 +257,27 @@ impl ToolDispatcher for IpcDispatcher { self.allowed.clone() } + fn upstream_tools(&self) -> Vec { + self.upstream.clone() + } + async fn call( &self, tool_name: &str, args: serde_json::Value, ) -> Result { - if !self.allowed.iter().any(|a| a == tool_name) { + // Two gates here: + // - Built-in tools must be in `OPENFANG_BRIDGE_ALLOWED` / + // `DEFAULT_ALLOWED`. + // - `mcp_*` upstream tools must have been advertised by the + // daemon at list-upstream time. Server-side `mcp_servers` + // gating in the daemon is the source of truth; this is + // just an early-exit hygiene check so we never ship a + // bogus `mcp_*` name across the wire. + let is_builtin_allowed = self.allowed.iter().any(|a| a == tool_name); + let is_advertised_upstream = tool_name.starts_with("mcp_") + && self.upstream.iter().any(|t| t.name == tool_name); + if !is_builtin_allowed && !is_advertised_upstream { return Err(ToolDispatchError::NotPermitted(tool_name.to_string())); } @@ -256,6 +325,7 @@ pub fn spawn_ipc_actor( stream: UnixStream, agent_id: String, allowed: Vec, + upstream: Vec, ) -> IpcDispatcher { let (tx, mut rx) = mpsc::channel::(32); let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); @@ -343,6 +413,7 @@ pub fn spawn_ipc_actor( IpcDispatcher { agent_id, allowed, + upstream, tx, next_id: AtomicU64::new(1), } @@ -445,6 +516,7 @@ mod tests { client, "agent-x".into(), vec!["file_read".into(), "file_list".into()], + Vec::new(), ); // Concurrent calls — exercise the correlation map. @@ -470,4 +542,178 @@ mod tests { let _ = server.await; } + + /// End-to-end list_upstream + mcp_* dispatch: + /// - fake daemon answers Hello/HelloAck, + /// - then responds to ListUpstream with a one-tool catalog, + /// - bridge dispatcher gets that cache, + /// - a `mcp_linear_getteams` call passes the bridge-side gate + /// (NOT in `allowed`) and round-trips back with the tool name. + /// - a `mcp_unknown_tool` call (not in upstream cache) is + /// rejected client-side with NotPermitted. + #[tokio::test] + async fn list_upstream_handshake_and_mcp_dispatch() { + use openfang_mcp_bridge::protocol::{ + CallResponse, UpstreamListResponse, UpstreamListResult, + }; + + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let (rh, mut wh) = stream.split(); + let mut rh = BufReader::new(rh); + // Hello. + match codec::read_frame(&mut rh).await.unwrap() { + Frame::Hello(_) => {} + _ => panic!("expected Hello"), + } + codec::write_frame( + &mut wh, + &Frame::HelloAck(HelloAck::Ok { + daemon_version: "test".into(), + }), + ) + .await + .unwrap(); + // ListUpstream. + let req = match codec::read_frame(&mut rh).await.unwrap() { + Frame::ListUpstream(r) => r, + other => panic!("expected ListUpstream, got {other:?}"), + }; + codec::write_frame( + &mut wh, + &Frame::UpstreamList(UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Ok { + tools: vec![UpstreamToolDef { + name: "mcp_linear_getteams".into(), + server: "linear".into(), + description: Some("List Linear teams".into()), + input_schema: serde_json::json!({"type":"object"}), + }], + }, + }), + ) + .await + .unwrap(); + // One mcp_* Call → echo tool_name back as Ok content. + let call = match codec::read_frame(&mut rh).await.unwrap() { + Frame::Call(c) => c, + other => panic!("expected Call, got {other:?}"), + }; + codec::write_frame( + &mut wh, + &Frame::Response(CallResponse { + request_id: call.request_id, + result: CallResult::Ok { + content: call.tool_name, + is_error: false, + }, + }), + ) + .await + .unwrap(); + }); + + let mut client = UnixStream::connect(&sock).await.unwrap(); + + // Inline handshake. + { + let (rh, mut wh) = client.split(); + let mut rh = BufReader::new(rh); + codec::write_frame( + &mut wh, + &Frame::Hello(Hello { + protocol_version: PROTOCOL_VERSION, + token: "t".into(), + bridge_version: "test".into(), + }), + ) + .await + .unwrap(); + match codec::read_frame(&mut rh).await.unwrap() { + Frame::HelloAck(HelloAck::Ok { .. }) => {} + other => panic!("bad ack: {other:?}"), + } + } + + // list_upstream round-trip via the production helper. + let upstream = list_upstream(&mut client).await.expect("list_upstream ok"); + assert_eq!(upstream.len(), 1); + assert_eq!(upstream[0].name, "mcp_linear_getteams"); + + let dispatcher = spawn_ipc_actor( + client, + "agent-x".into(), + // mcp_* is NOT in the built-in allowlist — only the + // upstream cache should grant it. + vec!["file_read".into()], + upstream, + ); + + // Allowed via upstream cache. + let ok = dispatcher + .call("mcp_linear_getteams", serde_json::json!({})) + .await + .expect("mcp dispatch ok"); + assert_eq!(ok.content, "mcp_linear_getteams"); + assert!(!ok.is_error); + + // Not in upstream cache, not in allowed — must be denied locally. + let denied = dispatcher + .call("mcp_linear_notallowed", serde_json::json!({})) + .await + .expect_err("unadvertised mcp_* tool must be denied"); + match denied { + ToolDispatchError::NotPermitted(t) => assert_eq!(t, "mcp_linear_notallowed"), + other => panic!("expected NotPermitted, got {other:?}"), + } + + let _ = server.await; + } + + /// list_upstream() surfaces protocol-layer errors as Err — the + /// production caller in main() downgrades these to "no upstream + /// surface this session" via unwrap_or_else. + #[tokio::test] + async fn list_upstream_propagates_error_result() { + use openfang_mcp_bridge::protocol::{UpstreamListResponse, UpstreamListResult}; + + let tmp = tempfile::tempdir().unwrap(); + let sock = tmp.path().join("bridge.sock"); + let listener = UnixListener::bind(&sock).unwrap(); + + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let (rh, mut wh) = stream.split(); + let mut rh = BufReader::new(rh); + // Skip Hello (the test doesn't perform it; client below + // sends ListUpstream directly). + let req = match codec::read_frame(&mut rh).await.unwrap() { + Frame::ListUpstream(r) => r, + other => panic!("expected ListUpstream, got {other:?}"), + }; + codec::write_frame( + &mut wh, + &Frame::UpstreamList(UpstreamListResponse { + request_id: req.request_id, + result: UpstreamListResult::Error { + message: "agent identity not resolvable".into(), + }, + }), + ) + .await + .unwrap(); + }); + + let mut client = UnixStream::connect(&sock).await.unwrap(); + let err = list_upstream(&mut client) + .await + .expect_err("Error result must propagate"); + assert!(err.to_string().contains("not resolvable")); + let _ = server.await; + } } From 52b4fba7bfd7165f7a2747fc22bd45a79c0b8875 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 20 May 2026 00:17:51 -0700 Subject: [PATCH 055/105] feat(runtime): MCP discovery collision check vs OpenFang built-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At `McpConnection::discover_tools`, refuse to register any upstream tool whose namespaced form (`mcp_{server}_{tool}`) shadows an OpenFang built-in, or duplicates a name already registered for the same server. Both skips emit a `WARN` trace and silently drop the tool. This is the final security gate from the upstream-forwarding design doc §5.2: built-ins always win over upstream MCP servers, so the bridge's blind trust of `mcp_*` names returned by the daemon is justified. Built-in shadowing is structurally impossible today (no built-in starts with `mcp_`), but enforcing this is defense-in-depth — adds zero risk, protects against any future built-in landing under `mcp_*`, and pins the invariant in a drift test. Changes: - `openfang-runtime::mcp`: - `RESERVED_BUILTIN_NAMES: &[&str]` — single-sourced list mirroring `openfang-api::bridge_ipc::ALLOWED_TOOLS`. - `is_reserved_builtin(name) -> bool` — gate helper. - `register_discovered_tool(...)` — extracted per-tool registration so the collision logic is unit-testable without a live MCP transport. Called from `discover_tools`. - `openfang-api::bridge_ipc`: - `reserved_builtin_names_matches_allowed_tools` drift test — asserts equality between `RESERVED_BUILTIN_NAMES` and `ALLOWED_TOOLS`. Adds to the existing drift detection suite that catches the analogous `built_in_tools()` ↔ `ALLOWED_TOOLS` drift. Tests: - `cargo test -p openfang-runtime --lib mcp::tests` → 11/11 (4 new: `reserved_builtin_names_includes_core_surface`, `register_discovered_tool_skips_builtin_shadow`, `register_discovered_tool_skips_duplicate_from_same_server`, `register_discovered_tool_drops_namespaced_collision_with_builtin`). - `cargo test -p openfang-api --lib bridge_ipc` → 18/18 (1 new drift test). - `cargo test -p openfang-mcp-bridge --lib --bins` → 16/16 (unchanged). - `cargo clippy -p openfang-runtime -p openfang-api -p openfang-mcp-bridge --tests -- -D warnings` ✅ --- crates/openfang-api/src/bridge_ipc.rs | 22 +++ crates/openfang-runtime/src/mcp.rs | 216 ++++++++++++++++++++++++-- 2 files changed, 222 insertions(+), 16 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index d1477a1513..cdac0bc8e2 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -1667,4 +1667,26 @@ mod tests { "legacy path has no fingerprint" ); } + + /// Drift detection: `RESERVED_BUILTIN_NAMES` (the collision-check list + /// used at MCP discovery in `openfang-runtime`) MUST equal + /// `ALLOWED_TOOLS` (this crate). If a new built-in tool is added to + /// `ALLOWED_TOOLS` and `built_in_tools()` but the reservation list + /// drifts, an upstream MCP server could shadow the new built-in. + /// + /// Owner contract: any addition to `ALLOWED_TOOLS` must also be added + /// to `openfang_runtime::mcp::RESERVED_BUILTIN_NAMES`. + #[test] + fn reserved_builtin_names_matches_allowed_tools() { + use openfang_runtime::mcp::RESERVED_BUILTIN_NAMES; + use std::collections::BTreeSet; + let allowed: BTreeSet<&str> = ALLOWED_TOOLS.iter().copied().collect(); + let reserved: BTreeSet<&str> = RESERVED_BUILTIN_NAMES.iter().copied().collect(); + assert_eq!( + allowed, reserved, + "drift: openfang_runtime::mcp::RESERVED_BUILTIN_NAMES ≠ ALLOWED_TOOLS. \ + Built-ins added to ALLOWED_TOOLS must also be added to \ + RESERVED_BUILTIN_NAMES so upstream MCP servers cannot shadow them." + ); + } } diff --git a/crates/openfang-runtime/src/mcp.rs b/crates/openfang-runtime/src/mcp.rs index 13f5902845..1b36798221 100644 --- a/crates/openfang-runtime/src/mcp.rs +++ b/crates/openfang-runtime/src/mcp.rs @@ -14,7 +14,7 @@ use rmcp::service::RunningService; use rmcp::{RoleClient, ServiceExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; // --------------------------------------------------------------------------- // Configuration types @@ -125,6 +125,11 @@ impl McpConnection { } /// Discover available tools via `tools/list`. + /// + /// Performs collision checks against [`RESERVED_BUILTIN_NAMES`] and + /// against names already discovered from this same server. Conflicting + /// upstream tools are dropped with a `WARN` log — built-ins win, no + /// silent shadowing. async fn discover_tools(&mut self) -> Result<(), String> { let tools = self .client @@ -132,26 +137,21 @@ impl McpConnection { .await .map_err(|e| format!("Failed to list MCP tools: {e}"))?; - let server_name = &self.config.name; + let server_name = self.config.name.clone(); for tool in &tools { - let raw_name = &tool.name; - let description = tool.description.as_deref().unwrap_or(""); - + let raw_name = tool.name.to_string(); + let description = tool.description.as_deref().unwrap_or("").to_string(); let input_schema = serde_json::to_value(&tool.input_schema) .unwrap_or(serde_json::json!({"type": "object"})); - // Namespace: mcp_{server}_{tool} - let namespaced = format_mcp_tool_name(server_name, raw_name); - - // Store original name so we can send it back to the server - self.original_names - .insert(namespaced.clone(), raw_name.to_string()); - - self.tools.push(ToolDefinition { - name: namespaced, - description: format!("[MCP:{server_name}] {description}"), + register_discovered_tool( + &server_name, + &raw_name, + &description, input_schema, - }); + &mut self.tools, + &mut self.original_names, + ); } Ok(()) @@ -339,6 +339,90 @@ impl McpConnection { // Tool namespacing helpers // --------------------------------------------------------------------------- +/// OpenFang built-in tool names that MCP upstream tools must not shadow. +/// +/// This list MUST stay in sync with +/// [`openfang_api::bridge_ipc::ALLOWED_TOOLS`] and +/// [`openfang_mcp_bridge::built_in_tools`]. A drift test in +/// `openfang-api::bridge_ipc::tests` asserts equality with `ALLOWED_TOOLS` +/// and will fail CI if these lists diverge. +/// +/// The MCP namespacing scheme (`mcp_{server}_{tool}`) makes structural +/// collisions impossible today (no built-in starts with `mcp_`), but +/// enforcing this check is defense-in-depth: future built-ins could be +/// added, and an upstream server returning a crafted name like +/// `file_read` (without `mcp_` prefix) is already disqualified by +/// namespacing — yet the namespaced form is what we ultimately gate, so +/// we check that. +pub const RESERVED_BUILTIN_NAMES: &[&str] = &[ + "file_read", + "file_list", + "file_write", + "create_directory", + "web_fetch", + "agent_list", + "channel_send", + "agent_send", + "agent_spawn", + "agent_kill", + "memory_store", + "memory_recall", + "agent_activate", + "agent_find", + "shell_exec", + "web_search", + "apply_patch", +]; + +/// True if `name` shadows an OpenFang built-in tool. +pub fn is_reserved_builtin(name: &str) -> bool { + RESERVED_BUILTIN_NAMES.contains(&name) +} + +/// Register a single discovered upstream tool, applying collision checks. +/// +/// Skips (with `WARN` log) if the namespaced name shadows an OpenFang +/// built-in or duplicates a tool already registered for this server. +/// Extracted from [`McpConnection::discover_tools`] so the gating logic +/// is unit-testable without standing up a live MCP transport. +pub(crate) fn register_discovered_tool( + server_name: &str, + raw_name: &str, + description: &str, + input_schema: serde_json::Value, + tools: &mut Vec, + original_names: &mut HashMap, +) { + let namespaced = format_mcp_tool_name(server_name, raw_name); + + if is_reserved_builtin(&namespaced) { + warn!( + server = %server_name, + tool = %raw_name, + namespaced = %namespaced, + "refusing to register MCP tool: shadows OpenFang built-in" + ); + return; + } + + if tools.iter().any(|t| t.name == namespaced) { + warn!( + server = %server_name, + tool = %raw_name, + namespaced = %namespaced, + "refusing to register MCP tool: duplicate namespaced name from same server" + ); + return; + } + + original_names.insert(namespaced.clone(), raw_name.to_string()); + tools.push(ToolDefinition { + name: namespaced, + description: format!("[MCP:{server_name}] {description}"), + input_schema, + }); +} + /// Format a namespaced MCP tool name: `mcp_{server}_{tool}`. pub fn format_mcp_tool_name(server: &str, tool: &str) -> String { format!("mcp_{}_{}", normalize_name(server), normalize_name(tool)) @@ -538,4 +622,104 @@ mod tests { _ => panic!("Expected Http transport"), } } + + #[test] + fn reserved_builtin_names_includes_core_surface() { + assert!(is_reserved_builtin("file_read")); + assert!(is_reserved_builtin("shell_exec")); + assert!(is_reserved_builtin("apply_patch")); + assert!(!is_reserved_builtin("mcp_linear_getteams")); + assert!(!is_reserved_builtin("")); + } + + #[test] + fn register_discovered_tool_skips_builtin_shadow() { + // An upstream server literally named "file" returning a "read" tool + // would produce the namespaced name `mcp_file_read`, which is NOT a + // built-in. But if the reserved list ever expanded to include the + // namespaced form, the gate must hold. Probe with a synthetic case + // by inserting a built-in name into the test against a server whose + // normalization yields exactly that name. + // + // The realistic threat is a future built-in collision. Simulate it + // by checking a name we know is reserved today. + let mut tools = Vec::new(); + let mut names = HashMap::new(); + + // Direct probe: pretend the server returned a tool whose namespaced + // form lands on a reserved name. We cheat by constructing one whose + // server+tool normalize there. Easiest: assert the gate function + // directly, since the namespaced form is what's gated. + for reserved in RESERVED_BUILTIN_NAMES { + assert!(is_reserved_builtin(reserved), "{reserved} should be reserved"); + } + + // Realistic register call against a non-colliding name succeeds. + register_discovered_tool( + "linear", + "getteams", + "list teams", + serde_json::json!({"type": "object"}), + &mut tools, + &mut names, + ); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "mcp_linear_getteams"); + assert_eq!(names.get("mcp_linear_getteams").map(String::as_str), Some("getteams")); + } + + #[test] + fn register_discovered_tool_skips_duplicate_from_same_server() { + // Two raw names from the same server can normalize to the same + // namespaced form when one contains a hyphen and the other an + // underscore (`get-teams` and `get_teams` both → `mcp_linear_get_teams`). + // The second registration must be dropped, not silently overwrite. + let mut tools = Vec::new(); + let mut names = HashMap::new(); + + register_discovered_tool( + "linear", + "get-teams", + "list teams", + serde_json::json!({"type": "object"}), + &mut tools, + &mut names, + ); + register_discovered_tool( + "linear", + "get_teams", + "list teams (dup)", + serde_json::json!({"type": "object"}), + &mut tools, + &mut names, + ); + + let count = tools + .iter() + .filter(|t| t.name == "mcp_linear_get_teams") + .count(); + assert_eq!(count, 1, "duplicate namespaced name must be dropped"); + // First registration wins — original name preserved as hyphenated form. + assert_eq!( + names.get("mcp_linear_get_teams").map(String::as_str), + Some("get-teams") + ); + } + + #[test] + fn register_discovered_tool_drops_namespaced_collision_with_builtin() { + // Synthetic: pretend "file_read" got added to RESERVED_BUILTIN_NAMES + // as the namespaced form. Today the namespaced form is + // `mcp_{server}_{tool}`, so a true builtin collision can only happen + // if a future built-in lives under `mcp_*`. We assert the gate + // refuses any name that IS in the reserved list by constructing a + // server name "" and tool name "file_read" — which normalize to + // `mcp__file_read` (not reserved). So this test instead validates + // the gate's structural correctness: every reserved name causes + // `is_reserved_builtin` to return true, full stop. + for r in RESERVED_BUILTIN_NAMES { + assert!(is_reserved_builtin(r)); + assert!(!is_reserved_builtin(&format!("mcp_x_{}", r))); + } + } } From 8afe35a5a5997fe7f16c34304fbcae53cd76316b Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 20 May 2026 20:19:09 -0700 Subject: [PATCH 056/105] style(fmt): cargo fmt --all on upstream-forwarding crates --- crates/openfang-api/src/bridge_ipc.rs | 33 +++++++++++++------------- crates/openfang-mcp-bridge/src/main.rs | 9 ++++--- crates/openfang-runtime/src/mcp.rs | 10 ++++++-- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index cdac0bc8e2..9279e1ddfb 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -41,8 +41,8 @@ use crate::bridge_auth::BridgeAuthority; use openfang_kernel::OpenFangKernel; use openfang_mcp_bridge::protocol::{ codec, CallRequest, CallResponse, CallResult, Frame, Hello, HelloAck, ListUpstreamRequest, - UpstreamListResponse, UpstreamListResult, UpstreamToolDef, MAX_FRAME_BYTES, - PROTOCOL_VERSION, SOCKET_RELATIVE_PATH, + UpstreamListResponse, UpstreamListResult, UpstreamToolDef, MAX_FRAME_BYTES, PROTOCOL_VERSION, + SOCKET_RELATIVE_PATH, }; use openfang_runtime::mcp::{extract_mcp_server_from_known, is_mcp_tool}; use openfang_types::agent::AgentId; @@ -760,9 +760,7 @@ async fn dispatch_upstream_mcp_call( // arrive. let result_text = { let mut conns = kernel.mcp_connections.lock().await; - let conn = conns - .iter_mut() - .find(|c| c.name() == server_name); + let conn = conns.iter_mut().find(|c| c.name() == server_name); let conn = match conn { Some(c) => c, None => { @@ -883,9 +881,7 @@ async fn handle_list_upstream( return UpstreamListResponse { request_id: req.request_id, result: UpstreamListResult::Error { - message: format!( - "agent '{resolved_agent_id_string}' has no registry entry" - ), + message: format!("agent '{resolved_agent_id_string}' has no registry entry"), }, }; } @@ -1162,7 +1158,10 @@ mod tests { // Twin can't reach real mcp_connections; canned // upstream-style ok lets list+invoke round-trip in tests. CallResult::Ok { - content: format!("[test-twin canned upstream ok for {}]", call.tool_name), + content: format!( + "[test-twin canned upstream ok for {}]", + call.tool_name + ), is_error: false, } } else if !ALLOWED_TOOLS.iter().any(|t| *t == call.tool_name) { @@ -1205,11 +1204,7 @@ mod tests { }], }, }; - codec::write_frame( - &mut write_half, - &Frame::UpstreamList(response), - ) - .await?; + codec::write_frame(&mut write_half, &Frame::UpstreamList(response)).await?; } _ => continue, } @@ -1288,7 +1283,9 @@ mod tests { match codec::read_frame(&mut cr).await.unwrap() { Frame::Response(CallResponse { request_id: 43, - result: CallResult::Ok { is_error: false, .. }, + result: CallResult::Ok { + is_error: false, .. + }, }) => {} other => panic!("unexpected response after ListUpstream: {other:?}"), } @@ -1350,7 +1347,11 @@ mod tests { match codec::read_frame(&mut cr).await.unwrap() { Frame::Response(CallResponse { request_id: 7, - result: CallResult::Ok { content, is_error: false }, + result: + CallResult::Ok { + content, + is_error: false, + }, }) => { assert!(content.contains("mcp_linear_getteams")); } diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 3d3006ce80..0e049a0822 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -138,7 +138,10 @@ async fn main() -> Result<()> { tracing::warn!(error = %e, "list_upstream failed; bridge continues without upstream MCP surface"); Vec::new() }); - tracing::info!(count = upstream_tools.len(), "upstream MCP tools advertised"); + tracing::info!( + count = upstream_tools.len(), + "upstream MCP tools advertised" + ); // --- Spawn IPC actor --- let dispatcher = spawn_ipc_actor( @@ -275,8 +278,8 @@ impl ToolDispatcher for IpcDispatcher { // just an early-exit hygiene check so we never ship a // bogus `mcp_*` name across the wire. let is_builtin_allowed = self.allowed.iter().any(|a| a == tool_name); - let is_advertised_upstream = tool_name.starts_with("mcp_") - && self.upstream.iter().any(|t| t.name == tool_name); + let is_advertised_upstream = + tool_name.starts_with("mcp_") && self.upstream.iter().any(|t| t.name == tool_name); if !is_builtin_allowed && !is_advertised_upstream { return Err(ToolDispatchError::NotPermitted(tool_name.to_string())); } diff --git a/crates/openfang-runtime/src/mcp.rs b/crates/openfang-runtime/src/mcp.rs index 1b36798221..3afe17e8f1 100644 --- a/crates/openfang-runtime/src/mcp.rs +++ b/crates/openfang-runtime/src/mcp.rs @@ -651,7 +651,10 @@ mod tests { // server+tool normalize there. Easiest: assert the gate function // directly, since the namespaced form is what's gated. for reserved in RESERVED_BUILTIN_NAMES { - assert!(is_reserved_builtin(reserved), "{reserved} should be reserved"); + assert!( + is_reserved_builtin(reserved), + "{reserved} should be reserved" + ); } // Realistic register call against a non-colliding name succeeds. @@ -665,7 +668,10 @@ mod tests { ); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "mcp_linear_getteams"); - assert_eq!(names.get("mcp_linear_getteams").map(String::as_str), Some("getteams")); + assert_eq!( + names.get("mcp_linear_getteams").map(String::as_str), + Some("getteams") + ); } #[test] From 71639c00c2ab3e4e0e51ceb390ef59f33a7de74f Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 20 May 2026 23:36:12 -0700 Subject: [PATCH 057/105] fix(sandbox): hard-deny ~/.mcp-auth/ regardless of workspace_root (MCP-02) Adds is_sensitive_home_path() check in resolve_sandbox_path so MCP OAuth token caches under ~/.mcp-auth/ cannot be read even from agents whose workspace_root would otherwise resolve under $HOME. Same threat model as the prior commit covering ~/.ssh, ~/.aws, etc. Findings ref: _shared/openfang/findings/mcp-forwarding/ MCP-02 (HIGH). --- .../openfang-runtime/src/workspace_sandbox.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/openfang-runtime/src/workspace_sandbox.rs b/crates/openfang-runtime/src/workspace_sandbox.rs index ee1300f83e..2484e3082c 100644 --- a/crates/openfang-runtime/src/workspace_sandbox.rs +++ b/crates/openfang-runtime/src/workspace_sandbox.rs @@ -10,6 +10,12 @@ //! misconfigured agent manifest (e.g. `workspace_root = "~/.openfang"`) or a //! future tool surface that bypasses workspace confinement cannot exfiltrate //! these files. +//! +//! A second deny region covers sensitive paths under the user's `$HOME` but +//! outside `$OPENFANG_HOME` — notably `~/.mcp-auth/`, which holds OAuth +//! tokens for `mcp-remote` and other MCP clients. Same threat model: a +//! misconfigured `workspace_root` (e.g. `~`) or a bypass of workspace +//! confinement must not be able to read those tokens. use std::path::{Path, PathBuf}; @@ -70,6 +76,31 @@ pub(crate) fn is_sensitive_openfang_path(path: &Path) -> Option<&'static str> { } } +/// Returns `Some(reason)` if `path` resolves to a sensitive directory under +/// the user's `$HOME` but outside `$OPENFANG_HOME` that must never be read +/// or written by an agent, regardless of `workspace_root`. +/// +/// Companion to [`is_sensitive_openfang_path`] for credentials that live +/// outside `$OPENFANG_HOME`. As of writing the only entry is `~/.mcp-auth/` +/// (OAuth tokens used by `mcp-remote` and similar MCP clients). Add new +/// first-component patterns conservatively: this fires for *any* agent on +/// *any* workspace, including misconfigured ones, so over-denial here costs +/// more than under-denial elsewhere. +/// +/// `path` should be canonicalized before this check. +pub(crate) fn is_sensitive_home_path(path: &Path) -> Option<&'static str> { + let home = std::env::var_os("HOME").map(PathBuf::from)?; + let canon_home = home.canonicalize().ok()?; + let rel = path.strip_prefix(&canon_home).ok()?; + let first = rel.components().next()?.as_os_str().to_str()?; + + match first { + // OAuth tokens for mcp-remote and similar MCP clients. + ".mcp-auth" => Some("oauth-tokens"), + _ => None, + } +} + /// Resolve a user-supplied path within a workspace sandbox. /// /// - Rejects `..` components outright. @@ -137,6 +168,24 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result Date: Wed, 20 May 2026 23:36:20 -0700 Subject: [PATCH 058/105] feat(bridge): list_upstream timeout, drift test, audit comment (MCP-06/08/09) MCP-06 (MED): wrap dispatch_upstream_list_tools round-trip in tokio::time::timeout (LIST_UPSTREAM_TIMEOUT = 15s). Slow upstream MCP servers previously could hang every new bridge session at startup. On timeout, downgrades to an empty upstream surface (same shape as the existing error path). MCP-08 (LOW): adds no_builtin_uses_mcp_prefix drift test in bridge_ipc.rs to lock the dispatch-ordering invariant that builtin tools never collide with the mcp__ namespace. MCP-09 (INFO): documents the UTF-8 4-byte assumption on the truncation budget in bridge_ipc.rs. Findings ref: _shared/openfang/findings/mcp-forwarding/. --- crates/openfang-api/src/bridge_ipc.rs | 23 +++++++++++++++++++++++ crates/openfang-mcp-bridge/src/main.rs | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/crates/openfang-api/src/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs index 9279e1ddfb..19ec85c882 100644 --- a/crates/openfang-api/src/bridge_ipc.rs +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -797,6 +797,10 @@ async fn dispatch_upstream_mcp_call( budget = CONTENT_BUDGET, "bridge IPC (upstream MCP): truncating oversized result" ); + // UTF-8 invariant: each `char` encodes to ≤ 4 bytes, so + // `chars().take(N)` produces a `String` of ≤ 4N bytes. + // Taking `CONTENT_BUDGET / 4` chars therefore yields a + // string of ≤ CONTENT_BUDGET bytes, fitting the frame. let mut truncated: String = content.chars().take(CONTENT_BUDGET / 4).collect(); truncated.push_str( " @@ -1690,4 +1694,23 @@ mod tests { RESERVED_BUILTIN_NAMES so upstream MCP servers cannot shadow them." ); } + /// Drift catcher: no built-in tool may use the `mcp_` prefix. + /// + /// The dispatch gate `is_mcp_tool(name) = name.starts_with("mcp_")` + /// in `openfang_runtime::mcp` short-circuits BEFORE the static + /// `ALLOWED_TOOLS` check at the top of `dispatch_tool_call`. If a + /// future built-in were named `mcp_*`, calls to it would route to + /// `dispatch_upstream_mcp_call` instead of the built-in's real + /// handler. Structurally impossible today; this test locks the + /// invariant so a future addition doesn't silently subvert dispatch. + #[test] + fn no_builtin_uses_mcp_prefix() { + for name in ALLOWED_TOOLS { + assert!( + !name.starts_with("mcp_"), + "built-in '{name}' uses 'mcp_' prefix; conflicts with \ + is_mcp_tool dispatch gate in openfang_runtime::mcp" + ); + } + } } diff --git a/crates/openfang-mcp-bridge/src/main.rs b/crates/openfang-mcp-bridge/src/main.rs index 0e049a0822..e315c96f6d 100644 --- a/crates/openfang-mcp-bridge/src/main.rs +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -191,14 +191,32 @@ async fn handshake(stream: &mut UnixStream, token: &str) -> Result<()> { } } +/// Maximum time to wait for the per-agent upstream MCP tool list from +/// the daemon. Bounds bridge startup so a wedged daemon or a slow +/// upstream MCP server (`mcp_connections` lock held during a slow +/// `tools/list`) cannot stall every new bridge session indefinitely. +/// On timeout the caller downgrades to "no upstream surface this +/// session" — same failure mode as a protocol-level refusal. +#[cfg(unix)] +const LIST_UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + /// Bridge → daemon: ask for the per-agent upstream MCP tool list. /// /// Sent once, immediately after a successful handshake while the /// caller still owns the stream end-to-end. Returns the advertised /// upstream tools (possibly empty) on success, or an error that the /// caller may downgrade to "no upstream surface this session". +/// +/// Bounded by [`LIST_UPSTREAM_TIMEOUT`]. #[cfg(unix)] async fn list_upstream(stream: &mut UnixStream) -> Result> { + tokio::time::timeout(LIST_UPSTREAM_TIMEOUT, list_upstream_inner(stream)) + .await + .map_err(|_| anyhow!("list_upstream timed out after {:?}", LIST_UPSTREAM_TIMEOUT))? +} + +#[cfg(unix)] +async fn list_upstream_inner(stream: &mut UnixStream) -> Result> { let (read_half, mut write_half) = stream.split(); let mut read_half = BufReader::new(read_half); From 3eec7834f0edeca6ae7053e5e0e06a579cf68184 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Tue, 12 May 2026 16:55:25 -0700 Subject: [PATCH 059/105] feat(channels): outbound file attachment parser + image_cache filename hints Squashed from three commits on the original feat/outbound-file-attach branch: - channels/bridge: outbound attachment parser () - image_cache: retain inbound filename hint in materialized tmpfile names - image_cache: open writable for set_modified (Windows fix) Stacks on feat/runtime-image-cache (PR #1151), consuming source_url on ContentBlock::Image to round-trip Discord attachments outbound. --- Cargo.lock | 2 + crates/openfang-channels/Cargo.toml | 1 + crates/openfang-channels/src/bridge.rs | 32 +- crates/openfang-channels/src/lib.rs | 1 + .../openfang-channels/src/outbound_attach.rs | 536 ++++++++++++++++++ .../src/drivers/claude_code.rs | 62 +- crates/openfang-runtime/src/image_cache.rs | 200 ++++++- 7 files changed, 817 insertions(+), 17 deletions(-) create mode 100644 crates/openfang-channels/src/outbound_attach.rs diff --git a/Cargo.lock b/Cargo.lock index 72977c0132..99be16e1f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4008,6 +4008,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "cbc", "chrono", "dashmap", @@ -4029,6 +4030,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tempfile", "tokio", "tokio-stream", "tokio-test", diff --git a/crates/openfang-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index e0ef71d551..156255b6e9 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -42,3 +42,4 @@ rumqttc = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 9cff60dd10..4381ad64f6 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -601,12 +601,38 @@ async fn send_response( thread_id: Option<&str>, output_format: OutputFormat, ) { + // Parse `` markers BEFORE formatting — channel + // formatters (telegram HTML, slack mrkdwn) escape `<` and would break + // marker detection downstream. + let (text_to_format, attachment_blocks): (String, Vec) = + match crate::outbound_attach::parse(&text, None).await { + crate::outbound_attach::Parsed::NoMarkers => (text, Vec::new()), + crate::outbound_attach::Parsed::WithAttachments { + stripped_text, + files, + } => (stripped_text, files), + }; + let formatted = if adapter.name() == "wecom" { - formatter::format_for_wecom(&text, output_format) + formatter::format_for_wecom(&text_to_format, output_format) + } else { + formatter::format_for_channel(&text_to_format, output_format) + }; + + let content = if attachment_blocks.is_empty() { + ChannelContent::Text(formatted) } else { - formatter::format_for_channel(&text, output_format) + let mut blocks = Vec::with_capacity(attachment_blocks.len() + 1); + if !formatted.trim().is_empty() { + blocks.push(ChannelContent::Text(formatted)); + } + blocks.extend(attachment_blocks); + if blocks.len() == 1 { + blocks.remove(0) + } else { + ChannelContent::Multipart(blocks) + } }; - let content = ChannelContent::Text(formatted); let result = if let Some(tid) = thread_id { adapter.send_in_thread(user, content, tid).await diff --git a/crates/openfang-channels/src/lib.rs b/crates/openfang-channels/src/lib.rs index 7b122d2a24..8deb77f6ce 100644 --- a/crates/openfang-channels/src/lib.rs +++ b/crates/openfang-channels/src/lib.rs @@ -8,6 +8,7 @@ pub mod discord; pub mod email; pub mod formatter; pub mod google_chat; +pub mod outbound_attach; pub mod irc; pub mod matrix; pub mod mattermost; diff --git a/crates/openfang-channels/src/outbound_attach.rs b/crates/openfang-channels/src/outbound_attach.rs new file mode 100644 index 0000000000..002527a600 --- /dev/null +++ b/crates/openfang-channels/src/outbound_attach.rs @@ -0,0 +1,536 @@ +//! Outbound attachment parser. +//! +//! Recognises `` markers in agent response text, validates each path +//! against an allow-root, reads the bytes, and produces +//! `ChannelContent::FileData` blocks that the wire layer (`discord::send`, +//! `telegram::send`, …) already knows how to chunk and upload. +//! +//! ## Marker syntax +//! +//! ```text +//! +//! +//! +//! ``` +//! +//! All attribute values use double quotes. The marker is self-closing. +//! Multiple markers per response are supported up to Discord's 10-attachment +//! per-message cap; the wire-layer chunker handles aggregate-size splitting. +//! +//! ## Security +//! +//! Paths are canonicalised (so symlinks are resolved) and must lie under +//! one of the allow-roots — by default `$HOME/.openfang/`. This covers the +//! ephemeral `~/.openfang/tmp/` scratch area and per-agent +//! `~/.openfang/workspaces//` directories without leaking access to +//! the rest of the filesystem in the face of a prompt-injected agent. +//! +//! ## Failure mode +//! +//! Per-directive errors (path missing, outside allow-root, oversized) are +//! logged at WARN and the marker is silently dropped from the outgoing +//! message — partial success rather than failing the whole reply. If every +//! directive fails the caller still gets the stripped text back, so the +//! user sees the prose without the broken markers. + +use crate::types::ChannelContent; +use regex_lite::Regex; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use tracing::warn; + +/// Per-attachment hard cap. Discord allows 25 MiB per request on the free +/// tier; we cap each file at 25 MiB and rely on the wire-layer chunker +/// (24 MiB aggregate, 10 attachments per chunk in `discord::send`) to split +/// large multi-file responses across several messages. +const MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; + +/// Hard cap on directives parsed from a single response. Discord refuses +/// more than 10 attachments per message; the chunker bucket-splits but +/// there's no point parsing further. +const MAX_ATTACHMENTS_PER_MESSAGE: usize = 10; + +/// Outcome of parsing an outbound response. +pub enum Parsed { + /// No `` marker present. Caller should take the + /// normal text-only path. + NoMarkers, + /// At least one marker was found. `stripped_text` is the original text + /// with all markers removed and any `caption=` values appended. `files` + /// is the resolved `FileData` blocks (possibly empty if every directive + /// failed validation). + WithAttachments { + stripped_text: String, + files: Vec, + }, +} + +fn marker_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"]*?)/>"#).expect("marker regex compiles") + }) +} + +fn attr_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r#"(\w+)\s*=\s*"([^"]*)""#).expect("attr regex compiles")) +} + +#[derive(Debug)] +struct AttachDirective { + path: String, + name: Option, + spoiler: bool, + caption: Option, +} + +fn parse_directive(attrs: &str) -> Option { + let mut path = None; + let mut name = None; + let mut spoiler = false; + let mut caption = None; + for cap in attr_regex().captures_iter(attrs) { + let key = cap.get(1)?.as_str(); + let val = cap.get(2)?.as_str().to_string(); + match key { + "path" => path = Some(val), + "name" => name = Some(val), + "spoiler" => spoiler = matches!(val.as_str(), "true" | "1" | "yes"), + "caption" => caption = Some(val), + _ => {} + } + } + Some(AttachDirective { + path: path?, + name, + spoiler, + caption, + }) +} + +/// Extension → MIME type. Mirrors the table used by `tool_runner` for +/// `channel_send`'s `file_path` parameter so inbound and outbound paths +/// agree on the wire-format. Unknown extensions fall back to +/// `application/octet-stream`. +fn mime_from_extension(path: &Path) -> &'static str { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "pdf" => "application/pdf", + "txt" | "md" | "log" => "text/plain", + "csv" => "text/csv", + "json" => "application/json", + "xml" => "application/xml", + "zip" => "application/zip", + "gz" | "gzip" => "application/gzip", + "tar" => "application/x-tar", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "mp4" => "video/mp4", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + _ => "application/octet-stream", + } +} + +/// Default allow-root: canonicalised `$HOME/.openfang/`. Returns an empty +/// vec if `HOME` is unset or the directory does not exist (in which case +/// every directive will be rejected — fail-closed). +fn default_allow_roots() -> Vec { + let mut roots = Vec::new(); + if let Some(home) = std::env::var_os("HOME") { + let mut p = PathBuf::from(home); + p.push(".openfang"); + if let Ok(canon) = std::fs::canonicalize(&p) { + roots.push(canon); + } + } + roots +} + +async fn resolve_directive( + d: &AttachDirective, + allow_roots: &[PathBuf], +) -> Result { + let raw = PathBuf::from(&d.path); + if !raw.is_absolute() { + return Err(format!("path must be absolute: {}", d.path)); + } + let canon = tokio::fs::canonicalize(&raw) + .await + .map_err(|e| format!("canonicalize {}: {e}", raw.display()))?; + if !allow_roots.iter().any(|r| canon.starts_with(r)) { + return Err(format!("path {} outside allow-roots", canon.display())); + } + let metadata = tokio::fs::metadata(&canon) + .await + .map_err(|e| format!("stat {}: {e}", canon.display()))?; + if !metadata.is_file() { + return Err(format!("not a regular file: {}", canon.display())); + } + if metadata.len() > MAX_FILE_BYTES { + return Err(format!( + "{} exceeds {} byte cap (size {})", + canon.display(), + MAX_FILE_BYTES, + metadata.len() + )); + } + let data = tokio::fs::read(&canon) + .await + .map_err(|e| format!("read {}: {e}", canon.display()))?; + let basename = canon + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let mut filename = d.name.clone().unwrap_or(basename); + if d.spoiler && !filename.starts_with("SPOILER_") { + // Discord's `SPOILER_` filename prefix flags the attachment as a + // spoiler. Other adapters ignore the prefix harmlessly. + filename = format!("SPOILER_{}", filename); + } + let mime_type = mime_from_extension(&canon).to_string(); + Ok(ChannelContent::FileData { + data, + filename, + mime_type, + }) +} + +/// Parse `text`, resolve every `` marker against +/// `allow_roots_override` (or the default `$HOME/.openfang/` root if +/// `None`), and return either `NoMarkers` or `WithAttachments`. +/// +/// The returned `stripped_text` is the original with markers removed and +/// `caption` attribute values appended (each on its own line, in +/// document order). The caller is responsible for running the channel +/// formatter over `stripped_text` — formatting *before* parsing would +/// HTML-escape `<` in markers and break detection. +pub async fn parse(text: &str, allow_roots_override: Option<&[PathBuf]>) -> Parsed { + let re = marker_regex(); + if !re.is_match(text) { + return Parsed::NoMarkers; + } + let owned_default; + let allow_roots: &[PathBuf] = match allow_roots_override { + Some(r) => r, + None => { + owned_default = default_allow_roots(); + &owned_default + } + }; + + let mut stripped = String::with_capacity(text.len()); + let mut last = 0; + let mut directives: Vec = Vec::new(); + let mut captions: Vec = Vec::new(); + + for cap in re.captures_iter(text) { + let m = cap.get(0).unwrap(); + let attrs = cap.get(1).map(|m| m.as_str()).unwrap_or(""); + stripped.push_str(&text[last..m.start()]); + match parse_directive(attrs) { + Some(d) => { + if directives.len() >= MAX_ATTACHMENTS_PER_MESSAGE { + warn!( + "outbound_attach: dropping marker beyond {} attachments cap", + MAX_ATTACHMENTS_PER_MESSAGE + ); + // Keep the marker visible — the agent should see it + // wasn't honoured. + stripped.push_str(m.as_str()); + } else { + if let Some(c) = &d.caption { + captions.push(c.clone()); + } + directives.push(d); + } + } + None => { + // Malformed marker — leave it in place for debuggability. + stripped.push_str(m.as_str()); + } + } + last = m.end(); + } + stripped.push_str(&text[last..]); + + // Append captions on their own lines. + let mut stripped_text = stripped.trim_end().to_string(); + for c in &captions { + if !stripped_text.is_empty() { + stripped_text.push('\n'); + } + stripped_text.push_str(c); + } + + let mut files: Vec = Vec::with_capacity(directives.len()); + for d in &directives { + match resolve_directive(d, allow_roots).await { + Ok(block) => files.push(block), + Err(e) => { + warn!("outbound_attach: skipping {}: {}", d.path, e); + } + } + } + + Parsed::WithAttachments { + stripped_text, + files, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture_root() -> (tempfile::TempDir, Vec) { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = std::fs::canonicalize(tmp.path()).expect("canonicalize tmp"); + (tmp, vec![root]) + } + + #[tokio::test] + async fn no_markers_returns_no_markers() { + let result = parse("just some prose, no markers here", None).await; + assert!(matches!(result, Parsed::NoMarkers)); + } + + #[tokio::test] + async fn single_marker_resolves_to_filedata() { + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("hello.txt"); + std::fs::write(&path, b"hi").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!( + "Here you go: done.", + canon.display() + ); + + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + } => { + assert_eq!(stripped_text, "Here you go: done."); + assert_eq!(files.len(), 1); + match &files[0] { + ChannelContent::FileData { + data, + filename, + mime_type, + } => { + assert_eq!(data, b"hi"); + assert_eq!(filename, "hello.txt"); + assert_eq!(mime_type, "text/plain"); + } + _ => panic!("expected FileData"), + } + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn caption_attribute_is_appended_to_text() { + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("note.pdf"); + std::fs::write(&path, b"%PDF-1.4 stub").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!( + "", + canon.display() + ); + + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + } => { + assert_eq!(stripped_text, "for the meeting"); + assert_eq!(files.len(), 1); + match &files[0] { + ChannelContent::FileData { + filename, + mime_type, + .. + } => { + assert_eq!(filename, "note.pdf"); + assert_eq!(mime_type, "application/pdf"); + } + _ => panic!("expected FileData"), + } + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn spoiler_prefixes_filename() { + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("secret.png"); + std::fs::write(&path, b"\x89PNG").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!( + "", + canon.display() + ); + + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { files, .. } => { + match &files[0] { + ChannelContent::FileData { filename, .. } => { + assert_eq!(filename, "SPOILER_secret.png"); + } + _ => panic!("expected FileData"), + } + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn name_attribute_overrides_basename() { + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("ugly-uuid-name.pdf"); + std::fs::write(&path, b"%PDF").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!( + "", + canon.display() + ); + + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { files, .. } => match &files[0] { + ChannelContent::FileData { filename, .. } => { + assert_eq!(filename, "report.pdf"); + } + _ => panic!("expected FileData"), + }, + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn path_outside_allow_root_is_rejected() { + // Use a path in /tmp that we know exists but isn't under our + // synthetic allow-root. + let (_keep, roots) = fixture_root(); + let outside = std::env::temp_dir().join("openfang-outbound-attach-outside.txt"); + std::fs::write(&outside, b"x").unwrap(); + let canon = std::fs::canonicalize(&outside).unwrap(); + + // Sanity: outside isn't under our fixture root. + assert!(!canon.starts_with(&roots[0])); + + let text = format!("", canon.display()); + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + } => { + assert_eq!(stripped_text, ""); + assert!( + files.is_empty(), + "directive outside allow-root must be dropped" + ); + } + _ => panic!("expected WithAttachments (with empty files)"), + } + let _ = std::fs::remove_file(&outside); + } + + #[tokio::test] + async fn relative_path_is_rejected() { + let (_keep, roots) = fixture_root(); + let result = parse( + "", + Some(&roots), + ) + .await; + match result { + Parsed::WithAttachments { files, .. } => { + assert!(files.is_empty(), "relative path must be rejected"); + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn multiple_markers_are_all_resolved() { + let (tmp, roots) = fixture_root(); + let p1 = tmp.path().join("a.txt"); + let p2 = tmp.path().join("b.txt"); + std::fs::write(&p1, b"a").unwrap(); + std::fs::write(&p2, b"b").unwrap(); + let c1 = std::fs::canonicalize(&p1).unwrap(); + let c2 = std::fs::canonicalize(&p2).unwrap(); + let text = format!( + "first then end", + c1.display(), + c2.display() + ); + + let result = parse(&text, Some(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + } => { + assert_eq!(stripped_text, "first then end"); + assert_eq!(files.len(), 2); + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn malformed_marker_left_in_place() { + // No `path=` attribute → directive is invalid. + let result = parse( + "before after", + None, + ) + .await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + } => { + assert!(files.is_empty()); + assert!( + stripped_text.contains(""), + "malformed marker should be preserved verbatim" + ); + } + _ => panic!("expected WithAttachments (with malformed marker preserved)"), + } + } + + #[test] + fn mime_table_covers_common_extensions() { + assert_eq!(mime_from_extension(Path::new("x.pdf")), "application/pdf"); + assert_eq!(mime_from_extension(Path::new("x.PNG")), "image/png"); + assert_eq!(mime_from_extension(Path::new("x.unknown")), "application/octet-stream"); + assert_eq!(mime_from_extension(Path::new("noext")), "application/octet-stream"); + } +} diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index fb18296498..97ecd1245d 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -384,7 +384,19 @@ impl ClaudeCodeDriver { None => String::new(), }; if let Some(dir) = image_dir { - if let Some(path) = materialize_image(media_type, data, dir) { + // Best-effort filename hint: peel the last path + // segment off the source URL (works for Discord + // CDN, Telegram file API, S3, etc.). Materialize + // appends a sanitized suffix so a human browsing + // ~/.openfang/tmp/images/ can grep for the + // original attachment name. Falls back to a + // pure-hash filename when no URL or no segment. + let name_hint = source_url + .as_deref() + .and_then(filename_hint_from_url); + if let Some(path) = + materialize_image(media_type, data, dir, name_hint.as_deref()) + { return Some(format!( "[attachment: {media_type} image, ~{approx_kb} KB — view with the Read tool at {path}{url_suffix}]", path = path.display() @@ -407,6 +419,8 @@ impl ClaudeCodeDriver { } } + // (helper `filename_hint_from_url` lives at module scope below.) + /// Map a model ID like "claude-code/opus" to CLI --model flag value. fn model_flag(model: &str) -> Option { let stripped = model.strip_prefix("claude-code/").unwrap_or(model); @@ -1366,6 +1380,52 @@ impl LlmDriver for ClaudeCodeDriver { } } +/// Best-effort: extract a filename hint from a URL's last path segment so +/// the materialized tmpfile carries a human-readable suffix. Drops query +/// and fragment, percent-decodes lossily, and bails on values that don't +/// look like filenames (no `.`, or only path-ish junk). Total — bad input +/// just yields `None` and the caller falls back to a pure-hash filename. +fn filename_hint_from_url(url: &str) -> Option { + // Strip scheme://host. Same shape as the discord adapter's helper but + // duplicated here to avoid pulling the channels crate into runtime. + let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url); + let path = after_scheme.split_once('/').map(|(_, r)| r).unwrap_or(""); + let path = path.split(['?', '#']).next().unwrap_or(""); + let last = path.rsplit('/').next().unwrap_or(""); + if last.is_empty() { + return None; + } + // file:// URLs already point at our own tmpfile (the inbox materializer + // uses them) — those names are already content-addressed and a name + // hint there would just double-suffix. Skip. + if url.starts_with("file://") { + return None; + } + // Lossy percent-decode for things like `photo%20final.png`. + let mut out = Vec::with_capacity(last.len()); + let bytes = last.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hi = (bytes[i + 1] as char).to_digit(16); + let lo = (bytes[i + 2] as char).to_digit(16); + if let (Some(h), Some(l)) = (hi, lo) { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + let decoded = String::from_utf8_lossy(&out).into_owned(); + if decoded.is_empty() { + None + } else { + Some(decoded) + } +} + /// Check if the Claude Code CLI is available. pub fn claude_code_available() -> bool { ClaudeCodeDriver::detect().is_some() || claude_credentials_exist() diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs index ccce6c470f..be8493fd6a 100644 --- a/crates/openfang-runtime/src/image_cache.rs +++ b/crates/openfang-runtime/src/image_cache.rs @@ -74,7 +74,18 @@ pub fn ext_for_mime(media_type: &str) -> &'static str { /// `dir`. Idempotent: if a file with the same content hash already exists, /// the existing path is returned without rewriting. Returns `None` on /// decode or I/O failure (caller falls back to a textual placeholder). -pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { +/// +/// `original_name`, if present, is sanitized and appended to the filename +/// after the content hash (`__.`) so a human +/// browsing `~/.openfang/tmp/images/` can grep/eyeball-match files to the +/// inbound attachment they came from. Cache-hit lookup globs `*.` +/// so a re-render with a new (or no) name reuses the existing file. +pub fn materialize_image( + media_type: &str, + data: &str, + dir: &Path, + original_name: Option<&str>, +) -> Option { let bytes = base64::engine::general_purpose::STANDARD .decode(data.as_bytes()) .ok()?; @@ -82,8 +93,28 @@ pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option format!("{hex}__{sanitized}.{ext}"), + None => format!("{hex}.{ext}"), + }; let path = dir.join(filename); + // Defensive: post-sanitize collision check (should be subsumed by the + // hash-prefix scan above, but kept so the legacy code path below is + // still safe if `find_existing_for_hash` ever misses). if path.exists() { // Refresh mtime on cache hit so the TTL sweep (which gates on // `meta.modified()`) does not GC a tmpfile still being actively @@ -129,13 +160,78 @@ pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option