diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4dff9c9c4..cb46392493 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) +- [ ] **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 -- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes -- [ ] `cargo test --workspace` passes - [ ] Live integration tested (if applicable) ## Security diff --git a/Cargo.lock b/Cargo.lock index e880570247..3ff8f9c8e8 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]] @@ -3975,6 +3975,7 @@ dependencies = [ "openfang-extensions", "openfang-hands", "openfang-kernel", + "openfang-mcp-bridge", "openfang-memory", "openfang-migrate", "openfang-runtime", @@ -4007,6 +4008,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "bytes", "cbc", "chrono", "dashmap", @@ -4016,6 +4018,7 @@ dependencies = [ "html-escape", "imap", "lettre", + "libc", "mailparse", "native-tls", "openfang-types", @@ -4028,6 +4031,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tempfile", "tokio", "tokio-stream", "tokio-test", @@ -4180,6 +4184,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "openfang-mcp-bridge" +version = "0.6.9" +dependencies = [ + "anyhow", + "async-trait", + "rmcp", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openfang-memory" version = "0.6.9" @@ -4295,9 +4315,11 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", "uuid", + "zeroize", ] [[package]] @@ -4487,6 +4509,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" @@ -5105,7 +5133,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5143,7 +5171,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5587,12 +5615,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 +5635,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" @@ -5709,7 +5754,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5768,7 +5813,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5827,7 +5872,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -5852,8 +5897,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 +5917,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" @@ -6314,7 +6373,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]] @@ -7050,7 +7109,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7622,7 +7681,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8495,7 +8554,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]] 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-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_auth.rs b/crates/openfang-api/src/bridge_auth.rs new file mode 100644 index 0000000000..8c153381e4 --- /dev/null +++ b/crates/openfang-api/src/bridge_auth.rs @@ -0,0 +1,245 @@ +//! 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() + } + + /// 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 + /// 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/bridge_ipc.rs b/crates/openfang-api/src/bridge_ipc.rs new file mode 100644 index 0000000000..313a0fbd3e --- /dev/null +++ b/crates/openfang-api/src/bridge_ipc.rs @@ -0,0 +1,1718 @@ +//! 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 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, 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. +//! +//! 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 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, +}; +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}; +use std::sync::Arc; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Notify; +use tracing::{debug, error, info, warn}; + +/// 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`). +/// +/// 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 +/// - `agent_send` — inter-agent messaging via the kernel +pub const ALLOWED_TOOLS: &[&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", +]; + +/// 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. +/// `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. +/// `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. +// 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 { + 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. + /// + /// `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 + // 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(); + let accept_authority = authority.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)) => { + 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, conn_authority).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, + }) + } + + /// 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(); + if self.socket_path.exists() { + let _ = std::fs::remove_file(&self.socket_path); + } + } +} + +impl Drop for BridgeIpcServer { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// 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 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. +#[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); + + // --- 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(()); + } + }; + + 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" + ); + + // --- 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), + }; + + 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; + } + } + } +} + +/// 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, + 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 + // resolution. + 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 + ), + }; + } + + // --- 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 { + 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 + } + 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" + ), + }; + } + }; + + // --- 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) + }; + 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 + // 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(); + + // shell_exec needs both `exec_policy` (allowlist / full / deny decision) + // 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), + &call.tool_name, + &call.args, + Some(&kernel_handle), + Some(&allowed_tools_owned), + Some(resolved_agent_id_string.as_str()), + Some(&skill_snapshot), + Some(&kernel.mcp_connections), + Some(&kernel.web_ctx), + Some(&kernel.browser_ctx), + allowed_env_arg, + workspace_root_arg, // scoped to the authenticated agent's workspace; gated above + Some(&kernel.media_engine), + effective_exec_policy, + entry.manifest.file_policy.as_ref(), // F6: agent's resolved policy (was None — silent tier downgrade on bridge) + 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), + None, // origin (no channel origin on bridge IPC tool path) + ) + .await; + + CallResult::Ok { + content: result.content, + is_error: result.is_error, + } +} + +/// 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" + ); + // 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( + " + +[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: +/// - 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 + )); + } + let presented = hello.token.trim(); + if presented.is_empty() { + return Err("empty auth token".to_string()); + } + + 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; + + /// 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. + /// + /// 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_allowlist_gate() { + let tmp = tempfile::tempdir().unwrap(); + 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, 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 — real hex token resolves to `agent_id` via authority. + 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:?}"), + } + + // 1. Non-allowlisted tool → allowlist Error. + codec::write_frame( + &mut cw, + &Frame::Call(CallRequest { + request_id: 1, + agent_id: "test-agent".into(), + tool_name: "definitely_not_a_real_tool".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 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 to allowed tool: {other:?}"), + } + + 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`]. + /// + /// 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, + authority: Arc, + ) -> 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) = authenticate_hello(&hello, &authority) { + 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), + }; + 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?; + } + 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, + } + } + } + + #[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] + fn authenticate_hello_rejects_version_mismatch() { + let authority = BridgeAuthority::new(); + let h = Hello { + protocol_version: 999, + token: "x".into(), + bridge_version: "t".into(), + }; + assert!(authenticate_hello(&h, &authority).is_err()); + } + + #[test] + fn authenticate_hello_rejects_empty_token() { + let authority = BridgeAuthority::new(); + let h = Hello { + protocol_version: PROTOCOL_VERSION, + token: "".into(), + bridge_version: "t".into(), + }; + 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 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 + // 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 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" + ); + } + + /// **Drift-catcher: surface correspondence + safe-by-default subset.** + /// + /// 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 fallback). + /// + /// **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()`. + /// + /// **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_surface_correspondence() { + 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(); + 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(); + 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). \ + daemon-only: {:?}, advertise-only: {:?}", + daemon_set + .difference(&advertise_borrowed) + .collect::>(), + advertise_borrowed + .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. 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_cardinality_pin() { + 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(), + 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(), + 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 + // 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). + // 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" + ); + } + + /// 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) + // 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: "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" + ); + assert!( + identity.token_fingerprint.is_none(), + "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." + ); + } + /// 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-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 37ad72921f..35ebe8db91 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -69,6 +69,67 @@ pub struct KernelBridgeAdapter { started_at: Instant, } +/// #2a (ANAI-81) authz decision for resolving a channel approval. +/// +/// Returns `Ok((audit_label, channel_echo))` for an authorized resolver, or +/// `Err(message)` carrying the channel-facing rejection text. Extracted +/// verbatim from `resolve_approval_text` so the gate's branching is +/// unit-testable against a cheap `AuthManager` fixture without standing up a +/// full kernel (see the `classify_approver_*` tests below). +/// +/// - `audit_label` is the stable `uid:name` recorded in `decided_by` (UUID +/// anchor, name as decoration). +/// - `channel_echo` is the name only — we never leak the internal UserId UUID +/// back to the channel. +fn classify_approver( + auth: &openfang_kernel::auth::AuthManager, + channel_type: &str, + approver_user_id: &str, + approver_display: &str, +) -> Result<(String, String), String> { + if auth.is_enabled() { + match auth.identify(channel_type, approver_user_id) { + Some(uid) => { + if let Err(e) = + auth.authorize(uid, &openfang_kernel::auth::Action::ApproveExecution) + { + return Err(format!("Not authorized to resolve approvals: {e}")); + } + // M1 (ANAI-81): anchor decided_by on the stable UserId (UUID), + // not the mutable display name. Names are non-unique and change + // across reloads; anchoring the audit record on uid preserves + // correlation. Format `uid:name` for a human-readable record + // without losing the stable key. + let name = auth + .get_user(uid) + .map(|u| u.name) + .unwrap_or_else(|| approver_display.to_string()); + Ok((format!("{uid}:{name}"), name)) + } + None => { + Err("Unrecognized user — not authorized to resolve approvals.".to_string()) + } + } + } else { + // RBAC disabled: fail-open-but-recorded (security-openfang verdict A). + // We record the real channel identity but do NOT gate, matching the + // bridge's allow-when-unconfigured posture. WARN so operators see an + // approval was resolved without identity verification on a possibly + // shared channel. (True shared-vs-DM discrimination would need a + // channel-scope signal threaded here; tracked as follow-up.) + warn!( + channel_type = %channel_type, + approver = %approver_user_id, + "Approval resolved with RBAC disabled — resolver identity \ + unverified; enable users/RBAC to gate /approve on shared channels" + ); + Ok(( + format!("{channel_type}:{approver_user_id} ({approver_display})"), + approver_display.to_string(), + )) + } +} + #[async_trait] impl ChannelBridgeHandle for KernelBridgeAdapter { async fn send_message(&self, agent_id: AgentId, message: &str) -> Result { @@ -620,33 +681,35 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { } async fn list_approvals_text(&self) -> String { - let pending = self.kernel.approval_manager.list_pending(); - if pending.is_empty() { - return "No pending approvals.".to_string(); - } - let mut msg = format!("Pending approvals ({}):\n", pending.len()); - for req in &pending { - let id_str = req.id.to_string(); - let id_short = safe_truncate_str(&id_str, 8); - let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds(); - let age = if age_secs >= 60 { - format!("{}m", age_secs / 60) - } else { - format!("{age_secs}s") - }; - msg.push_str(&format!( - " [{}] {} — {} ({:?}) age:{}\n", - id_short, req.agent_id, req.tool_name, req.risk_level, age, - )); - if !req.action_summary.is_empty() { - msg.push_str(&format!(" {}\n", req.action_summary)); - } - } - msg.push_str("\nUse /approve or /reject "); - msg + render_pending_approvals(&self.kernel.approval_manager.list_pending()) } - async fn resolve_approval_text(&self, id_prefix: &str, approve: bool) -> String { + async fn resolve_approval_text( + &self, + id_prefix: &str, + approve: bool, + channel_type: &str, + approver_user_id: &str, + approver_display: &str, + ) -> String { + // ── #2a authz gate ────────────────────────────── + // Pre-#2a this stamped `decided_by = "channel"` with NO identity check: + // any user on a bound channel could resolve another agent's approval and + // the audit trail recorded only the literal "channel". The gate now + // requires an identified, authorized operator and records the REAL + // approver. Branching logic + the RBAC-disabled fail-open posture + // (security-openfang verdict A) live in `classify_approver`, which is + // unit-tested below. + let (approver_label, approver_echo): (String, String) = match classify_approver( + &self.kernel.auth, + channel_type, + approver_user_id, + approver_display, + ) { + Ok(pair) => pair, + Err(msg) => return msg, + }; + let pending = self.kernel.approval_manager.list_pending(); let matched: Vec<_> = pending .iter() @@ -664,17 +727,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { match self.kernel.approval_manager.resolve( req.id, decision, - Some("channel".to_string()), + Some(approver_label.clone()), ) { Ok(_) => { let verb = if approve { "Approved" } else { "Rejected" }; let id_str = req.id.to_string(); format!( - "{} [{}] {} — {}", + "{} [{}] {} — {} (by {})", verb, safe_truncate_str(&id_str, 8), req.tool_name, - req.agent_id + req.agent_id, + approver_echo ) } Err(e) => format!("Failed to resolve approval: {e}"), @@ -907,6 +971,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { recipient, message, None, + None, ) .await .map(|_| ()) @@ -1831,6 +1896,49 @@ pub async fn start_channel_bridge_with_config( if started_names.is_empty() { (None, Vec::new()) } else { + // §8 step 5 — emit consumer. Subscribe to the approval lifecycle event + // stream and push a prompt back to the originating conversation when a + // request is submitted. This is the ONLY place `ApprovalRequest.origin` + // goes live, and it is a pure side-effect: the surfacer never resolves + // a request (resolution stays on the text `/approve` and button paths, + // which re-authorize from the platform-attested interaction identity) + // and never feeds `origin`/`recipient` into authorization. + let mut approval_rx = kernel.approval_manager.subscribe(); + let mut surfacer_shutdown = manager.shutdown_receiver(); + let surfacer_kernel = kernel.clone(); + let surfacer = tokio::spawn(async move { + use openfang_kernel::approval::ApprovalEvent; + use tokio::sync::broadcast::error::RecvError; + loop { + tokio::select! { + ev = approval_rx.recv() => match ev { + Ok(ApprovalEvent::Submitted(req)) => { + surfacer_kernel.surface_approval_prompt(&req).await; + } + // Resolved / TimedOut carry no proactive push in this step. + Ok(_) => {} + // Lossy broadcast: a dropped/lagged event means "no + // proactive prompt", never "no approval". The decision + // path (await/timeout in request_approval) is wholly + // independent of this listener. Log and keep going. + Err(RecvError::Lagged(n)) => { + warn!( + missed = n, + "approval surfacer lagged; {n} prompt(s) not pushed (approvals unaffected)" + ); + } + Err(RecvError::Closed) => break, + }, + _ = surfacer_shutdown.changed() => { + if *surfacer_shutdown.borrow() { + break; + } + } + } + } + info!("approval surfacer task stopped"); + }); + manager.attach_task(surfacer); (Some(manager), started_names) } } @@ -1903,8 +2011,77 @@ pub async fn reload_channels_from_disk( Ok(started) } +/// Render the `/approvals` listing text from a pending snapshot. +/// +/// Pure (no kernel/IO) so the marker-neutralization invariant is unit-testable. +/// Agent-controlled fields (`agent_id`, `tool_name`, `action_summary`) are run +/// through `neutralize_markers` because this text returns through `send_parsed` +/// → `outbound_attach::parse` (ANAI-82): without it a queued request's +/// `` marker would be stripped and its caption promoted at +/// the exact moment an operator inspects the queue to decide. +fn render_pending_approvals(pending: &[openfang_types::approval::ApprovalRequest]) -> String { + if pending.is_empty() { + return "No pending approvals.".to_string(); + } + let n = openfang_channels::outbound_attach::neutralize_markers; + let mut msg = format!("Pending approvals ({}):\n", pending.len()); + for req in pending { + let id_str = req.id.to_string(); + let id_short = safe_truncate_str(&id_str, 8); + let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds(); + let age = if age_secs >= 60 { + format!("{}m", age_secs / 60) + } else { + format!("{age_secs}s") + }; + msg.push_str(&format!( + " [{}] {} — {} ({:?}) age:{}\n", + id_short, + n(&req.agent_id), + n(&req.tool_name), + req.risk_level, + age, + )); + if !req.action_summary.is_empty() { + msg.push_str(&format!(" {}\n", n(&req.action_summary))); + } + } + msg.push_str("\nUse /approve or /reject "); + msg +} + #[cfg(test)] mod tests { + #[tokio::test] + async fn approvals_listing_neutralizes_attach_markers() { + use openfang_channels::outbound_attach::{parse, ParseOptions, Parsed}; + use openfang_types::approval::{ApprovalRequest, RiskLevel}; + + let req = ApprovalRequest { + id: uuid::Uuid::new_v4(), + agent_id: "agent-x".to_string(), + tool_name: "shell_exec".to_string(), + description: "d".to_string(), + action_summary: + "rm -rf /important " + .to_string(), + risk_level: RiskLevel::High, + requested_at: chrono::Utc::now(), + timeout_secs: 300, + origin: None, + }; + + // An operator running /approvals must see the request verbatim, never a + // stripped + caption-promoted version: the composed text carries no + // parseable attach marker. + let listing = super::render_pending_approvals(std::slice::from_ref(&req)); + assert!(matches!( + parse(&listing, ParseOptions::default()).await, + Parsed::NoMarkers + )); + assert!(listing.contains("<openfang:attach")); + } + #[tokio::test] async fn test_bridge_skips_when_no_config() { let config = openfang_types::config::KernelConfig::default(); @@ -1983,4 +2160,104 @@ mod tests { let feishu = config.channels.feishu.expect("feishu config should exist"); assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket); } + + // ── #2a (ANAI-81) classify_approver gate ──────────────────────────── + // The kernel-layer authorize() matrix (admin floor, unrecognized-denied) + // is covered in openfang-kernel's auth.rs tests. These cover the bridge + // glue that the kernel tests can't see: the RBAC-enabled/disabled + // branching, the stable `uid:name` audit label, the name-only channel echo + // (no UserId UUID leak), and the rejection strings — all without standing + // up a full OpenFangKernel fixture. + use super::classify_approver; + use openfang_kernel::auth::AuthManager; + use openfang_types::config::UserConfig; + use std::collections::HashMap; + + /// One user per role, each bound on discord. Mirrors the kernel auth.rs + /// fixture so the two layers exercise the same role matrix. + fn rbac_role_configs() -> Vec { + let bind = |id: &str| { + let mut m = HashMap::new(); + m.insert("discord".to_string(), id.to_string()); + m + }; + vec![ + UserConfig { + name: "Owner".to_string(), + role: "owner".to_string(), + channel_bindings: bind("owner-1"), + api_key_hash: None, + }, + UserConfig { + name: "Admin".to_string(), + role: "admin".to_string(), + channel_bindings: bind("admin-1"), + api_key_hash: None, + }, + UserConfig { + name: "Member".to_string(), + role: "user".to_string(), + channel_bindings: bind("user-1"), + api_key_hash: None, + }, + UserConfig { + name: "Viewer".to_string(), + role: "viewer".to_string(), + channel_bindings: bind("viewer-1"), + api_key_hash: None, + }, + ] + } + + // Authorized resolvers (Admin floor) get Ok((uid:name, name)). The audit + // label is anchored on the stable UserId; the channel echo is name-only. + #[test] + fn test_classify_approver_admin_floor_records_uid_name() { + let auth = AuthManager::new(&rbac_role_configs()); + + for (bind_id, name) in [("admin-1", "Admin"), ("owner-1", "Owner")] { + let uid = auth + .identify("discord", bind_id) + .expect("bound user resolves"); + let (label, echo) = classify_approver(&auth, "discord", bind_id, "ignored-display") + .expect("authorized resolver"); + assert_eq!(label, format!("{uid}:{name}"), "audit label is uid:name"); + assert_eq!(echo, name, "channel echo is name-only"); + assert!(!echo.contains(':'), "echo must not leak the UserId UUID"); + } + } + + // Recognized-but-below-floor roles (user, viewer) are denied. + #[test] + fn test_classify_approver_below_floor_denied() { + let auth = AuthManager::new(&rbac_role_configs()); + for bind_id in ["user-1", "viewer-1"] { + let err = classify_approver(&auth, "discord", bind_id, "d") + .expect_err("below-floor role denied"); + assert!(err.contains("Not authorized"), "got: {err}"); + } + } + + // An unrecognized channel identity is rejected before authorize(). + #[test] + fn test_classify_approver_unrecognized_denied() { + let auth = AuthManager::new(&rbac_role_configs()); + let err = classify_approver(&auth, "discord", "stranger", "d") + .expect_err("unrecognized user denied"); + assert!(err.contains("Unrecognized"), "got: {err}"); + } + + // RBAC disabled (no users configured): fail-open-but-recorded. No gate, + // but the real channel identity is stamped into the audit label and the + // display name is echoed. (security-openfang verdict A.) + #[test] + fn test_classify_approver_rbac_disabled_fail_open_records_channel_identity() { + let auth = AuthManager::new(&[]); + assert!(!auth.is_enabled(), "no users => RBAC disabled"); + + let (label, echo) = classify_approver(&auth, "discord", "raw-123", "Ben") + .expect("fail-open records without gating"); + assert_eq!(label, "discord:raw-123 (Ben)"); + assert_eq!(echo, "Ben"); + } } diff --git a/crates/openfang-api/src/lib.rs b/crates/openfang-api/src/lib.rs index 243da788b2..676330d7f9 100644 --- a/crates/openfang-api/src/lib.rs +++ b/crates/openfang-api/src/lib.rs @@ -32,6 +32,14 @@ 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_auth; +#[cfg(unix)] +pub mod bridge_ipc; pub mod channel_bridge; pub mod middleware; pub mod openai_compat; 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 cebb1f599a..6ca401c123 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) => { @@ -336,6 +337,42 @@ 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 +398,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 +440,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 { @@ -520,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 @@ -1548,6 +1601,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 +1638,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, @@ -7056,6 +7125,97 @@ 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. + // 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::{requires_exec_policy, AgentExecContext}; + 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()); + // 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", + "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." + ) + } + })); + } + // 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 .kernel @@ -7067,21 +7227,29 @@ 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()); + // 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, &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, + allowed_env_arg, + workspace_path.as_deref(), Some(&state.kernel.media_engine), - None, // exec_policy + effective_exec_policy, + None, // file_policy if state.kernel.config.tts.enabled { Some(&state.kernel.tts_engine) } else { @@ -7093,6 +7261,7 @@ pub async fn mcp_http( None }, Some(&*state.kernel.process_manager), + None, // origin (no channel origin on MCP-HTTP tool path) ) .await; @@ -7950,7 +8119,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 { @@ -7961,6 +8133,8 @@ pub async fn test_provider( temperature: 0.0, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, }; match driver.complete(test_req).await { Ok(_) => { @@ -10888,6 +11062,7 @@ pub async fn create_approval( risk_level: RiskLevel::High, requested_at: chrono::Utc::now(), timeout_secs: policy.timeout_secs, + origin: None, }; // Spawn the request in the background (it will block until resolved or timed out) @@ -12973,3 +13148,80 @@ 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)" + ) + ); + } +} diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a1a2bc9c06..6969cd328a 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -16,6 +16,8 @@ use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; 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)] @@ -804,11 +806,26 @@ 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 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 no authority is constructed and the kernel keeps its + // `token_issuer` slot empty. + kernel.start_background_agents(); // Config file hot-reload watcher (polls every 30 seconds) @@ -844,6 +861,100 @@ 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. + // + // 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(), 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" + ); + } + } + 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) + } + 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-channels/Cargo.toml b/crates/openfang-channels/Cargo.toml index e0ef71d551..7f0c89c33f 100644 --- a/crates/openfang-channels/Cargo.toml +++ b/crates/openfang-channels/Cargo.toml @@ -29,6 +29,7 @@ sha1 = { workspace = true } aes = "0.8" cbc = "0.1" base64 = { workspace = true } +bytes = { workspace = true } hex = { workspace = true } html-escape = { workspace = true } regex-lite = "0.1" @@ -42,3 +43,7 @@ rumqttc = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } +tempfile = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 2043aeaa76..37a0cdc97f 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -13,10 +13,11 @@ use async_trait::async_trait; use dashmap::DashMap; use futures::StreamExt; use openfang_types::agent::AgentId; -use openfang_types::approval::ApprovalRequest; +use openfang_types::approval::{ApprovalOrigin, ApprovalRequest}; use openfang_types::commands::{self as slash_commands, Surfaces}; use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat, PrefixStyle}; use openfang_types::message::ContentBlock; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::watch; @@ -122,6 +123,39 @@ pub trait ChannelBridgeHandle: Send + Sync { self.send_message(agent_id, &text).await } + /// Send a message to an agent, carrying the live origin of the triggering + /// run so a downstream approval prompt can be pushed back to the exact + /// channel/conversation it came from. + /// + /// Default implementation ignores `origin` and delegates to + /// [`send_message`](Self::send_message) — preserving current behavior for + /// every adapter and caller that does not override it. Only the real kernel + /// impl (openfang-api) overrides this to thread `origin` into the run. + async fn send_message_with_origin( + &self, + agent_id: AgentId, + message: &str, + origin: ApprovalOrigin, + ) -> Result { + let _ = origin; + self.send_message(agent_id, message).await + } + + /// Origin-carrying counterpart to + /// [`send_message_with_blocks`](Self::send_message_with_blocks). + /// + /// Default implementation ignores `origin` and delegates to the non-origin + /// block variant. + async fn send_message_with_blocks_and_origin( + &self, + agent_id: AgentId, + blocks: Vec, + origin: ApprovalOrigin, + ) -> Result { + let _ = origin; + self.send_message_with_blocks(agent_id, blocks).await + } + /// Find an agent by name, returning its ID. async fn find_agent_by_name(&self, name: &str) -> Result, String>; @@ -314,7 +348,14 @@ pub trait ChannelBridgeHandle: Send + Sync { } /// Approve or reject a pending approval by UUID prefix. - async fn resolve_approval_text(&self, _id_prefix: &str, _approve: bool) -> String { + async fn resolve_approval_text( + &self, + _id_prefix: &str, + _approve: bool, + _channel_type: &str, + _approver_user_id: &str, + _approver_display: &str, + ) -> String { "Approvals not available.".to_string() } @@ -406,6 +447,23 @@ impl BridgeManager { &self.router } + /// A receiver for this bridge's shutdown signal. + /// + /// Lets an externally-spawned side-effect task (e.g. the approval surfacer + /// in openfang-api, which needs the kernel's `ApprovalManager`) observe the + /// same shutdown as the adapter dispatch loops, so it is torn down on + /// `stop()` / channel reload rather than leaking across reloads. + pub fn shutdown_receiver(&self) -> watch::Receiver { + self.shutdown_rx.clone() + } + + /// Hand the manager ownership of an externally-spawned task so it is awaited + /// during `stop()`. The task is expected to observe + /// [`shutdown_receiver`](Self::shutdown_receiver) and exit on signal. + pub fn attach_task(&mut self, task: tokio::task::JoinHandle<()>) { + self.tasks.push(task); + } + /// Start an adapter: subscribe to its message stream and spawn a dispatch task. /// /// Each incoming message is dispatched as a concurrent task so that slow LLM @@ -593,7 +651,79 @@ async fn maybe_prefix_response( } } +/// Compute the per-agent workspace root for outbound attachment scoping. +/// +/// Returns the canonical path to `~/.openfang/workspaces//` if +/// the agent name can be resolved and the directory exists; `None` +/// otherwise. A `None` result causes the attachment parser to fall back to +/// its (empty) default allow-roots — i.e. attachments are dropped fail- +/// closed rather than escalated. The text portion of the message is still +/// delivered. +async fn agent_workspace_root( + handle: &Arc, + agent_id: AgentId, +) -> Option { + let name = resolve_agent_name(handle, agent_id).await?; + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push(".openfang"); + p.push("workspaces"); + p.push(&name); + std::fs::canonicalize(&p).ok() +} + +/// Send an *agent-authored* response: resolves the agent's workspace root, +/// parses outbound `` markers scoped to that root, +/// then formats and dispatches. +/// +/// This is the only send path on which agent-generated text reaches the +/// adapter; system-authored text (rate-limit notices, help, errors) goes +/// through [`send_response`], which uses empty allow-roots and therefore +/// silently strips any markers (system text never contains them, but +/// defense-in-depth). +async fn send_agent_response( + handle: &Arc, + agent_id: AgentId, + adapter: &dyn ChannelAdapter, + user: &ChannelUser, + text: String, + thread_id: Option<&str>, + output_format: OutputFormat, +) { + let workspace_root = agent_workspace_root(handle, agent_id).await; + let _ = send_parsed( + adapter, + user, + text, + thread_id, + output_format, + SendOptions { workspace_root }, + ) + .await; +} + +/// Caller-supplied context for an outbound send. +/// +/// Carries the per-agent workspace root used to scope outbound +/// `` marker resolution. `None` means "no allow-roots" +/// — the parser is fail-closed against missing context, so attachments are +/// silently dropped while text still goes through. Callers that want +/// attachments to work MUST set `workspace_root` (typically +/// `~/.openfang/workspaces//`). +#[derive(Clone, Debug, Default)] +pub struct SendOptions { + /// Per-agent workspace root. When `Some`, the outbound attachment + /// parser is restricted to paths under this root. When `None`, + /// attachments are dropped fail-closed. + pub workspace_root: Option, +} + /// Send a response, applying output formatting and optional threading. +/// +/// Bridge reply-path entrypoint. Delegates to [`send_parsed`] with default +/// [`SendOptions`] — kept for source-compatibility with the many call sites +/// inside this module; new code (kernel proactive-send) should call +/// [`send_parsed`] directly with an explicit `workspace_root`. async fn send_response( adapter: &dyn ChannelAdapter, user: &ChannelUser, @@ -601,25 +731,136 @@ async fn send_response( thread_id: Option<&str>, output_format: OutputFormat, ) { + let _ = send_parsed( + adapter, + user, + text, + thread_id, + output_format, + SendOptions::default(), + ) + .await; +} + +/// Parse outbound `` markers, apply channel +/// formatting, and dispatch to the adapter. +/// +/// Shared by the bridge reply-path ([`send_response`]) and the kernel +/// proactive-send path (commit 3). The `options.workspace_root`, when +/// set, is forwarded to [`outbound_attach::parse`] as the sole allow-root, +/// scoping attachment resolution to that agent's workspace. +/// Returns the list of `` directives whose paths +/// parsed but whose resolution failed (missing file, outside allow-roots, +/// oversized, …) — `(directive_path, reason)` per entry. Callers that +/// surface a tool-call result back to the agent (e.g. +/// `kernel::send_channel_message` for the `channel_send` tool) should +/// include this list so the agent can react to silent drops. The bridge +/// reply-path discards it; the WARN log inside `outbound_attach::parse` +/// remains the operator-facing record. +pub async fn send_parsed( + adapter: &dyn ChannelAdapter, + user: &ChannelUser, + text: String, + thread_id: Option<&str>, + output_format: OutputFormat, + options: SendOptions, +) -> Vec<(String, String)> { + // Parse `` markers BEFORE formatting — channel + // formatters (telegram HTML, slack mrkdwn) escape `<` and would break + // marker detection downstream. + // + // `workspace_root` plays two distinct roles in the parser: + // - `allow_roots`: authorisation. The canonicalised target must + // live under this root. + // - `base`: resolution context for *relative* directive paths. + // Without an explicit base the parser refuses to fall back to + // process CWD (see outbound_attach module docs). + // Bridge currently passes the same workspace for both; that's a + // caller convention, not a parser invariant. + let allow_roots_override: Option> = + options.workspace_root.as_ref().map(|p| vec![p.clone()]); + let parse_opts = crate::outbound_attach::ParseOptions { + allow_roots: allow_roots_override.as_deref(), + base: options.workspace_root.as_deref(), + }; + let (text_to_format, attachment_blocks, skipped): ( + String, + Vec, + Vec<(String, String)>, + ) = match crate::outbound_attach::parse(&text, parse_opts).await { + crate::outbound_attach::Parsed::NoMarkers => (text, Vec::new(), Vec::new()), + crate::outbound_attach::Parsed::WithAttachments { + stripped_text, + files, + skipped, + } => (stripped_text, files, skipped), + }; + 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, output_format) + formatter::format_for_channel(&text_to_format, output_format) }; - let content = ChannelContent::Text(formatted); - let result = if let Some(tid) = thread_id { - adapter.send_in_thread(user, content, tid).await + let content = if attachment_blocks.is_empty() { + ChannelContent::Text(formatted) } else { - adapter.send(user, content).await + 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 result = dispatch_content(adapter, user, content, thread_id).await; + if let Err(e) = result { error!("Failed to send response: {e}"); } + + skipped +} + +/// Degrade-aware dispatch chokepoint (ANAI-82 Piece 1). +/// +/// Single point through which outbound [`ChannelContent`] reaches an adapter. +/// If the content is [`ChannelContent::Interactive`] (action buttons) but the +/// target adapter cannot render it (`!supports_interactive()`), collapse it to +/// its plain-text body via [`ChannelContent::degrade_to_text`] before sending. +/// This keeps the ~50 non-Discord adapters from ever receiving a variant they +/// cannot render, and gives the interactive approval-prompt emit path (Piece 2) +/// a safe sink on every channel. +async fn dispatch_content( + adapter: &dyn ChannelAdapter, + user: &ChannelUser, + content: ChannelContent, + thread_id: Option<&str>, +) -> Result<(), Box> { + let content = if matches!(content, ChannelContent::Interactive { .. }) + && !adapter.supports_interactive() + { + debug!( + adapter = adapter.name(), + "degrading Interactive content to text (adapter lacks button support)" + ); + content.degrade_to_text() + } else { + content + }; + + if let Some(tid) = thread_id { + adapter.send_in_thread(user, content, tid).await + } else { + adapter.send(user, content).await + } } -fn default_output_format_for_channel(channel_type: &str) -> OutputFormat { +pub fn default_output_format_for_channel(channel_type: &str) -> OutputFormat { match channel_type { "telegram" => OutputFormat::TelegramHtml, "slack" => OutputFormat::SlackMrkdwn, @@ -709,6 +950,25 @@ fn binding_context_for(message: &ChannelMessage) -> BindingContext { } } +/// Build the live [`ApprovalOrigin`] for an incoming message so a downstream +/// approval prompt can be pushed back to the exact channel/conversation that +/// triggered the run. +/// +/// `channel_type` / `channel_id` / `recipient` are sourced from the same +/// single-source-of-truth as routing ([`binding_context_for`]); `thread_id` +/// carries the resolved (possibly auto-created) thread the run is replying in. +/// `recipient` is audit/targeting only — never an authz carrier (the clicker is +/// re-authorized from the platform-attested interaction identity). +fn approval_origin_for(message: &ChannelMessage, thread_id: Option<&str>) -> ApprovalOrigin { + let ctx = binding_context_for(message); + ApprovalOrigin { + channel_type: ctx.channel, + channel_id: ctx.channel_id, + thread_id: thread_id.map(String::from), + recipient: Some(ctx.peer_id), + } +} + /// If an error contains "Agent not found", try to re-resolve the channel's default agent /// by name (the name stored at bridge startup). Returns `Some(new_id)` on success. async fn try_reresolution( @@ -892,6 +1152,7 @@ async fn dispatch_message( router, &message.sender, sender_user_id(message), + channel_type_str(&message.channel), ) .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; @@ -952,6 +1213,14 @@ async fn dispatch_message( provider_metadata: None, }); } + // Outbound-only variant; never expected inbound. Render + // its text body defensively rather than drop the message. + ChannelContent::Interactive { text, .. } => { + blocks.push(ContentBlock::Text { + text: text.clone(), + provider_metadata: None, + }); + } // Defensive: debug_assert above catches this in dev; ignore // gracefully in release. ChannelContent::Multipart(_) => {} @@ -1022,6 +1291,8 @@ async fn dispatch_message( let text = match &message.content { ChannelContent::Text(t) => t.clone(), + // Outbound-only variant; never expected inbound. Fall back to text. + ChannelContent::Interactive { text, .. } => text.clone(), ChannelContent::Command { .. } => unreachable!(), // handled above ChannelContent::Image { ref url, @@ -1076,6 +1347,8 @@ async fn dispatch_message( ChannelContent::Command { name, args } => { format!("/{name} {}", args.join(" ")) } + // Outbound-only; never expected inbound. Fall back to text. + ChannelContent::Interactive { text, .. } => text.clone(), // Nesting is rejected by adapters; emit empty so the join // doesn't insert spurious separators. ChannelContent::Multipart(_) => String::new(), @@ -1103,6 +1376,7 @@ async fn dispatch_message( router, &message.sender, sender_user_id(message), + channel_type_str(&message.channel), ) .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; @@ -1277,7 +1551,16 @@ async fn dispatch_message( // If auto-reply is enabled but suppressed for this message, skip agent call entirely. if let Some(reply) = handle.check_auto_reply(agent_id, &text).await { let reply = maybe_prefix_response(handle, overrides.as_ref(), agent_id, reply).await; - send_response(adapter, &message.sender, reply, thread_id, output_format).await; + send_agent_response( + handle, + agent_id, + adapter, + &message.sender, + reply, + thread_id, + output_format, + ) + .await; handle .record_delivery( agent_id, @@ -1330,8 +1613,15 @@ async fn dispatch_message( text.clone() }; + // Carry the live origin so a downstream approval prompt can be pushed back + // to this exact channel/conversation. Nothing reads `origin` until the emit + // site (step 5) — this is behavior-preserving on every existing path. + let origin = approval_origin_for(message, thread_id); + // Send to agent and relay response - let result = handle.send_message(agent_id, &prefixed_text).await; + let result = handle + .send_message_with_origin(agent_id, &prefixed_text, origin.clone()) + .await; // Stop the typing refresh now that we have a response typing_task.abort(); @@ -1343,7 +1633,16 @@ async fn dispatch_message( } let response = maybe_prefix_response(handle, overrides.as_ref(), agent_id, response).await; - send_response(adapter, &message.sender, response, thread_id, output_format).await; + send_agent_response( + handle, + agent_id, + adapter, + &message.sender, + response, + thread_id, + output_format, + ) + .await; handle .record_delivery( agent_id, @@ -1359,7 +1658,9 @@ async fn dispatch_message( // Try re-resolution before reporting error if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await { let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); - let retry = handle.send_message(new_id, &text).await; + let retry = handle + .send_message_with_origin(new_id, &text, origin.clone()) + .await; typing_task2.abort(); match retry { Ok(response) => { @@ -1375,8 +1676,16 @@ async fn dispatch_message( let response = maybe_prefix_response(handle, overrides.as_ref(), new_id, response) .await; - send_response(adapter, &message.sender, response, thread_id, output_format) - .await; + send_agent_response( + handle, + new_id, + adapter, + &message.sender, + response, + thread_id, + output_format, + ) + .await; handle .record_delivery( new_id, @@ -1688,7 +1997,14 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec apply_agent_prefix(prefix_style, name, &response), None => response, }; - send_response(adapter, &message.sender, response, thread_id, output_format).await; + send_agent_response( + handle, + agent_id, + adapter, + &message.sender, + response, + thread_id, + output_format, + ) + .await; handle .record_delivery( agent_id, @@ -1823,7 +2151,9 @@ async fn dispatch_with_blocks( // Try re-resolution before reporting error if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await { let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone()); - let retry = handle.send_message_with_blocks(new_id, blocks).await; + let retry = handle + .send_message_with_blocks_and_origin(new_id, blocks, origin.clone()) + .await; typing_task2.abort(); match retry { Ok(response) => { @@ -1845,8 +2175,16 @@ async fn dispatch_with_blocks( Some(name) => apply_agent_prefix(prefix_style, name, &response), None => response, }; - send_response(adapter, &message.sender, response, thread_id, output_format) - .await; + send_agent_response( + handle, + new_id, + adapter, + &message.sender, + response, + thread_id, + output_format, + ) + .await; handle .record_delivery( new_id, @@ -1938,6 +2276,7 @@ async fn handle_command( router: &Arc, sender: &ChannelUser, user_id: &str, + channel_type: &str, ) -> String { // Canonicalise through the unified command registry: aliases resolve to // their canonical name and matching is case-insensitive. If the command @@ -2164,14 +2503,30 @@ async fn handle_command( if args.is_empty() { "Usage: /approve ".to_string() } else { - handle.resolve_approval_text(&args[0], true).await + handle + .resolve_approval_text( + &args[0], + true, + channel_type, + user_id, + &sender.display_name, + ) + .await } } "reject" => { if args.is_empty() { "Usage: /reject ".to_string() } else { - handle.resolve_approval_text(&args[0], false).await + handle + .resolve_approval_text( + &args[0], + false, + channel_type, + user_id, + &sender.display_name, + ) + .await } } @@ -2190,7 +2545,7 @@ async fn handle_command( #[cfg(test)] mod tests { use super::*; - use crate::types::ChannelType; + use crate::types::{ButtonStyle, ChannelType, InteractiveButton}; use std::sync::Mutex; /// Mock kernel handle for testing. @@ -2231,6 +2586,133 @@ mod tests { assert_eq!(args, vec!["hello-world"]); } + /// The origin-carrying trait method must, by default, ignore `origin` and + /// behave exactly like `send_message` — so the ~50 adapters and all existing + /// callers are unaffected until the real kernel impl overrides it (ANAI-82 + /// Piece 2, Option Y plumbing). + #[tokio::test] + async fn send_message_with_origin_defaults_to_send_message() { + let handle = MockHandle { + agents: Mutex::new(Vec::new()), + }; + let origin = ApprovalOrigin { + channel_type: "discord".to_string(), + channel_id: Some("chan-1".to_string()), + thread_id: None, + recipient: Some("peer-1".to_string()), + }; + let out = handle + .send_message_with_origin(AgentId::new(), "hello", origin) + .await + .unwrap(); + assert_eq!(out, "Echo: hello"); + } + + /// Records every [`ChannelContent`] handed to `send` so dispatch-path tests + /// can assert what the adapter actually received. `interactive` toggles + /// [`ChannelAdapter::supports_interactive`] to exercise both branches of the + /// degrade chokepoint (ANAI-82 Piece 1). + struct RecordingAdapter { + interactive: bool, + received: Mutex>, + } + + #[async_trait] + impl ChannelAdapter for RecordingAdapter { + fn name(&self) -> &str { + "recording-mock" + } + fn channel_type(&self) -> ChannelType { + ChannelType::Custom("recording-mock".to_string()) + } + async fn start( + &self, + ) -> Result< + std::pin::Pin + Send>>, + Box, + > { + Err("mock adapter does not stream".into()) + } + async fn send( + &self, + _user: &ChannelUser, + content: ChannelContent, + ) -> Result<(), Box> { + self.received.lock().unwrap().push(content); + Ok(()) + } + async fn stop(&self) -> Result<(), Box> { + Ok(()) + } + fn supports_interactive(&self) -> bool { + self.interactive + } + } + + fn sample_interactive() -> ChannelContent { + ChannelContent::Interactive { + text: "Approve request? /approve abc123".to_string(), + buttons: vec![InteractiveButton { + custom_id: "ap:abc123".to_string(), + label: "Approve".to_string(), + style: ButtonStyle::Success, + }], + } + } + + fn test_user() -> ChannelUser { + ChannelUser { + platform_id: "u1".to_string(), + display_name: "tester".to_string(), + openfang_user: None, + } + } + + /// Adapter WITHOUT button support: Interactive must be degraded to its text + /// body before it ever reaches `send` (the ~50 non-Discord adapters). + #[tokio::test] + async fn dispatch_degrades_interactive_when_unsupported() { + let adapter = RecordingAdapter { + interactive: false, + received: Mutex::new(Vec::new()), + }; + let user = test_user(); + + dispatch_content(&adapter, &user, sample_interactive(), None) + .await + .unwrap(); + + let received = adapter.received.lock().unwrap(); + assert_eq!(received.len(), 1); + match &received[0] { + ChannelContent::Text(t) => { + assert!(t.contains("/approve abc123"), "text body must survive degrade"); + } + other => panic!("expected degraded Text, got {other:?}"), + } + } + + /// Adapter WITH button support (Discord): Interactive passes through intact. + #[tokio::test] + async fn dispatch_preserves_interactive_when_supported() { + let adapter = RecordingAdapter { + interactive: true, + received: Mutex::new(Vec::new()), + }; + let user = test_user(); + + dispatch_content(&adapter, &user, sample_interactive(), None) + .await + .unwrap(); + + let received = adapter.received.lock().unwrap(); + assert_eq!(received.len(), 1); + assert!( + matches!(received[0], ChannelContent::Interactive { .. }), + "interactive-capable adapter must receive Interactive unchanged" + ); + } + #[tokio::test] async fn test_dispatch_routes_to_correct_agent() { let agent_id = AgentId::new(); @@ -2265,10 +2747,10 @@ mod tests { openfang_user: None, }; - let result = handle_command("agents", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("agents", &[], &handle, &router, &sender, "user1", "cli").await; assert!(result.contains("coder")); - let result = handle_command("help", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("help", &[], &handle, &router, &sender, "user1", "cli").await; assert!(result.contains("/agents")); } @@ -2293,6 +2775,7 @@ mod tests { &router, &sender, "user1", + "telegram", ) .await; assert!(result.contains("Now talking to agent: coder")); @@ -2328,6 +2811,7 @@ mod tests { &router, &sender, user_id, + "discord", ) .await; assert!(result.contains("Now talking to agent: coder")); @@ -2357,7 +2841,7 @@ mod tests { openfang_user: None, }; - let result = handle_command("agent", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("agent", &[], &handle, &router, &sender, "user1", "cli").await; assert!(result.contains("Usage: /agent ")); assert!(result.contains("coder")); } @@ -2585,6 +3069,7 @@ mod tests { ContentBlock::Image { media_type: "image/jpeg".to_string(), data: "base64data".to_string(), + source_url: None, }, ]; @@ -2607,6 +3092,7 @@ mod tests { let blocks = vec![ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }]; // Default impl sends empty text when no text blocks @@ -2810,4 +3296,65 @@ mod tests { "image/jpeg" ); } + + /// Regression test: `download_image_to_blocks` must populate the + /// `source_url` field on the resulting `ContentBlock::Image`. Text-only + /// drivers (e.g. Claude Code) rely on this URL to reference the image + /// without re-uploading bytes; a regression here silently breaks vision + /// for those drivers. + /// + /// Spins up a local TCP listener that serves a stub PNG response so we + /// can drive the function end-to-end without external dependencies. + #[tokio::test] + async fn download_image_to_blocks_populates_source_url() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{addr}/test.png"); + + // Body is arbitrary — the function trusts the Content-Type header + // when it starts with `image/`. PNG signature is included so any + // future magic-byte fallback also matches. + let body: &[u8] = b"\x89PNG\r\n\x1a\nfakepngbytes"; + let response_head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + // Drain the request — we don't care what reqwest sent. + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + sock.write_all(response_head.as_bytes()).await.unwrap(); + sock.write_all(body).await.unwrap(); + let _ = sock.shutdown().await; + }); + + let blocks = download_image_to_blocks(&url, Some("hello")).await; + + // Caption first, image second. + assert_eq!(blocks.len(), 2, "expected caption + image blocks"); + match &blocks[0] { + ContentBlock::Text { text, .. } => 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-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index 8b5ff0773c..6b5a35df26 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -5,17 +5,21 @@ use crate::types::{ split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser, + InteractiveButton, + ResolutionError, }; use async_trait::async_trait; use futures::{SinkExt, Stream, StreamExt}; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::net::{IpAddr, Ipv4Addr}; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::{mpsc, watch, Mutex, RwLock}; use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; +use url::Url; use zeroize::Zeroizing; const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; @@ -26,6 +30,63 @@ const DISCORD_MSG_LIMIT: usize = 2000; /// MESSAGE_UPDATE (embed resolution) events arrive within seconds of the /// original CREATE; entries older than this cap are safe to discard. const MAX_DEDUP_MSG_IDS: usize = 2_000; +/// Multipart field name Discord requires for the first attachment payload. +/// Test-only fixture: the production call site uses `format!("files[{i}]")` +/// directly; this constant pins the wire format in +/// `test_attachment_field_name_pinned` so a `file[0]` typo or future +/// refactor can't slip past review. +#[cfg(test)] +const ATTACHMENT_FIELD_NAME: &str = "files[0]"; +/// Floor on the rate-limit retry delay. Discord occasionally returns +/// `retry_after: 0` (or a missing header), which would busy-loop the retry. +const RETRY_AFTER_FLOOR_SECS: f64 = 0.05; +/// Cap on the rate-limit retry delay so a misbehaving response can't park us +/// for a long time on a one-shot retry. +const RETRY_AFTER_CEIL_SECS: f64 = 30.0; +/// Per-request timeout for outbound URL fetches (File/Image arms). Matches the +/// adapter's existing 15s budget for other REST calls so a slow remote can't +/// stall the send pipeline. +const URL_FETCH_TIMEOUT: Duration = Duration::from_secs(15); +/// Hard cap on the size of a fetched URL body, both via Content-Length pre-flight +/// and via streamed accumulation. Discord itself caps non-Nitro uploads at +/// 25 MiB; matching that here means we reject before paying for bytes Discord +/// would refuse anyway. +const URL_FETCH_MAX_BYTES: usize = 25 * 1024 * 1024; +/// Maximum number of attachments per multipart POST. Discord's REST API caps +/// `files[N]` at 10 per request; the multipart helper relies on the caller +/// having pre-chunked. +const ATTACHMENTS_PER_CHUNK: usize = 10; +/// Aggregate byte cap on a single multipart POST's attachment payload. +/// Discord caps non-Nitro requests at 25 MiB total (multipart envelope + +/// payload_json + every `files[i]`); 24 MiB leaves ~1 MiB of headroom for the +/// envelope so an over-budget attempt can't silently 413. Files larger than +/// this cap end up in their own single-attachment chunk; Discord still +/// rejects them, but the caller sees the same error they did before this +/// cap landed. +const CHUNK_TOTAL_CAP_BYTES: usize = 24 * 1024 * 1024; +/// Cap on the number of HTTP redirects we'll follow on URL fetches. Each hop +/// is independently SSRF-rechecked at the literal-IP level. +const URL_FETCH_MAX_REDIRECTS: usize = 3; +/// User-Agent we identify as on outbound URL fetches. Pinned so a future test +/// can assert on it; remote operators looking at access logs see a single +/// stable identifier instead of reqwest's default. +const URL_FETCH_USER_AGENT: &str = concat!("openfang-channels-discord/", env!("CARGO_PKG_VERSION")); + +// --- ANAI-55 recipient resolution ------------------------------------------ +/// Dedicated `tracing` target for the recipient-resolution audit log. +/// +/// Kept separate from the message-payload log target so a log scrape cannot +/// trivially correlate "agent X resolved recipient Y" with the message body +/// (security review observability ask). +#[cfg(debug_assertions)] +const RESOLVER_AUDIT_TARGET: &str = "openfang_channels::discord::resolver_audit"; + +/// Window for the resolution-failure burst counter. +const RESOLVER_FAIL_WINDOW: Duration = Duration::from_secs(60); + +/// Failures within `RESOLVER_FAIL_WINDOW` above this count emit a single +/// `warn!` (rate-limited via the deque so we don't spam at high rates). +const RESOLVER_FAIL_WARN_THRESHOLD: usize = 20; /// Discord Gateway opcodes. mod opcode { @@ -51,6 +112,236 @@ fn build_heartbeat_payload(last_sequence: Option) -> serde_json::Value { }) } +/// Format a URL for log/error messages with the query string and fragment +/// stripped. Discord CDN URLs carry HMAC-style query params (`ex`, `is`, `hm`, +/// `__cf_bm`) that grant time-limited access; logging them at warn/error level +/// would leak credential-equivalent material into operator log aggregators. +fn redact_url(u: &Url) -> String { + format!( + "{}://{}{}", + u.scheme(), + u.host_str().unwrap_or(""), + u.path() + ) +} + +/// Returns true if the IP address is one we refuse to fetch from to prevent +/// SSRF: loopback, RFC1918 / link-local / unique-local, multicast, unspecified, +/// or the literal cloud-metadata IP. IPv4-mapped IPv6 addresses are unwrapped +/// to their underlying v4 and re-checked. +fn is_blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_blocked_v4(v4), + IpAddr::V6(v6) => { + // Strip IPv4-mapped wrappers (::ffff:a.b.c.d) before re-checking. + // `Ipv6Addr::to_ipv4_mapped` is stable as of 1.63 but we use the + // older `to_ipv4` which also covers IPv4-compatible ::a.b.c.d. + if let Some(v4) = v6.to_ipv4() { + if is_blocked_v4(v4) { + return true; + } + } + if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() { + return true; + } + // Link-local fe80::/10. `Ipv6Addr::is_unicast_link_local` is + // unstable; check the prefix manually. + let seg0 = v6.segments()[0]; + if (seg0 & 0xffc0) == 0xfe80 { + return true; + } + // Unique local fc00::/7. + if (seg0 & 0xfe00) == 0xfc00 { + return true; + } + false + } + } +} + +fn is_blocked_v4(v4: Ipv4Addr) -> bool { + if v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_multicast() + || v4.is_broadcast() + { + return true; + } + // 169.254.169.254 is technically link-local (covered above) but make the + // intent explicit — a future stdlib change to `is_link_local` shouldn't + // silently re-open cloud-metadata exfiltration. + if v4.octets() == [169, 254, 169, 254] { + return true; + } + // Carrier-grade NAT 100.64.0.0/10. Not in `Ipv4Addr::is_private` but is + // commonly internal. + if v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40 { + return true; + } + false +} + +/// Synchronous SSRF check on a parsed URL: scheme allowlist + literal-IP host +/// range check. Hostname (DNS) resolution is the caller's responsibility (see +/// [`resolve_and_check_host`]); this function intentionally avoids DNS so it +/// can run inside the sync `redirect::Policy::custom` callback on every hop. +/// +/// Threat model note: the redirect callback can only do this literal-IP +/// recheck, not DNS, because reqwest's redirect policy is sync. A malicious +/// DNS server that returns a public IP at first lookup and a private IP on a +/// second lookup is *not* in the threat model — the threat is a malicious URL +/// the agent was tricked into emitting. Literal-IP redirects are still +/// blocked at every hop, which closes the most obvious bypass. +fn check_url_scheme_and_literal_ip(u: &Url) -> Result<(), String> { + match u.scheme() { + "http" | "https" => {} + other => { + return Err(format!( + "URL fetch refused: scheme {other:?} not allowed (need http/https) for {}", + redact_url(u) + )); + } + } + if let Some(host) = u.host() { + match host { + url::Host::Ipv4(v4) => { + if is_blocked_v4(v4) { + return Err(format!( + "URL fetch refused: blocked IPv4 host for {}", + redact_url(u) + )); + } + } + url::Host::Ipv6(v6) => { + if is_blocked_ip(IpAddr::V6(v6)) { + return Err(format!( + "URL fetch refused: blocked IPv6 host for {}", + redact_url(u) + )); + } + } + url::Host::Domain(_) => {} + } + } else { + return Err(format!( + "URL fetch refused: missing host for {}", + redact_url(u) + )); + } + Ok(()) +} + +/// Typed intermediate produced by the single classification pass over a +/// `ChannelContent::Multipart`'s blocks. Carrying enough information per +/// variant lets the subsequent resolve step operate on this enum alone +/// without a second walk over the original `Vec`. +enum AttachmentSource { + /// Already fully-resolved attachment (came from a `FileData` block). + Resolved { + bytes: bytes::Bytes, + filename: String, + mime: String, + }, + /// URL-backed image; `Fetcher` resolves the bytes, then + /// `resolve_image_mime` / `resolve_image_filename` derive the metadata. + UrlImage { url: String }, + /// URL-backed file with caller-supplied filename/mime hints; `Fetcher` + /// resolves the bytes, then `resolve_file_mime` / `resolve_file_filename` + /// reconcile against the response Content-Type. + UrlFile { + url: String, + filename: String, + mime: Option, + }, +} + +/// Abstraction over "fetch a URL into memory" so production and tests share +/// the same wire-level HTTP code while differing only in whether SSRF +/// validation runs first. +/// +/// - [`ProductionFetcher`] performs scheme + DNS-resolved IP checks via +/// [`resolve_and_check_host`] before issuing the request. +/// - [`PermissiveFetcher`] (test-only) skips the SSRF preflight so tests +/// can hit `127.0.0.1` fixture servers via the same wire path. +/// +/// Returns the body as `Bytes` plus the response's `Content-Type` with any +/// MIME parameters (e.g. `; charset=utf-8`) stripped. +#[async_trait] +trait Fetcher: Send + Sync { + async fn fetch( + &self, + url: &str, + ) -> Result<(bytes::Bytes, Option), Box>; +} + +/// Production fetcher: parses the URL, runs the SSRF preflight, then performs +/// the HTTP fetch via [`do_http_fetch`]. +struct ProductionFetcher; + +#[async_trait] +impl Fetcher for ProductionFetcher { + async fn fetch( + &self, + url: &str, + ) -> Result<(bytes::Bytes, Option), Box> { + let parsed = Url::parse(url).map_err(|e| format!("URL fetch refused: parse error: {e}"))?; + resolve_and_check_host(&parsed).await?; + do_http_fetch(&parsed).await + } +} + +/// Test-only fetcher that performs the same wire fetch but skips the SSRF +/// preflight, so tests can point `Image{url}` / `File{url}` blocks at local +/// stub servers without bypassing the production code path. +#[cfg(test)] +struct PermissiveFetcher; + +#[cfg(test)] +#[async_trait] +impl Fetcher for PermissiveFetcher { + async fn fetch( + &self, + url: &str, + ) -> Result<(bytes::Bytes, Option), Box> { + let parsed = Url::parse(url).map_err(|e| format!("URL fetch refused: parse error: {e}"))?; + do_http_fetch(&parsed).await + } +} + +/// Resolve the URL's host (DNS if hostname; identity if IP literal) and reject +/// if any resolved address fails the SSRF check. Performs both the scheme +/// check and the per-IP check. +async fn resolve_and_check_host(u: &Url) -> Result<(), String> { + check_url_scheme_and_literal_ip(u)?; + let host = match u.host() { + Some(url::Host::Domain(d)) => d.to_string(), + // IP literals already passed the literal-IP check above; no DNS needed. + Some(_) => return Ok(()), + None => { + return Err(format!( + "URL fetch refused: missing host for {}", + redact_url(u) + )) + } + }; + let port = u.port_or_known_default().unwrap_or(0); + let hostport = format!("{host}:{port}"); + let addrs = tokio::net::lookup_host(hostport.as_str()) + .await + .map_err(|_| format!("URL fetch refused: DNS lookup failed for {}", redact_url(u)))?; + for sa in addrs { + if is_blocked_ip(sa.ip()) { + return Err(format!( + "URL fetch refused: host resolved to blocked address for {}", + redact_url(u) + )); + } + } + Ok(()) +} + /// Discord Gateway adapter using WebSocket. pub struct DiscordAdapter { /// SECURITY: Bot token is zeroized on drop to prevent memory disclosure. @@ -77,6 +368,36 @@ pub struct DiscordAdapter { /// Populated immediately when MESSAGE_CREATE is forwarded — before bridge processing — /// to eliminate the race window where MESSAGE_UPDATE arrives before thread creation completes. threaded_message_ids: Arc>>, + + // --- ANAI-55 recipient resolution caches ------------------------------- + /// `username` → `user_id`. Populated passively on every inbound + /// MESSAGE_CREATE. Services `@username` DM resolution only — bare names + /// are refused (see [`ResolutionError::BareNameDmRefused`]). + /// Last-write-wins on collision; overwrites emit a `debug!` line. + username_to_user_id: Arc>>, + + /// `(guild_id, channel_name)` → `channel_id`. Keyed by guild to prevent + /// cross-guild `#name` collisions (security F2). Populated from + /// GUILD_CREATE, GUILD_UPDATE, CHANNEL_CREATE, CHANNEL_UPDATE; evicted + /// on CHANNEL_DELETE. + channel_name_to_channel_id: Arc>>, + + /// `user_id` → DM channel id. Populated on first successful DM-open via + /// `POST /users/@me/channels`. 4xx fails closed; no auto-retry. + user_id_to_dm_channel_id: Arc>>, + + /// Sliding-window timestamps of recent resolution failures (used for the + /// burst-warn defense-in-depth signal). + resolution_failures: Arc>>, + /// Override for the Discord REST API base URL. `None` in production (uses + /// `DISCORD_API_BASE`). Set by tests that spin up a local stub server. + #[cfg(test)] + api_base_override: Option, + /// Resolver for outbound URL fetches (`Image{url}` / `File{url}`). In + /// production this is [`ProductionFetcher`] which runs the SSRF preflight; + /// tests can swap in [`PermissiveFetcher`] to point at local stubs without + /// bypassing the wire path. + fetcher: Arc, } impl DiscordAdapter { @@ -104,9 +425,31 @@ impl DiscordAdapter { resume_gateway_url: Arc::new(RwLock::new(None)), created_thread_ids: Arc::new(RwLock::new(HashMap::new())), threaded_message_ids: Arc::new(RwLock::new(HashSet::new())), + username_to_user_id: Arc::new(RwLock::new(HashMap::new())), + channel_name_to_channel_id: Arc::new(RwLock::new(HashMap::new())), + user_id_to_dm_channel_id: Arc::new(RwLock::new(HashMap::new())), + resolution_failures: Arc::new(RwLock::new(VecDeque::new())), + #[cfg(test)] + api_base_override: None, + fetcher: Arc::new(ProductionFetcher), } } + /// Returns the Discord REST API base URL, honouring the test override when + /// present. In production this is always `DISCORD_API_BASE`. + #[cfg(test)] + fn api_base(&self) -> &str { + self.api_base_override + .as_deref() + .unwrap_or(DISCORD_API_BASE) + } + + #[cfg(not(test))] + #[inline(always)] + fn api_base(&self) -> &str { + DISCORD_API_BASE + } + /// Get the WebSocket gateway URL from the Discord API. async fn get_gateway_url(&self) -> Result> { let url = format!("{DISCORD_API_BASE}/gateway/bot"); @@ -132,7 +475,7 @@ impl DiscordAdapter { channel_id: &str, text: &str, ) -> Result<(), Box> { - let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages"); + let url = format!("{}/channels/{channel_id}/messages", self.api_base()); let chunks = split_message(text, DISCORD_MSG_LIMIT); for chunk in chunks { @@ -153,9 +496,204 @@ impl DiscordAdapter { Ok(()) } + /// Send a message carrying an action row of buttons (ANAI-82). + /// + /// Approval prompts are short and never split, so this sends a single + /// message with a `components` payload. Discord caps an action row at 5 + /// buttons and `custom_id` at 100 chars; `build_action_rows` chunks to fit. + async fn api_send_interactive( + &self, + channel_id: &str, + text: &str, + buttons: &[InteractiveButton], + ) -> Result<(), Box> { + let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/messages"); + let body = serde_json::json!({ + "content": text, + "components": build_action_rows(buttons), + }); + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bot {}", self.token.as_str())) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let body_text = resp.text().await.unwrap_or_default(); + warn!("Discord interactive sendMessage failed: {body_text}"); + } + Ok(()) + } + /// Send a file attachment to a Discord channel via REST multipart upload. + /// + /// Thin wrapper around `api_send_attachments` for the common single-file + /// case. `Bytes::clone` is a refcount bump so passing through is free. + async fn api_send_attachment( + &self, + channel_id: &str, + data: impl Into, + filename: &str, + mime_type: &str, + caption: Option<&str>, + ) -> Result<(), Box> { + self.api_send_attachments( + channel_id, + vec![(data.into(), filename.to_string(), mime_type.to_string())], + caption, + ) + .await + } + + /// Send one or more file attachments in a single multipart POST. + /// + /// Builds a `multipart/form-data` request with `payload_json` plus + /// `files[0]`…`files[N-1]` parts (N ≤ 10, per Discord's limit). The + /// caller is responsible for chunking larger batches. + /// + /// On HTTP 429 we honor `Retry-After` once before giving up. Higher-tier + /// rate-limit handling can land later if needed. + async fn api_send_attachments( + &self, + channel_id: &str, + attachments: Vec<(bytes::Bytes, String, String)>, + caption: Option<&str>, + ) -> Result<(), Box> { + let url = format!("{}/channels/{channel_id}/messages", self.api_base()); + + // Discord caps message content at DISCORD_MSG_LIMIT chars; truncate + // explicitly so a long caption doesn't silently 400. + let payload_json = build_attachment_payload_json(caption); + + // Pre-compute lengths. `Bytes::clone` is a refcount bump so the + // retry-path form rebuild is allocation-free for the file data. + let parts_meta: Vec<(bytes::Bytes, u64, String, String)> = attachments + .into_iter() + .map(|(b, name, mime)| { + let len = b.len() as u64; + (b, len, name, mime) + }) + .collect(); + + let build_form = || -> Result> { + let mut form = + reqwest::multipart::Form::new().text("payload_json", payload_json.clone()); + for (i, (bytes, body_len, filename, mime_type)) in parts_meta.iter().enumerate() { + let body = reqwest::Body::from(bytes.clone()); + let file_part = reqwest::multipart::Part::stream_with_length(body, *body_len) + .file_name(filename.clone()) + .mime_str(mime_type)?; + // Discord requires field names `files[0]`, `files[1]`, etc. + // The wire format is pinned by `test_attachment_field_name_pinned` + // (asserts `format!("files[{}]", 0) == ATTACHMENT_FIELD_NAME`). + let field_name = format!("files[{i}]"); + form = form.part(field_name, file_part); + } + Ok(form) + }; + + let mut attempts = 0u8; + loop { + attempts += 1; + let form = build_form()?; + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bot {}", self.token.as_str())) + .multipart(form) + .send() + .await?; + + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + + // Honor Retry-After once on 429. Discord puts the canonical + // `retry_after` in the JSON body; the HTTP header is a fallback + // (and is sometimes absent on per-route limits). + if status == reqwest::StatusCode::TOO_MANY_REQUESTS && attempts == 1 { + let header_secs = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + let body_text = resp.text().await.unwrap_or_default(); + let body_secs = serde_json::from_str::(&body_text) + .ok() + .and_then(|v| v.get("retry_after").and_then(|r| r.as_f64())); + let retry_after_secs = body_secs + .or(header_secs) + .unwrap_or(1.0) + .clamp(RETRY_AFTER_FLOOR_SECS, RETRY_AFTER_CEIL_SECS); + warn!( + "Discord sendAttachments rate-limited; retrying after {retry_after_secs:.2}s" + ); + tokio::time::sleep(Duration::from_millis((retry_after_secs * 1000.0) as u64)).await; + continue; + } + + let body_text = resp.text().await.unwrap_or_default(); + warn!("Discord sendAttachments failed ({status}): {body_text}"); + return Err(format!("Discord sendAttachments failed ({status}): {body_text}").into()); + } + } + + /// Resolve a single [`AttachmentSource`] into the + /// `(bytes, filename, mime)` tuple consumed by `api_send_attachments`. + /// + /// `Resolved` returns immediately; URL variants delegate to `Fetcher` and + /// then run their respective resolver chains. Errors are wrapped with the + /// `"Multipart fetch failed for {url}: …"` prefix the existing tests pin. + /// + /// The error type is `Box` (not the looser + /// `Box` used elsewhere) so the resulting future is `Send` — + /// required by `try_join_all` in the multipart resolve step. The + /// conversion to `Box` happens implicitly at the call site + /// via `?`. + async fn resolve_attachment_source( + &self, + source: AttachmentSource, + ) -> Result<(bytes::Bytes, String, String), Box> { + match source { + AttachmentSource::Resolved { + bytes, + filename, + mime, + } => Ok((bytes, filename, mime)), + AttachmentSource::UrlImage { url } => { + // `Fetcher::fetch` returns `Box` (no Send); + // stringify so the error becomes `Send + Sync` for `?`. + let (bytes, response_ct) = self + .fetcher + .fetch(&url) + .await + .map_err(|e| format!("Multipart fetch failed for {url}: {e}"))?; + let resolved_mime = resolve_image_mime(response_ct.as_deref(), &url); + let resolved_filename = resolve_image_filename(&url, &resolved_mime); + Ok((bytes, resolved_filename, resolved_mime)) + } + AttachmentSource::UrlFile { + url, + filename, + mime, + } => { + let (bytes, response_ct) = self + .fetcher + .fetch(&url) + .await + .map_err(|e| format!("Multipart fetch failed for {url}: {e}"))?; + let resolved_filename = resolve_file_filename(Some(filename.as_str()), &url); + let resolved_mime = + resolve_file_mime(mime.as_deref(), response_ct.as_deref(), &resolved_filename); + Ok((bytes, resolved_filename, resolved_mime)) + } + } + } + /// Send typing indicator to a Discord channel. async fn api_send_typing(&self, channel_id: &str) -> Result<(), Box> { - let url = format!("{DISCORD_API_BASE}/channels/{channel_id}/typing"); + let url = format!("{}/channels/{channel_id}/typing", self.api_base()); let _ = self .client .post(&url) @@ -165,6 +703,182 @@ impl DiscordAdapter { Ok(()) } + /// Open (or fetch) the DM channel for a given user snowflake. + /// + /// On success returns the DM channel id and inserts it into + /// `user_id_to_dm_channel_id` for future calls. On HTTP 4xx the status + /// code is returned to the caller for fail-closed handling — no + /// auto-retry (see ANAI-55 §3.2 step 6). Transport failures map to `0`. + async fn api_open_dm_channel(&self, user_id: &str) -> Result { + let url = format!("{DISCORD_API_BASE}/users/@me/channels"); + let body = serde_json::json!({ "recipient_id": user_id }); + let resp = match self + .client + .post(&url) + .header("Authorization", format!("Bot {}", self.token.as_str())) + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(e) => { + warn!("discord DM-open transport error for {user_id}: {e}"); + return Err(0); + } + }; + let status = resp.status().as_u16(); + if !resp.status().is_success() { + return Err(status); + } + let v: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + warn!("discord DM-open returned non-JSON for {user_id}: {e}"); + return Err(status); + } + }; + let dm_id = match v["id"].as_str() { + Some(s) => s.to_string(), + None => { + warn!("discord DM-open missing `id` field for {user_id}"); + return Err(status); + } + }; + self.user_id_to_dm_channel_id + .write() + .await + .insert(user_id.to_string(), dm_id.clone()); + Ok(dm_id) + } + + /// ANAI-55 resolver body. Returns `(platform_id, via_label)` on success. + async fn resolve_recipient_inner( + &self, + recipient: &str, + ) -> Result<(String, &'static str), ResolutionError> { + let r = recipient.trim(); + if r.is_empty() { + return Err(ResolutionError::UnknownRecipient { + recipient: recipient.to_string(), + }); + } + + // 1. Raw snowflake. + if is_snowflake(r) { + return Ok((r.to_string(), "snowflake")); + } + + // 2. Channel mention <#id>. + if let Some(id) = strip_channel_mention(r) { + if is_snowflake(&id) { + return Ok((id, "channel_mention")); + } + } + + // 3. User mention <@id> / <@!id> — DM-open with the id. + if let Some(id) = strip_user_mention(r) { + if is_snowflake(&id) { + return self + .resolve_user_id_to_dm(&id) + .await + .map(|d| (d, "user_mention+dm_open")); + } + } + + // 4. `#name` or bare channel-shaped name. + let (had_hash, name_part) = if let Some(stripped) = r.strip_prefix('#') { + (true, stripped) + } else { + (false, r) + }; + + if !name_part.is_empty() && is_channel_name_shape(name_part) { + let cache = self.channel_name_to_channel_id.read().await; + let mut hits: Vec<(String, String)> = cache + .iter() + .filter(|((_gid, n), _cid)| n == name_part) + .map(|((gid, _n), cid)| (gid.clone(), cid.clone())) + .collect(); + drop(cache); + match hits.len() { + 1 => { + let (_gid, cid) = hits.pop().unwrap(); + return Ok((cid, "channel_name")); + } + n if n > 1 => { + let guilds: Vec = hits.into_iter().map(|(g, _)| g).collect(); + return Err(ResolutionError::AmbiguousChannel { + name: name_part.to_string(), + guilds, + }); + } + _ => { + if had_hash { + return Err(ResolutionError::UnknownRecipient { + recipient: recipient.to_string(), + }); + } + } + } + } + + // 5. `@username` — DM path. + if let Some(uname) = r.strip_prefix('@') { + if !uname.is_empty() && is_username_shape(uname) { + let user_id = self.username_to_user_id.read().await.get(uname).cloned(); + return match user_id { + Some(uid) => self + .resolve_user_id_to_dm(&uid) + .await + .map(|d| (d, "username_cache+dm_open")), + None => Err(ResolutionError::UnknownRecipient { + recipient: recipient.to_string(), + }), + }; + } + } + + // 6. Bare name — refused as a DM target (ANAI-55 F1). + Err(ResolutionError::BareNameDmRefused { + name: r.to_string(), + }) + } + + /// Step 6 of the resolver: cache-or-open the DM channel for a user id. + async fn resolve_user_id_to_dm(&self, user_id: &str) -> Result { + if let Some(dm) = self + .user_id_to_dm_channel_id + .read() + .await + .get(user_id) + .cloned() + { + return Ok(dm); + } + self.api_open_dm_channel(user_id) + .await + .map_err(|status| ResolutionError::DmOpenFailed { + user_id: user_id.to_string(), + status, + }) + } + + /// Record a resolution failure for the sliding-window burst-warn signal. + /// Returns `true` the single time the threshold is crossed within a window. + async fn note_resolution_failure(&self) -> bool { + let now = Instant::now(); + let mut q = self.resolution_failures.write().await; + while let Some(front) = q.front() { + if now.duration_since(*front) > RESOLVER_FAIL_WINDOW { + q.pop_front(); + } else { + break; + } + } + q.push_back(now); + q.len() == RESOLVER_FAIL_WARN_THRESHOLD + 1 + } + /// Create a thread from a message in a Discord channel. async fn api_create_thread( &self, @@ -239,6 +953,122 @@ impl DiscordAdapter { } } +/// Wire-level HTTP fetch shared by [`ProductionFetcher`] and +/// [`PermissiveFetcher`]. Assumes the caller has already done any SSRF +/// preflight on `parsed`. Performs: +/// +/// 1. Per-request reqwest client with a redirect policy that caps at +/// [`URL_FETCH_MAX_REDIRECTS`] hops and re-applies the literal-IP SSRF +/// check on every hop's URL. +/// 2. Two-stage size enforcement: Content-Length pre-flight, then streaming +/// chunk accumulation that aborts mid-stream on overrun. +/// +/// Errors are scrubbed via [`redact_url`] so Discord CDN HMAC params don't +/// land in operator logs. +async fn do_http_fetch( + parsed: &Url, +) -> Result<(bytes::Bytes, Option), Box> { + // Per-request client with a custom redirect policy. We cannot reuse + // a shared client because its redirect policy is fixed at build time. + let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= URL_FETCH_MAX_REDIRECTS { + return attempt.error(format!("redirect cap ({URL_FETCH_MAX_REDIRECTS}) exceeded")); + } + // Sync context: we can only do the literal-IP recheck here; DNS + // requires async. The original hostname was DNS-checked before + // the request started, so the only new bypass to close at this + // layer is a redirect to a literal private IP. + if let Err(e) = check_url_scheme_and_literal_ip(attempt.url()) { + return attempt.error(e); + } + attempt.follow() + }); + let client = reqwest::Client::builder() + .redirect(redirect_policy) + .user_agent(URL_FETCH_USER_AGENT) + .timeout(URL_FETCH_TIMEOUT) + .build()?; + + let resp = client.get(parsed.as_str()).send().await.map_err(|e| { + // reqwest's Display impl for Error includes the URL it was + // fetching (with query string). Replace it with the redacted + // form to keep CDN HMAC params out of error logs. + // + // For redirect-policy errors, reqwest's outer Display is the + // generic "error following redirect"; the actual cause (e.g. + // "URL fetch refused: blocked IPv4 host for ...") lives in the + // source chain. Walk it so the operator sees *why* we refused. + let stripped = e.without_url(); + let mut msg = stripped.to_string(); + let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&stripped); + while let Some(s) = src { + use std::fmt::Write as _; + let _ = write!(msg, ": {s}"); + src = s.source(); + } + format!("URL fetch failed for {}: {msg}", redact_url(parsed)) + })?; + + let status = resp.status(); + if !status.is_success() { + // Read up to 512B of the body for diagnostics; ignore errors. + let snippet: String = resp + .text() + .await + .unwrap_or_default() + .chars() + .take(512) + .collect(); + return Err(format!( + "URL fetch failed ({status}) for {}: {snippet}", + redact_url(parsed) + ) + .into()); + } + + // Pre-flight: trust Content-Length when present so we can fail fast + // without buffering 26 MiB before erroring. + let content_length = resp.content_length(); + if let Some(len) = content_length { + if len as usize > URL_FETCH_MAX_BYTES { + return Err(format!( + "URL fetch refused: Content-Length {len} exceeds cap {URL_FETCH_MAX_BYTES} for {}", + redact_url(parsed) + ) + .into()); + } + } + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(strip_mime_params) + .filter(|s| !s.is_empty()); + + // Pre-size the buffer: if we have a trustworthy Content-Length, use it + // (clamped to the cap); otherwise start at 64 KiB so the happy path + // doesn't pay ~24 doublings on a 25 MiB body. + let initial_cap = std::cmp::min( + content_length.unwrap_or(64 * 1024) as usize, + URL_FETCH_MAX_BYTES, + ); + let mut buf = bytes::BytesMut::with_capacity(initial_cap); + let mut resp = resp; + while let Some(chunk) = resp.chunk().await? { + if buf.len() + chunk.len() > URL_FETCH_MAX_BYTES { + return Err(format!( + "URL fetch refused: streamed body exceeds cap {URL_FETCH_MAX_BYTES} for {}", + redact_url(parsed) + ) + .into()); + } + buf.extend_from_slice(&chunk); + } + + Ok((buf.freeze(), content_type)) +} + #[async_trait] impl ChannelAdapter for DiscordAdapter { fn name(&self) -> &str { @@ -295,6 +1125,8 @@ impl ChannelAdapter for DiscordAdapter { let resume_url_store = self.resume_gateway_url.clone(); let created_thread_ids = self.created_thread_ids.clone(); let threaded_message_ids = self.threaded_message_ids.clone(); + let username_to_user_id = self.username_to_user_id.clone(); + let channel_name_to_channel_id = self.channel_name_to_channel_id.clone(); let mut shutdown = self.shutdown_rx.clone(); tokio::spawn(async move { @@ -527,6 +1359,28 @@ impl ChannelAdapter for DiscordAdapter { } "MESSAGE_CREATE" | "MESSAGE_UPDATE" => { + // Passively populate the username cache + // for ANAI-55 `@username` resolution. + // Done before parse so we still learn the + // mapping even when the message itself is + // dropped by allow-list filters downstream. + if event_name == "MESSAGE_CREATE" { + if let (Some(uname), Some(uid)) = ( + d["author"]["username"].as_str(), + d["author"]["id"].as_str(), + ) { + let mut cache = username_to_user_id.write().await; + if let Some(prev) = cache.get(uname) { + if prev != uid { + debug!( + "discord username cache overwrite: {uname} {prev} -> {uid}" + ); + } + } + cache.insert(uname.to_string(), uid.to_string()); + } + } + if let Some(msg) = parse_discord_message( d, &bot_user_id, @@ -577,19 +1431,73 @@ impl ChannelAdapter for DiscordAdapter { } } - "THREAD_DELETE" | "CHANNEL_DELETE" => { - // Clean up tracking when a thread is deleted so the - // next message in the parent channel is treated fresh. + "THREAD_DELETE" => { if let Some(tid) = d["id"].as_str() { created_thread_ids.write().await.remove(tid); - // Prune the dedup set to prevent unbounded growth. - // Entries older than MAX_DEDUP_MSG_IDS are safe to - // discard — embed UPDATE events arrive within seconds. let mut ids = threaded_message_ids.write().await; if ids.len() > MAX_DEDUP_MSG_IDS { ids.clear(); } - debug!("Discord thread/channel deleted: {tid}"); + debug!("Discord thread deleted: {tid}"); + } + } + + "CHANNEL_DELETE" => { + if let Some(cid) = d["id"].as_str() { + created_thread_ids.write().await.remove(cid); + let mut ids = threaded_message_ids.write().await; + if ids.len() > MAX_DEDUP_MSG_IDS { + ids.clear(); + } + debug!("Discord channel deleted: {cid}"); + } + // Evict from the (guild_id, name) → channel_id + // cache so ANAI-55 `#name` resolution doesn't keep + // routing to a dead channel. + if let (Some(gid), Some(name)) = + (d["guild_id"].as_str(), d["name"].as_str()) + { + channel_name_to_channel_id + .write() + .await + .remove(&(gid.to_string(), name.to_string())); + } + } + + "GUILD_CREATE" | "GUILD_UPDATE" => { + // Populate (guild_id, channel_name) → channel_id from + // the GUILD payload. Requires the GUILDS gateway + // intent (bit 0); default Discord adapter intents + // include it post-ANAI-55. + if let (Some(gid), Some(channels)) = + (d["id"].as_str(), d["channels"].as_array()) + { + let mut cache = channel_name_to_channel_id.write().await; + for ch in channels { + if let (Some(cname), Some(cid)) = + (ch["name"].as_str(), ch["id"].as_str()) + { + cache.insert( + (gid.to_string(), cname.to_string()), + cid.to_string(), + ); + } + } + } + } + + "CHANNEL_CREATE" | "CHANNEL_UPDATE" => { + // Keep `(guild_id, name) → channel_id` fresh as + // channels are added or renamed. + if let (Some(gid), Some(cname), Some(cid)) = ( + d["guild_id"].as_str(), + d["name"].as_str(), + d["id"].as_str(), + ) { + channel_name_to_channel_id.write().await.insert( + (gid.to_string(), cname.to_string()), + cid.to_string(), + ); } } @@ -675,6 +1583,10 @@ impl ChannelAdapter for DiscordAdapter { Ok(Box::pin(stream)) } + fn supports_interactive(&self) -> bool { + true + } + async fn send( &self, user: &ChannelUser, @@ -686,6 +1598,181 @@ impl ChannelAdapter for DiscordAdapter { ChannelContent::Text(text) => { self.api_send_message(channel_id, &text).await?; } + ChannelContent::Interactive { text, buttons } => { + self.api_send_interactive(channel_id, &text, &buttons).await?; + } + ChannelContent::FileData { + data, + filename, + mime_type, + } => { + self.api_send_attachment(channel_id, data, &filename, &mime_type, None) + .await?; + } + ChannelContent::File { + url, + filename, + mime, + size: _, + } => { + // Fetch then route through the existing multipart helper. + // `Fetcher::fetch` enforces SSRF + 15s timeout + 25 MiB cap. + let (bytes, response_ct) = self.fetcher.fetch(&url).await?; + let resolved_filename = resolve_file_filename(Some(filename.as_str()), &url); + let resolved_mime = + resolve_file_mime(mime.as_deref(), response_ct.as_deref(), &resolved_filename); + // No caption on a bare File; captions travel via Multipart([Text, File]). + self.api_send_attachment( + channel_id, + bytes, + &resolved_filename, + &resolved_mime, + None, + ) + .await?; + } + ChannelContent::Image { url, caption } => { + let (bytes, response_ct) = self.fetcher.fetch(&url).await?; + let resolved_mime = resolve_image_mime(response_ct.as_deref(), &url); + let resolved_filename = resolve_image_filename(&url, &resolved_mime); + let caption_ref = caption.as_deref().filter(|s| !s.is_empty()); + self.api_send_attachment( + channel_id, + bytes, + &resolved_filename, + &resolved_mime, + caption_ref, + ) + .await?; + } + ChannelContent::Multipart(parts) => { + // Single pass over `parts`: bucket each block into caption + // pieces, an `AttachmentSource` for later resolution, or a + // logged-unknown name. The two-pass classify/resolve split is + // collapsed by carrying enough info on `AttachmentSource` for + // the resolve step to operate on the typed intermediate alone. + let mut caption_pieces: Vec = Vec::new(); + let mut sources: Vec = Vec::with_capacity(parts.len()); + let mut unknown_names: Vec<&str> = Vec::new(); + + for part in parts { + match part { + ChannelContent::Text(t) => caption_pieces.push(t), + ChannelContent::FileData { + data, + filename, + mime_type, + } => sources.push(AttachmentSource::Resolved { + bytes: bytes::Bytes::from(data), + filename, + mime: mime_type, + }), + // Per-Image inner captions are ignored inside Multipart; + // the outer caption_pieces form the single caption. + ChannelContent::Image { url, caption: _ } => { + sources.push(AttachmentSource::UrlImage { url }) + } + ChannelContent::File { + url, + filename, + mime, + size: _, + } => sources.push(AttachmentSource::UrlFile { + url, + filename, + mime, + }), + ChannelContent::Voice { .. } => unknown_names.push("Voice"), + ChannelContent::Location { .. } => unknown_names.push("Location"), + ChannelContent::Command { .. } => unknown_names.push("Command"), + ChannelContent::Multipart(_) => unknown_names.push("Multipart"), + ChannelContent::Interactive { .. } => unknown_names.push("Interactive"), + } + } + + if !unknown_names.is_empty() { + warn!( + "Discord Multipart: skipping unknown/unsupported nested variant(s): {:?}", + unknown_names + ); + } + + // Build the single caption string from all Text blocks. + let caption_str = caption_pieces.join("\n\n"); + let caption_str = caption_str.trim(); + let caption_opt: Option<&str> = if caption_str.is_empty() { + None + } else { + Some(caption_str) + }; + + // Resolve sources concurrently. `try_join_all` preserves + // input order in the output Vec (so `files[i]` still lines + // up with the source's original position) and fails fast on + // the first error, cancelling the rest — matching the spec + // and the previous serial behavior. For an N-URL Multipart + // this drops latency from sum-of-RTTs to max-of-RTT. + let attachments_resolved: Vec<(bytes::Bytes, String, String)> = + futures::future::try_join_all( + sources + .into_iter() + .map(|s| self.resolve_attachment_source(s)), + ) + .await + // Widen `Box` (needed so the + // resolve future is `Send` for `try_join_all`) to the + // looser `Box` returned by `send`. Unsizing + // a trait object by removing auto traits is allowed but + // not exposed via `From`, so we coerce explicitly. + .map_err(|e| -> Box { e })?; + + if attachments_resolved.is_empty() { + // Caption-only Multipart (all blocks were Text/unknown). + if let Some(cap) = caption_opt { + self.api_send_message(channel_id, cap).await?; + } else { + warn!("Discord Multipart: all blocks empty or unknown, nothing to send"); + } + return Ok(()); + } + + // Chunk by both count (≤ ATTACHMENTS_PER_CHUNK) and aggregate + // bytes (≤ CHUNK_TOTAL_CAP_BYTES) so a 10×3 MiB Multipart + // doesn't 413 on Discord's per-request size limit. Order is + // preserved; oversized single attachments get their own + // chunk (they'll still be rejected by Discord, but with the + // same error path as before this cap existed). + let chunks: Vec> = + chunk_attachments(attachments_resolved); + let total_chunks = chunks.len(); + for (i, chunk) in chunks.into_iter().enumerate() { + let chunk_caption = if i == 0 { caption_opt } else { None }; + if let Err(e) = self + .api_send_attachments(channel_id, chunk, chunk_caption) + .await + { + if i > 0 { + // Standalone WARN with structured fields so an + // operator grepping for "why are some files + // showing and some not?" can find this in one + // search instead of parsing prose. The failed + // chunk index is recoverable as `chunks_sent` + // (the count of chunks that succeeded before + // this one). + warn!( + event = "discord_multipart_partial_send", + chunks_sent = i, + chunks_total = total_chunks, + "discord multipart partial send: chunk {}/{} failed after {} chunk(s) already on the wire", + i + 1, + total_chunks, + i + ); + } + return Err(e); + } + } + } _ => { self.api_send_message(channel_id, "(Unsupported content type)") .await?; @@ -742,14 +1829,221 @@ impl ChannelAdapter for DiscordAdapter { let _ = self.shutdown_tx.send(true); Ok(()) } -} -/// Maximum byte size for an attachment to be classified as a vision-eligible + /// Discord recipient resolver (ANAI-55). + /// + /// Resolution order: snowflake → `<#…>` channel mention → `<@…>` user + /// mention → `#name` / single-token channel name → `@username` → bare + /// name (refused). See `2026-05-28-anai-55-proposal-v2.md` §3.2. + async fn resolve_recipient(&self, recipient: &str) -> Result { + match self.resolve_recipient_inner(recipient).await { + Ok((platform_id, via)) => { + // Dedicated audit sink: bypasses tracing entirely so a log + // scrape on stderr/tui.log cannot correlate this event with + // adjacent payload logs by timestamp (ANAI-55 security ask). + crate::resolver_audit::record_resolution("discord", recipient, &platform_id, via); + // Demoted from info! to debug! — production EnvFilter (info) + // emits zero correlatable event to the main log; dev mode + // opts in via RUST_LOG=...resolver_audit=debug. + #[cfg(debug_assertions)] + debug!( + target: RESOLVER_AUDIT_TARGET, + "recipient_resolved adapter=discord input={:?} resolved_platform_id={} via={}", + recipient, platform_id, via + ); + Ok(ChannelUser { + platform_id, + display_name: recipient.to_string(), + openfang_user: None, + }) + } + Err(e) => { + if self.note_resolution_failure().await { + warn!( + "discord resolver: >{} failures in last {}s (most recent: {})", + RESOLVER_FAIL_WARN_THRESHOLD, + RESOLVER_FAIL_WINDOW.as_secs(), + e + ); + } + Err(e) + } + } + } +} + +/// Maximum byte size for an attachment to be classified as a vision-eligible /// image. Anthropic's image content blocks are capped at 5 MB; oversize images /// fall through to `File` so the bridge passes the URL as text instead of /// attempting an inline image block. const VISION_IMAGE_MAX_BYTES: u64 = 5 * 1024 * 1024; +/// Build the `payload_json` body for an outbound attachment request. +/// +/// Discord's `POST /channels/{id}/messages` multipart endpoint expects a +/// `payload_json` part containing the same JSON the JSON-only variant would +/// take. Captions longer than `DISCORD_MSG_LIMIT` chars must be truncated +/// explicitly; otherwise the API responds 400 and silently drops the upload. +/// Greedy-pack attachments into chunks subject to two caps: +/// +/// 1. At most [`ATTACHMENTS_PER_CHUNK`] entries per chunk (Discord's +/// `files[N]` limit). +/// 2. At most [`CHUNK_TOTAL_CAP_BYTES`] aggregate bytes per chunk (Discord's +/// ~25 MiB request size limit, with headroom for multipart overhead). +/// +/// Order of inputs is preserved across the output. If a single attachment +/// alone exceeds the byte cap, it lands in its own chunk and is forwarded +/// untouched — Discord will reject the request, but that mirrors the +/// pre-existing behavior where the per-file cap was the only gate. +fn chunk_attachments( + attachments: Vec<(bytes::Bytes, String, String)>, +) -> Vec> { + let mut chunks: Vec> = Vec::new(); + let mut current: Vec<(bytes::Bytes, String, String)> = Vec::new(); + let mut current_bytes: usize = 0; + + for item in attachments { + let item_len = item.0.len(); + // Start a new chunk when adding this item would push us over the + // count or byte cap — but only if the current chunk isn't empty. + // (Empty + oversized item: keep going so we always make progress.) + let would_exceed_count = current.len() >= ATTACHMENTS_PER_CHUNK; + let would_exceed_bytes = current_bytes.saturating_add(item_len) > CHUNK_TOTAL_CAP_BYTES; + if !current.is_empty() && (would_exceed_count || would_exceed_bytes) { + chunks.push(std::mem::take(&mut current)); + current_bytes = 0; + } + current_bytes = current_bytes.saturating_add(item_len); + current.push(item); + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +fn build_attachment_payload_json(caption: Option<&str>) -> String { + match caption { + Some(c) if !c.is_empty() => { + let truncated: String = c.chars().take(DISCORD_MSG_LIMIT).collect(); + serde_json::json!({ "content": truncated }).to_string() + } + _ => serde_json::json!({}).to_string(), + } +} + +/// Strip MIME parameters (e.g. `; charset=utf-8`) so downstream comparisons +/// against canonical types like `image/png` work. Lower-cases and trims so +/// `IMAGE/PNG ; charset=utf-8` and `image/png` both normalize to `image/png`. +fn strip_mime_params(raw: &str) -> String { + raw.split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() +} + +/// Derive a filename from a URL path: take the segment after the last `/`, +/// drop any query/fragment, percent-decode best-effort. Returns None if the +/// URL has no useful path segment (e.g. `https://host/`). +fn derive_filename_from_url(url: &str) -> Option { + // Strip scheme://host. We only care about the path-ish suffix; doing + // this without a real URL parser keeps the helper dep-free and total + // (a malformed URL still gets a best-effort answer). + let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url); + let path = after_scheme.split_once('/').map(|(_, r)| r).unwrap_or(""); + // Drop query and fragment. + let path = path.split(['?', '#']).next().unwrap_or(""); + let last = path.rsplit('/').next().unwrap_or(""); + if last.is_empty() { + return None; + } + // Percent-decode best-effort; fall back to the raw segment on failure. + let decoded = percent_decode_lossy(last); + if decoded.is_empty() { + None + } else { + Some(decoded) + } +} + +/// Tiny percent-decoder. We don't pull in `percent-encoding` for this — the +/// adapter already avoids new deps and we only need it to prettify Discord +/// CDN paths like `photo%20final.png` → `photo final.png`. +fn percent_decode_lossy(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + 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; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Pick a filename for an outbound `File` arm. Preference order: explicit +/// `filename` field → URL path tail → `"file"`. +fn resolve_file_filename(field: Option<&str>, url: &str) -> String { + field + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| derive_filename_from_url(url)) + .unwrap_or_else(|| "file".to_string()) +} + +/// Pick a MIME for an outbound `File` arm. Preference order: explicit `mime` +/// field → response Content-Type → extension lookup from filename → +/// `application/octet-stream`. +fn resolve_file_mime(field: Option<&str>, response_ct: Option<&str>, filename: &str) -> String { + field + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| response_ct.map(str::to_string)) + .or_else(|| mime_from_extension(filename).map(str::to_string)) + .unwrap_or_else(|| "application/octet-stream".to_string()) +} + +/// Pick a filename for an outbound `Image` arm. Preference order: URL path +/// tail → `"image" + extension` inferred from the resolved MIME (default +/// `.png`). +fn resolve_image_filename(url: &str, resolved_mime: &str) -> String { + if let Some(name) = derive_filename_from_url(url) { + return name; + } + let ext = match resolved_mime { + "image/jpeg" => ".jpg", + "image/gif" => ".gif", + "image/webp" => ".webp", + "image/heic" => ".heic", + "image/heif" => ".heif", + _ => ".png", + }; + format!("image{ext}") +} + +/// Pick a MIME for an outbound `Image` arm. Preference order: response +/// Content-Type → extension lookup from URL tail → `image/png`. +fn resolve_image_mime(response_ct: Option<&str>, url: &str) -> String { + if let Some(ct) = response_ct.filter(|s| !s.is_empty()) { + return ct.to_string(); + } + if let Some(tail) = derive_filename_from_url(url) { + if let Some(ext) = mime_from_extension(&tail) { + return ext.to_string(); + } + } + "image/png".to_string() +} + /// Best-effort MIME inference from a filename extension. Used as a fallback /// when Discord's `content_type` field is missing or empty (we've observed /// this on some bot-relayed attachments). @@ -815,6 +2109,48 @@ fn classify_discord_attachment(att: &serde_json::Value) -> ChannelContent { } } +/// True for ASCII-digit strings 17-20 chars long (Discord snowflake shape). +fn is_snowflake(s: &str) -> bool { + let len = s.len(); + (17..=20).contains(&len) && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// Match `<#1234567890>` and return the inner digits. +fn strip_channel_mention(s: &str) -> Option { + let inner = s.strip_prefix("<#")?.strip_suffix('>')?; + if !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit()) { + Some(inner.to_string()) + } else { + None + } +} + +/// Match `<@1234567890>` or `<@!1234567890>` and return the inner digits. +fn strip_user_mention(s: &str) -> Option { + let inner = s.strip_prefix("<@")?.strip_suffix('>')?; + let inner = inner.strip_prefix('!').unwrap_or(inner); + if !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit()) { + Some(inner.to_string()) + } else { + None + } +} + +/// Discord channel-name charset: lowercase alphanumeric, `-`, `_`. +fn is_channel_name_shape(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') +} + +/// Discord username charset (post-discriminator world): lowercase +/// alphanumeric, `.`, `_`. +fn is_username_shape(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'_') +} + /// Parse a Discord MESSAGE_CREATE or MESSAGE_UPDATE payload into a `ChannelMessage`. async fn parse_discord_message( d: &serde_json::Value, @@ -1033,6 +2369,81 @@ mod tests { Arc::new(RwLock::new(HashMap::new())) } + #[test] + fn test_attachment_payload_no_caption() { + // No caption → empty JSON object so Discord doesn't reject it. + assert_eq!(build_attachment_payload_json(None), "{}"); + assert_eq!(build_attachment_payload_json(Some("")), "{}"); + } + + #[test] + fn test_attachment_payload_short_caption() { + let json = build_attachment_payload_json(Some("hello")); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["content"], "hello"); + } + + #[test] + fn test_attachment_payload_truncates_long_caption() { + // 3000 chars → must truncate to DISCORD_MSG_LIMIT (2000) so Discord + // accepts the request instead of 400-ing on a too-long content field. + let long = "a".repeat(3000); + let json = build_attachment_payload_json(Some(&long)); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + v["content"].as_str().unwrap().chars().count(), + DISCORD_MSG_LIMIT + ); + } + + #[test] + fn test_attachment_payload_truncation_is_char_safe() { + // Multibyte chars must not be split mid-codepoint. + let s: String = "héllo ".repeat(500); // 6 chars per chunk → 3000 chars total + let json = build_attachment_payload_json(Some(&s)); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + // Round-trip through serde guarantees we didn't produce invalid UTF-8. + assert_eq!( + v["content"].as_str().unwrap().chars().count(), + DISCORD_MSG_LIMIT + ); + } + + #[test] + fn test_attachment_field_name_pinned() { + // Discord rejects the upload silently if the multipart field isn't + // exactly `files[0]` (a `file[0]` typo would fail at runtime, per + // attachment, with no useful error). Pin the wire format here so a + // typo at the call site is impossible without also changing this test. + // Both invariants matter: the constant's literal value AND the + // `format!("files[{i}]", i=0)` we now use at the call site must agree. + assert_eq!(ATTACHMENT_FIELD_NAME, "files[0]"); + assert_eq!(format!("files[{}]", 0), ATTACHMENT_FIELD_NAME); + } + + #[test] + fn test_multipart_part_accepts_common_mimes() { + // Validate that mime_str() doesn't reject the MIME types we map from + // tool_runner.rs::channel_send. If any of these started failing we'd + // surface as a runtime upload error per file. + for mime in [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "application/json", + "application/octet-stream", + "video/mp4", + ] { + let part = reqwest::multipart::Part::bytes(b"x".to_vec()) + .file_name("f.bin") + .mime_str(mime); + assert!(part.is_ok(), "mime_str rejected {mime}"); + } + } + #[tokio::test] async fn test_parse_discord_message_basic() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); @@ -1625,6 +3036,453 @@ mod tests { assert!(matches!(msg.content, ChannelContent::Image { .. })); } + // -- Pure helper tests for File/Image arm fallback chains -------------- + + #[test] + fn test_strip_mime_params_basic() { + assert_eq!(strip_mime_params("image/png"), "image/png"); + assert_eq!(strip_mime_params("image/png; charset=utf-8"), "image/png"); + assert_eq!( + strip_mime_params(" IMAGE/PNG ; charset=utf-8 "), + "image/png" + ); + assert_eq!(strip_mime_params(""), ""); + } + + #[test] + fn test_derive_filename_from_url() { + assert_eq!( + derive_filename_from_url("https://cdn.example.com/a/b/photo.png"), + Some("photo.png".to_string()) + ); + assert_eq!( + derive_filename_from_url("https://cdn.example.com/a/b/photo.png?ex=1&hm=2"), + Some("photo.png".to_string()) + ); + assert_eq!( + derive_filename_from_url("https://cdn.example.com/a/photo%20final.png"), + Some("photo final.png".to_string()) + ); + // Trailing slash → no filename derivable. + assert_eq!(derive_filename_from_url("https://cdn.example.com/"), None); + assert_eq!(derive_filename_from_url("https://cdn.example.com"), None); + } + + #[test] + fn test_resolve_file_filename_chain() { + // Field wins over URL. + assert_eq!( + resolve_file_filename(Some("explicit.bin"), "https://x/y/url.dat"), + "explicit.bin" + ); + // Empty field → URL fallback. + assert_eq!( + resolve_file_filename(Some(""), "https://x/y/url.dat"), + "url.dat" + ); + // None → URL fallback. + assert_eq!( + resolve_file_filename(None, "https://x/y/url.dat"), + "url.dat" + ); + // No URL tail → "file". + assert_eq!(resolve_file_filename(None, "https://x/"), "file"); + } + + #[test] + fn test_resolve_file_mime_chain() { + // Field wins. + assert_eq!( + resolve_file_mime(Some("application/pdf"), Some("text/plain"), "f.txt"), + "application/pdf" + ); + // No field → response Content-Type. + assert_eq!( + resolve_file_mime(None, Some("text/plain"), "f.txt"), + "text/plain" + ); + // No field, no CT → extension lookup. + assert_eq!(resolve_file_mime(None, None, "f.pdf"), "application/pdf"); + // Nothing → default. + assert_eq!( + resolve_file_mime(None, None, "no-ext"), + "application/octet-stream" + ); + } + + #[test] + fn test_resolve_image_filename_chain() { + // URL tail wins. + assert_eq!( + resolve_image_filename("https://x/y/picture.jpg", "image/jpeg"), + "picture.jpg" + ); + // No URL tail → image + ext from MIME. + assert_eq!( + resolve_image_filename("https://x/", "image/jpeg"), + "image.jpg" + ); + assert_eq!( + resolve_image_filename("https://x/", "image/png"), + "image.png" + ); + assert_eq!( + resolve_image_filename("https://x/", "image/webp"), + "image.webp" + ); + // Unknown MIME → .png default. + assert_eq!( + resolve_image_filename("https://x/", "application/octet-stream"), + "image.png" + ); + } + + #[test] + fn test_resolve_image_mime_chain() { + // Response CT wins. + assert_eq!( + resolve_image_mime(Some("image/jpeg"), "https://x/y/foo.png"), + "image/jpeg" + ); + // No CT → URL extension. + assert_eq!(resolve_image_mime(None, "https://x/y/foo.png"), "image/png"); + // No CT, no extension → default. + assert_eq!(resolve_image_mime(None, "https://x/y/blob"), "image/png"); + } + + // -- Fetcher::fetch / do_http_fetch size-cap tests ---------------------- + + /// Spawn a hand-rolled HTTP server that replies with a fixed status, + /// optional Content-Length header (lying or omitted), and a body produced + /// by the supplied closure. This intentionally does NOT use axum's + /// `Body::from(Vec)` (which always sends the real Content-Length); we + /// need control over the header to exercise both pre-flight and streaming + /// rejection paths. + async fn spawn_raw_http_server( + status_line: &'static str, + content_length: Option<&'static str>, + body: bytes::Bytes, + ) -> String { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + // Accept exactly one connection — sufficient for a single + // do_http_fetch test invocation. + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + // Drain the request line + headers (best-effort; we don't parse). + let mut buf = [0u8; 4096]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; + let mut header = format!("HTTP/1.1 {status_line}\r\n"); + header.push_str("Content-Type: application/octet-stream\r\n"); + if let Some(cl) = content_length { + header.push_str(&format!("Content-Length: {cl}\r\n")); + } + header.push_str("Connection: close\r\n\r\n"); + let _ = sock.write_all(header.as_bytes()).await; + let _ = sock.write_all(&body).await; + let _ = sock.shutdown().await; + }); + format!("http://{addr}/file") + } + + /// Like `spawn_raw_http_server` but with a configurable Content-Type + /// header and a configurable path suffix in the returned URL. Used by + /// mixed-type Multipart tests where different URLs must carry different + /// content-types (e.g. `image/png` for the Image block, `application/pdf` + /// for the File block). + async fn spawn_fixture_server( + content_type: &'static str, + path: &'static str, + body: bytes::Bytes, + ) -> String { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let mut buf = [0u8; 4096]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; + let body_len = body.len(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n" + ); + let _ = sock.write_all(header.as_bytes()).await; + let _ = sock.write_all(&body).await; + let _ = sock.shutdown().await; + }); + format!("http://{addr}/{path}") + } + + /// Spawn a server that omits Content-Length (so the pre-flight CL check + /// can't short-circuit) and streams `actual_len` bytes in 1 MiB chunks + /// using HTTP/1.1 Connection: close framing. After each successful chunk + /// write, increments `bytes_sent`. The test asserts on the counter to + /// prove the client aborted *mid-stream* rather than buffering the + /// entire body and complaining at the end. + async fn spawn_chunked_streaming_server( + actual_len: usize, + bytes_sent: Arc, + ) -> String { + use std::sync::atomic::Ordering; + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let mut hbuf = [0u8; 4096]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut hbuf).await; + let header = "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n"; + if sock.write_all(header.as_bytes()).await.is_err() { + return; + } + let chunk = vec![0u8; 1024 * 1024]; + let mut written = 0usize; + while written < actual_len { + let n = std::cmp::min(chunk.len(), actual_len - written); + if sock.write_all(&chunk[..n]).await.is_err() { + // Client aborted (which is exactly what we expect once it + // hits the cap). Stop writing further chunks. + break; + } + // Flush so the kernel doesn't coalesce chunks beyond what the + // client has actually pulled — that would let the server + // "appear" to write 100 MiB instantly while the client has + // only consumed 25 MiB. With small SO_SNDBUF + flush the + // counter approximates client-consumed bytes. + let _ = sock.flush().await; + written += n; + bytes_sent.fetch_add(n, Ordering::SeqCst); + } + let _ = sock.shutdown().await; + }); + format!("http://{addr}/file") + } + + fn test_adapter() -> DiscordAdapter { + DiscordAdapter::new("test-token".into(), vec![], vec![], true, 0, String::new()) + } + + /// Test-only helper: drive the wire-level fetch directly so tests against + /// 127.0.0.1 fixture servers don't trip the SSRF preflight. Production + /// callers always go through `Fetcher::fetch` and inherit the guard. + async fn test_fetch( + _adapter: &DiscordAdapter, + url: &str, + ) -> Result<(bytes::Bytes, Option), Box> { + let parsed = Url::parse(url).unwrap(); + do_http_fetch(&parsed).await + } + + #[tokio::test] + async fn test_download_size_cap_via_content_length() { + // Server advertises an oversized Content-Length and sends a tiny body. + // The adapter must reject before reading anything significant. + let oversized = (URL_FETCH_MAX_BYTES + 1).to_string(); + // Leak the string so we can hand &'static str to the spawn helper. + let cl: &'static str = Box::leak(oversized.into_boxed_str()); + let url = spawn_raw_http_server("200 OK", Some(cl), bytes::Bytes::from_static(b"x")).await; + + let adapter = test_adapter(); + let res = test_fetch(&adapter, &url).await; + assert!(res.is_err(), "expected Err on oversized Content-Length"); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("Content-Length"), + "err should mention CL: {err}" + ); + } + + #[tokio::test] + async fn test_download_size_cap_via_streaming_aborts_midstream() { + // The strengthened version: server claims a believable Content-Length + // (just under the cap) so the pre-flight check passes, then streams + // chunks past the cap. We assert via a side-channel counter that the + // server stopped writing well before the full body went out — proving + // the client aborted mid-stream rather than buffering everything and + // erroring at the end. + use std::sync::atomic::Ordering; + let actual = URL_FETCH_MAX_BYTES * 4; // ~100 MiB worth of chunks queued + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let url = spawn_chunked_streaming_server(actual, counter.clone()).await; + + let adapter = test_adapter(); + let res = test_fetch(&adapter, &url).await; + assert!(res.is_err(), "expected Err on oversized streamed body"); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("streamed body exceeds cap"), + "err should mention streaming cap: {err}" + ); + // Give the server task a beat to observe the closed socket. + tokio::time::sleep(Duration::from_millis(250)).await; + let sent = counter.load(Ordering::SeqCst); + // Allow generous slack for kernel/userland buffering on top of the + // 25 MiB cap. The regression we're guarding against is "client buffers + // the entire 100 MiB then errors" — that would show ~100 MiB sent. + // Allowing up to 2*cap covers reasonable in-flight buffering without + // letting the regression slip through. + let allowed = URL_FETCH_MAX_BYTES * 2; + assert!( + sent <= allowed, + "server pushed {sent} bytes (allowed {allowed}); client did not abort mid-stream" + ); + assert!( + sent < actual, + "server pushed full payload ({sent} of {actual}); client did not abort mid-stream" + ); + } + + #[tokio::test] + async fn test_download_under_cap_succeeds_and_returns_bytes_and_ct() { + // Sanity check: a normal small payload returns the bytes and the + // stripped Content-Type. Uses axum so Content-Length is set correctly. + use axum::{routing::get, Router}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().route( + "/f", + get(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "image/png; charset=utf-8")], + bytes::Bytes::from_static(b"PNGDATA"), + ) + }), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://{addr}/f"); + + let adapter = test_adapter(); + let (bytes, ct) = test_fetch(&adapter, &url).await.unwrap(); + assert_eq!(&bytes[..], b"PNGDATA"); + // Content-Type parameters must be stripped. + assert_eq!(ct.as_deref(), Some("image/png")); + } + + // -- SSRF guard tests --------------------------------------------------- + + async fn assert_ssrf_blocked(url: &str) { + let adapter = test_adapter(); + let res = adapter.fetcher.fetch(url).await; + let err = res + .err() + .unwrap_or_else(|| panic!("expected SSRF block for {url}")) + .to_string(); + assert!( + err.contains("refused") || err.contains("not allowed") || err.contains("blocked"), + "expected SSRF refusal for {url}, got: {err}" + ); + // The query string must not appear in the error (log-scrubbing). + assert!( + !err.contains("?"), + "SSRF error must not leak query string for {url}: {err}" + ); + } + + #[tokio::test] + async fn test_ssrf_blocks_loopback() { + assert_ssrf_blocked("http://127.0.0.1/secret?token=abc").await; + } + + #[tokio::test] + async fn test_ssrf_blocks_private_10() { + assert_ssrf_blocked("http://10.0.0.1/admin?key=v").await; + } + + #[tokio::test] + async fn test_ssrf_blocks_private_192() { + assert_ssrf_blocked("http://192.168.1.1/router?op=reboot").await; + } + + #[tokio::test] + async fn test_ssrf_blocks_link_local() { + // Cloud metadata: explicit canary URL from the spec. + assert_ssrf_blocked( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/role", + ) + .await; + } + + #[tokio::test] + async fn test_ssrf_blocks_non_http_scheme() { + for u in [ + "file:///etc/passwd", + "gopher://127.0.0.1:25/_HELO", + "ftp://example.com/x", + "data:text/plain,hello", + ] { + let adapter = test_adapter(); + let res = adapter.fetcher.fetch(u).await; + let err = res + .err() + .unwrap_or_else(|| panic!("expected scheme refusal for {u}")) + .to_string(); + assert!( + err.contains("scheme") || err.contains("refused"), + "expected scheme refusal for {u}: {err}" + ); + } + } + + #[tokio::test] + async fn test_ssrf_allows_public_ip_literal_check() { + // Validate the *check*, not the network round-trip: a public IP literal + // must pass `resolve_and_check_host` so we know the guard isn't + // accidentally over-blocking. + let u = Url::parse("http://1.1.1.1/").unwrap(); + resolve_and_check_host(&u) + .await + .expect("public IP must pass SSRF check"); + } + + #[tokio::test] + async fn test_ssrf_blocks_ipv6_loopback_and_metadata_mapped() { + // Bracketed IPv6 loopback. + assert_ssrf_blocked("http://[::1]/secret?x=1").await; + // IPv4-mapped IPv6 of the cloud metadata IP must also be blocked. + assert_ssrf_blocked("http://[::ffff:169.254.169.254]/latest?creds=1").await; + } + + #[test] + fn test_redact_url_strips_query() { + let u = Url::parse( + "https://cdn.discordapp.com/attachments/1/2/file.png?ex=abc&is=def&hm=secret#frag", + ) + .unwrap(); + let r = redact_url(&u); + assert_eq!(r, "https://cdn.discordapp.com/attachments/1/2/file.png"); + assert!(!r.contains("ex=")); + assert!(!r.contains("hm=")); + assert!(!r.contains("frag")); + } + + #[test] + fn test_is_blocked_v4_canary() { + // Explicit assertion that the cloud-metadata IP is rejected even if + // some future stdlib change widens or narrows `is_link_local`. + assert!(is_blocked_v4(Ipv4Addr::new(169, 254, 169, 254))); + assert!(is_blocked_v4(Ipv4Addr::new(127, 0, 0, 1))); + assert!(is_blocked_v4(Ipv4Addr::new(10, 0, 0, 1))); + assert!(is_blocked_v4(Ipv4Addr::new(192, 168, 1, 1))); + assert!(is_blocked_v4(Ipv4Addr::new(172, 16, 0, 1))); + assert!(is_blocked_v4(Ipv4Addr::new(0, 0, 0, 0))); + assert!(is_blocked_v4(Ipv4Addr::new(100, 64, 0, 1))); // CGNAT + // Public addresses must pass. + assert!(!is_blocked_v4(Ipv4Addr::new(1, 1, 1, 1))); + assert!(!is_blocked_v4(Ipv4Addr::new(8, 8, 8, 8))); + } + #[tokio::test] async fn test_parse_empty_message_with_no_attachments_returns_none() { let bot_id = Arc::new(RwLock::new(Some("bot123".to_string()))); @@ -1632,4 +3490,989 @@ mod tests { let msg = parse_discord_message(&d, &bot_id, &[], &[], true, &empty_threads()).await; assert!(msg.is_none()); } + + // --- ANAI-55 resolver tests -------------------------------------------- + + fn make_adapter() -> DiscordAdapter { + DiscordAdapter::new( + "test_token".to_string(), + vec![], + vec![], + true, + 37377, + "false".to_string(), + ) + } + + #[tokio::test] + async fn resolver_passthrough_snowflake() { + let a = make_adapter(); + let u = a.resolve_recipient("1476626559401066688").await.unwrap(); + assert_eq!(u.platform_id, "1476626559401066688"); + } + + #[tokio::test] + async fn resolver_channel_mention() { + let a = make_adapter(); + let u = a.resolve_recipient("<#1476626559401066688>").await.unwrap(); + assert_eq!(u.platform_id, "1476626559401066688"); + } + + #[tokio::test] + async fn resolver_user_mention_uncached_attempts_dm_open() { + // No DM cache entry → resolver falls through to api_open_dm_channel, + // which fails (no network) and we get DmOpenFailed. We only assert + // the *error class*, not the status code, since transport failure + // surfaces as `0`. + let a = make_adapter(); + let err = a + .resolve_recipient("<@1086446153098342510>") + .await + .unwrap_err(); + assert!( + matches!(err, ResolutionError::DmOpenFailed { .. }), + "got {err:?}" + ); + } + + #[tokio::test] + async fn resolver_channel_name_single_guild() { + let a = make_adapter(); + a.channel_name_to_channel_id.write().await.insert( + ("guild_A".to_string(), "system-code".to_string()), + "1476626559401066688".to_string(), + ); + let u = a.resolve_recipient("#system-code").await.unwrap(); + assert_eq!(u.platform_id, "1476626559401066688"); + + let u2 = a.resolve_recipient("system-code").await.unwrap(); + assert_eq!(u2.platform_id, "1476626559401066688"); + } + + #[tokio::test] + async fn resolver_channel_name_ambiguous_across_guilds() { + let a = make_adapter(); + { + let mut c = a.channel_name_to_channel_id.write().await; + c.insert( + ("guild_A".to_string(), "general".to_string()), + "111".to_string(), + ); + c.insert( + ("guild_B".to_string(), "general".to_string()), + "222".to_string(), + ); + } + let err = a.resolve_recipient("#general").await.unwrap_err(); + match err { + ResolutionError::AmbiguousChannel { name, guilds } => { + assert_eq!(name, "general"); + assert_eq!(guilds.len(), 2); + } + other => panic!("expected AmbiguousChannel, got {other:?}"), + } + } + + #[tokio::test] + async fn resolver_explicit_hash_no_hit_is_unknown() { + let a = make_adapter(); + let err = a.resolve_recipient("#nope").await.unwrap_err(); + assert!(matches!(err, ResolutionError::UnknownRecipient { .. })); + } + + #[tokio::test] + async fn resolver_bare_name_with_no_channel_is_refused_as_dm() { + let a = make_adapter(); + let err = a.resolve_recipient("benhoverter").await.unwrap_err(); + match err { + ResolutionError::BareNameDmRefused { name } => assert_eq!(name, "benhoverter"), + other => panic!("expected BareNameDmRefused, got {other:?}"), + } + } + + #[tokio::test] + async fn resolver_at_username_uncached_is_unknown() { + let a = make_adapter(); + let err = a.resolve_recipient("@nobody").await.unwrap_err(); + assert!(matches!(err, ResolutionError::UnknownRecipient { .. })); + } + + #[tokio::test] + async fn resolver_at_username_cached_attempts_dm_open() { + let a = make_adapter(); + a.username_to_user_id + .write() + .await + .insert("benhoverter".to_string(), "1086446153098342510".to_string()); + let err = a.resolve_recipient("@benhoverter").await.unwrap_err(); + assert!(matches!(err, ResolutionError::DmOpenFailed { .. })); + } + + #[tokio::test] + async fn resolver_dm_channel_cache_hit_skips_api_call() { + let a = make_adapter(); + a.username_to_user_id + .write() + .await + .insert("benhoverter".to_string(), "1086446153098342510".to_string()); + a.user_id_to_dm_channel_id + .write() + .await + .insert("1086446153098342510".to_string(), "dm_chan_999".to_string()); + let u = a.resolve_recipient("@benhoverter").await.unwrap(); + assert_eq!(u.platform_id, "dm_chan_999"); + } + + #[tokio::test] + async fn resolver_empty_input_is_unknown() { + let a = make_adapter(); + let err = a.resolve_recipient("").await.unwrap_err(); + assert!(matches!(err, ResolutionError::UnknownRecipient { .. })); + } + + // ========================================================================== + // Outbound Multipart send() tests + // ========================================================================== + // + // Test helpers: spin up an axum stub that accepts multipart POSTs to + // `/channels/:id/messages`, captures the `payload_json` field text and + // the number of file parts, and stores them in a shared Arc>. + // We point the adapter at the stub via `api_base_override`. + + use tokio::sync::Mutex as TokioMutex; + + #[derive(Debug, Default, Clone)] + struct CapturedFile { + field_name: String, + filename: Option, + content_type: Option, + } + + #[derive(Debug, Default, Clone)] + struct CapturedPost { + payload_json: String, + /// Bare field names (legacy, kept so existing assertions continue to compile). + file_field_names: Vec, + /// Richer per-file metadata captured from each `files[*]` part. + files: Vec, + } + + /// Build an axum stub that captures one or more multipart POSTs to + /// `/channels/test/messages` and records them into `captured`. + async fn spawn_discord_stub(captured: Arc>>) -> String { + use axum::{ + extract::{DefaultBodyLimit, Multipart}, + http::StatusCode, + routing::post, + Extension, Router, + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let app = Router::new() + .route( + "/channels/test/messages", + post( + |Extension(store): Extension>>>, + mut multipart: Multipart| async move { + let mut post = CapturedPost::default(); + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "payload_json" { + post.payload_json = field.text().await.unwrap_or_default(); + } else { + let filename = field.file_name().map(str::to_string); + let content_type = field.content_type().map(str::to_string); + // Drain the file bytes so axum doesn't error. + let _ = field.bytes().await; + post.files.push(CapturedFile { + field_name: name.clone(), + filename, + content_type, + }); + post.file_field_names.push(name); + } + } + store.lock().await.push(post); + StatusCode::OK + }, + ), + ) + // Default 2 MiB body limit would reject the byte-cap chunking + // test's ~20 MiB chunks; disable it on the stub. + .layer(DefaultBodyLimit::disable()) + .layer(Extension(captured)); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + format!("http://{addr}") + } + + fn make_channel_user(channel_id: &str) -> ChannelUser { + ChannelUser { + platform_id: channel_id.to_string(), + display_name: "test-user".to_string(), + openfang_user: None, + } + } + + fn test_adapter_with_base(base: String) -> DiscordAdapter { + let mut a = test_adapter(); + a.api_base_override = Some(base); + a + } + + /// Like `test_adapter_with_base` but installs [`PermissiveFetcher`] so + /// `Image{url}` / `File{url}` blocks pointing at localhost stub servers + /// can flow through the normal `Fetcher::fetch` path without tripping + /// the SSRF preflight. + fn test_adapter_with_base_and_ssrf_bypass(base: String) -> DiscordAdapter { + let mut a = test_adapter_with_base(base); + a.fetcher = Arc::new(PermissiveFetcher); + a + } + + // ---- required test a: caption concatenation -------------------------------- + + #[tokio::test] + async fn test_multipart_outbound_caption_concatenation() { + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let content = ChannelContent::Multipart(vec![ + ChannelContent::Text("hello".to_string()), + ChannelContent::Text("world".to_string()), + ChannelContent::FileData { + data: b"payload".to_vec(), + filename: "file.txt".to_string(), + mime_type: "text/plain".to_string(), + }, + ]); + + adapter.send(&user, content).await.unwrap(); + + let posts = captured.lock().await; + assert_eq!(posts.len(), 1, "expected exactly one POST"); + let v: serde_json::Value = serde_json::from_str(&posts[0].payload_json).unwrap(); + assert_eq!( + v["content"].as_str().unwrap_or(""), + "hello\n\nworld", + "caption should be the two Text blocks joined by \\n\\n" + ); + } + + // ---- required test b: empty/whitespace caption suppressed ------------------ + // + // Image URL fetches go through the SSRF guard which blocks 127.0.0.1, so + // this test uses FileData to avoid the network requirement while still + // exercising the caption-suppression logic path. + + #[tokio::test] + async fn test_multipart_outbound_empty_caption_suppressed() { + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let content = ChannelContent::Multipart(vec![ + ChannelContent::Text("".to_string()), + ChannelContent::Text(" ".to_string()), + ChannelContent::FileData { + data: b"bytes".to_vec(), + filename: "f.bin".to_string(), + mime_type: "application/octet-stream".to_string(), + }, + ]); + adapter.send(&user, content).await.unwrap(); + + let posts = captured.lock().await; + assert_eq!(posts.len(), 1, "expected one POST"); + let v: serde_json::Value = serde_json::from_str(&posts[0].payload_json).unwrap(); + assert!( + v.get("content").is_none(), + "empty/whitespace caption must produce payload_json without 'content' field; got: {}", + posts[0].payload_json + ); + } + + // ---- required test c: chunking >10 ---------------------------------------- + + #[tokio::test] + async fn test_multipart_outbound_chunking_gt10() { + // 23 FileData blocks should produce ceil(23/10) = 3 POSTs. + // First chunk: caption + files[0..10) (10 files) + // Second chunk: no caption + files[0..10) (10 files) + // Third chunk: no caption + files[0..3) (3 files) + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let mut parts = vec![ChannelContent::Text("cap".to_string())]; + for i in 0..23u32 { + parts.push(ChannelContent::FileData { + data: format!("data{i}").into_bytes(), + filename: format!("f{i}.txt"), + mime_type: "text/plain".to_string(), + }); + } + + adapter + .send(&user, ChannelContent::Multipart(parts)) + .await + .unwrap(); + + let posts = captured.lock().await; + assert_eq!( + posts.len(), + 3, + "23 files should produce 3 POSTs (chunks of 10)" + ); + + // First chunk carries the caption. + let v0: serde_json::Value = serde_json::from_str(&posts[0].payload_json).unwrap(); + assert_eq!( + v0["content"].as_str().unwrap_or(""), + "cap", + "first chunk must carry the caption" + ); + assert_eq!( + posts[0].file_field_names.len(), + 10, + "first chunk must have 10 files" + ); + + // Second chunk has no caption. + let v1: serde_json::Value = serde_json::from_str(&posts[1].payload_json).unwrap(); + assert!( + v1.get("content").is_none(), + "second chunk must not carry the caption" + ); + assert_eq!( + posts[1].file_field_names.len(), + 10, + "second chunk must have 10 files" + ); + + // Third chunk has no caption and only 3 files. + let v2: serde_json::Value = serde_json::from_str(&posts[2].payload_json).unwrap(); + assert!( + v2.get("content").is_none(), + "third chunk must not carry the caption" + ); + assert_eq!( + posts[2].file_field_names.len(), + 3, + "third chunk must have 3 files" + ); + } + + // ---- required test d: caption-only fallback -------------------------------- + + /// Checks that a Multipart with only Text blocks sends exactly one plain + /// text message (no multipart POST) via the `api_send_message` path. + #[tokio::test] + async fn test_multipart_outbound_caption_only_fallback() { + // The Discord stub only handles `/channels/test/messages` POSTs. + // api_send_message sends JSON (not multipart), so we use a simple + // axum stub that accepts any POST and records the Content-Type. + use axum::{extract::Request, http::StatusCode, routing::post, Extension, Router}; + + let calls: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let calls_clone = calls.clone(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = + Router::new() + .route( + "/channels/test/messages", + post( + |Extension(store): Extension>>>, + req: Request| async move { + let ct = req + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + store.lock().await.push(ct); + StatusCode::OK + }, + ), + ) + .layer(Extension(calls_clone)); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let adapter = test_adapter_with_base(format!("http://{addr}")); + let user = make_channel_user("test"); + + let content = + ChannelContent::Multipart(vec![ChannelContent::Text("only text".to_string())]); + adapter.send(&user, content).await.unwrap(); + + let cts = calls.lock().await; + assert_eq!(cts.len(), 1, "expected exactly one POST for caption-only"); + // Plain message (JSON), not multipart. + assert!( + cts[0].contains("application/json"), + "caption-only should send JSON, not multipart; content-type was: {}", + cts[0] + ); + } + + // ---- required test e: mixed Image{url}+File{url} resolver dispatch ---------- + + /// Verifies that a `Multipart([Text, Image{url}, File{url}])` block routes + /// each attachment through the correct resolver branch: + /// + /// - `Image{url}` → `resolve_image_mime` / `resolve_image_filename`: + /// the response Content-Type is used as-is and the filename is derived + /// from the URL path or inferred from the MIME (e.g. `image.png`). + /// + /// - `File{url, filename, mime}` → `resolve_file_mime` / + /// `resolve_file_filename`: the explicitly supplied filename and MIME + /// from the `File{}` block take precedence over the server's + /// Content-Type. + /// + /// The test spins up two local HTTP fixture servers (bypassing the SSRF + /// guard via `ssrf_bypass`), one per URL, then asserts on the + /// per-part filename and Content-Type captured by the Discord stub. + #[tokio::test] + async fn test_multipart_outbound_mixed_types_single_post() { + // Spawn a fixture server for the Image block — serves image/png bytes. + let image_url = spawn_fixture_server( + "image/png", + "photo.png", + bytes::Bytes::from_static(b"\x89PNG\r\n\x1a\n"), // minimal PNG magic + ) + .await; + + // Spawn a fixture server for the File block — serves application/pdf + // bytes. The File block supplies an explicit filename and MIME so the + // resolver must prefer those over the server's Content-Type. + let file_url = spawn_fixture_server( + "application/octet-stream", // server sends generic; resolver should prefer field mime + "ignored-server-name.bin", + bytes::Bytes::from_static(b"%PDF-1.4"), + ) + .await; + + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base_and_ssrf_bypass(base); + let user = make_channel_user("test"); + + let content = ChannelContent::Multipart(vec![ + ChannelContent::Text("mixed".to_string()), + ChannelContent::Image { + url: image_url.clone(), + caption: None, + }, + ChannelContent::File { + url: file_url.clone(), + filename: "report.pdf".to_string(), + mime: Some("application/pdf".to_string()), + size: None, + }, + ]); + + adapter.send(&user, content).await.unwrap(); + + let posts = captured.lock().await; + assert_eq!( + posts.len(), + 1, + "expected exactly one POST for mixed Multipart" + ); + + // Caption preserved. + let v: serde_json::Value = serde_json::from_str(&posts[0].payload_json).unwrap(); + assert_eq!(v["content"].as_str().unwrap_or(""), "mixed"); + + // Both files appeared in a single POST. + assert_eq!( + posts[0].files.len(), + 2, + "expected two file parts in the POST" + ); + + // ---- Image block assertions ---- + // resolve_image_mime: server sent image/png → resolved mime = "image/png" + // resolve_image_filename: URL path tail is "photo.png" → filename = "photo.png" + let img_part = posts[0] + .files + .iter() + .find(|f| f.field_name == "files[0]") + .expect("files[0] must be present"); + assert_eq!( + img_part.content_type.as_deref(), + Some("image/png"), + "Image block must use resolve_image_mime (server Content-Type preserved)" + ); + assert_eq!( + img_part.filename.as_deref(), + Some("photo.png"), + "Image block must use resolve_image_filename (URL path tail)" + ); + + // ---- File block assertions ---- + // resolve_file_filename: explicit filename "report.pdf" takes precedence over URL + // resolve_file_mime: explicit mime "application/pdf" takes precedence over server CT + let file_part = posts[0] + .files + .iter() + .find(|f| f.field_name == "files[1]") + .expect("files[1] must be present"); + assert_eq!( + file_part.content_type.as_deref(), + Some("application/pdf"), + "File block must use resolve_file_mime (explicit mime from File{{}} block)" + ); + assert_eq!( + file_part.filename.as_deref(), + Some("report.pdf"), + "File block must use resolve_file_filename (explicit filename from File{{}} block)" + ); + } + + // ---- should-have: mid-batch fetch failure ---------------------------------- + + #[tokio::test] + async fn test_multipart_outbound_fetch_failure_returns_err() { + // A File block with an SSRF-blocked URL should cause send() to return Err. + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let content = ChannelContent::Multipart(vec![ + ChannelContent::Text("cap".to_string()), + ChannelContent::File { + url: "http://127.0.0.1/secret".to_string(), + filename: "s.txt".to_string(), + mime: None, + size: None, + }, + ]); + + let result = adapter.send(&user, content).await; + assert!(result.is_err(), "expected Err on fetch failure"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Multipart fetch failed") || err.contains("refused"), + "error should mention failing fetch; got: {err}" + ); + // No POST should have been made (fetch failed before send). + let posts = captured.lock().await; + assert!(posts.is_empty(), "no POST should occur if fetch fails"); + } + + // ---- should-have: empty Multipart ------------------------------------------ + + #[tokio::test] + async fn test_multipart_outbound_empty_is_ok_no_posts() { + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let result = adapter.send(&user, ChannelContent::Multipart(vec![])).await; + assert!(result.is_ok(), "empty Multipart should return Ok"); + let posts = captured.lock().await; + assert!( + posts.is_empty(), + "empty Multipart must not produce any POSTs" + ); + } + + // ---- should-have: unknown nested variant is logged, not fatal -------------- + + #[tokio::test] + async fn test_multipart_outbound_unknown_nested_variant_skipped() { + // A Multipart containing a nested Multipart (and a FileData) should + // warn but still send the FileData. + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let content = ChannelContent::Multipart(vec![ + ChannelContent::Text("x".to_string()), + ChannelContent::Multipart(vec![]), // unknown nesting + ChannelContent::FileData { + data: b"f".to_vec(), + filename: "f.txt".to_string(), + mime_type: "text/plain".to_string(), + }, + ]); + + let result = adapter.send(&user, content).await; + assert!(result.is_ok(), "unknown nested variant must not be fatal"); + let posts = captured.lock().await; + assert_eq!(posts.len(), 1, "FileData should still be sent"); + } + + // ---- multi-file 429 retry -------------------------------------------------- + + /// Spawn a stub at `/channels/test/messages` that returns + /// `first_response` on attempt 0 and 200 OK on every subsequent attempt. + /// Captures every POST's parsed multipart fields into the returned + /// `Arc<...Vec>` for assertions. Used by the 429 retry + /// tests to vary only the 429 response shape (body+header vs header-only) + /// while sharing the rest of the scaffolding. + async fn spawn_429_then_ok_stub( + first_response: Arc axum::response::Response + Send + Sync + 'static>, + ) -> (String, Arc>>) { + use axum::{ + extract::{DefaultBodyLimit, Multipart}, + http::StatusCode, + response::IntoResponse, + routing::post, + Extension, Router, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let attempt = Arc::new(AtomicUsize::new(0)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let captured_clone = captured.clone(); + let attempt_clone = attempt.clone(); + let app = Router::new() + .route( + "/channels/test/messages", + post( + move |Extension(_): Extension<()>, mut multipart: Multipart| { + let captured = captured_clone.clone(); + let attempt = attempt_clone.clone(); + let first_response = first_response.clone(); + async move { + let n = attempt.fetch_add(1, Ordering::SeqCst); + let mut post_rec = CapturedPost::default(); + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "payload_json" { + post_rec.payload_json = field.text().await.unwrap_or_default(); + } else { + let filename = field.file_name().map(str::to_string); + let content_type = field.content_type().map(str::to_string); + let _ = field.bytes().await; + post_rec.files.push(CapturedFile { + field_name: name.clone(), + filename, + content_type, + }); + post_rec.file_field_names.push(name); + } + } + captured.lock().await.push(post_rec); + if n == 0 { + first_response() + } else { + StatusCode::OK.into_response() + } + } + }, + ), + ) + .layer(DefaultBodyLimit::disable()) + .layer(Extension(())); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), captured) + } + + /// Build a `ChannelContent::Multipart` with `n` `FileData` blocks named + /// `a.txt`, `b.txt`, … (for tests that only care about field-name + /// ordering, not content). `n` must be ≤ 26. + fn caption_plus_n_files(caption: &str, n: usize) -> ChannelContent { + assert!(n <= 26, "caption_plus_n_files: n must fit in a-z"); + let mut parts = vec![ChannelContent::Text(caption.to_string())]; + for i in 0..n { + let ch = (b'a' + i as u8) as char; + parts.push(ChannelContent::FileData { + data: vec![ch as u8], + filename: format!("{ch}.txt"), + mime_type: "text/plain".to_string(), + }); + } + ChannelContent::Multipart(parts) + } + + /// Assert every captured POST's multipart fields are exactly + /// `["files[0]", "files[1]", …, "files[n-1]"]`. + async fn assert_all_attempts_carry_files( + captured: &Arc>>, + n: usize, + ) { + let expected: Vec = (0..n).map(|i| format!("files[{i}]")).collect(); + let posts = captured.lock().await; + for (i, p) in posts.iter().enumerate() { + assert_eq!( + p.file_field_names, expected, + "attempt {i} must include files[0..{n})" + ); + } + } + + /// 429 response with both `Retry-After: 0` header and a JSON body + /// containing `retry_after: 0.0`. Sending a 3-attachment Multipart + /// must produce exactly 2 POSTs and both must carry the full file set. + /// Locks in the body-aware retry path (body wins over header per the + /// adapter's `body_secs.or(header_secs)`). + #[tokio::test] + async fn test_multipart_outbound_multifile_429_retries_once() { + use axum::{http::StatusCode, response::IntoResponse}; + let first: Arc axum::response::Response + Send + Sync> = Arc::new(|| { + ( + StatusCode::TOO_MANY_REQUESTS, + [(axum::http::header::RETRY_AFTER, "0")], + r#"{"retry_after":0.0,"global":false}"#, + ) + .into_response() + }); + let (base, captured) = spawn_429_then_ok_stub(first).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + adapter + .send(&user, caption_plus_n_files("cap", 3)) + .await + .unwrap(); + + assert_eq!( + captured.lock().await.len(), + 2, + "expected 2 POSTs (one 429-rejected, one 200) for the same chunk" + ); + assert_all_attempts_carry_files(&captured, 3).await; + } + + /// 429 response with **only** the `Retry-After` header (empty body). + /// The header-fallback path (`body_secs.or(header_secs)`) must still + /// trigger the retry, so a regression that drops header parsing fails + /// here independently of the body-present test. + #[tokio::test] + async fn test_multipart_outbound_multifile_429_header_only_retries_once() { + use axum::{http::StatusCode, response::IntoResponse}; + let first: Arc axum::response::Response + Send + Sync> = Arc::new(|| { + ( + StatusCode::TOO_MANY_REQUESTS, + [(axum::http::header::RETRY_AFTER, "0")], + "", + ) + .into_response() + }); + let (base, captured) = spawn_429_then_ok_stub(first).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + adapter + .send(&user, caption_plus_n_files("cap", 2)) + .await + .unwrap(); + + assert_eq!( + captured.lock().await.len(), + 2, + "header-only 429 must still trigger one retry" + ); + assert_all_attempts_carry_files(&captured, 2).await; + } + + // ---- aggregate per-chunk byte cap ------------------------------------------ + + /// Three 10 MiB FileData blocks must split into two chunks under the + /// 24 MiB per-chunk byte cap (20 MiB + 10 MiB). The caption rides only + /// on the first chunk; chunk-2 has no caption. + #[tokio::test] + async fn test_multipart_outbound_chunking_by_byte_cap() { + let captured: Arc>> = Arc::new(TokioMutex::new(Vec::new())); + let base = spawn_discord_stub(captured.clone()).await; + let adapter = test_adapter_with_base(base); + let user = make_channel_user("test"); + + let big = vec![0u8; 10 * 1024 * 1024]; + let parts = vec![ + ChannelContent::Text("cap".to_string()), + ChannelContent::FileData { + data: big.clone(), + filename: "a.bin".to_string(), + mime_type: "application/octet-stream".to_string(), + }, + ChannelContent::FileData { + data: big.clone(), + filename: "b.bin".to_string(), + mime_type: "application/octet-stream".to_string(), + }, + ChannelContent::FileData { + data: big, + filename: "c.bin".to_string(), + mime_type: "application/octet-stream".to_string(), + }, + ]; + + adapter + .send(&user, ChannelContent::Multipart(parts)) + .await + .unwrap(); + + let posts = captured.lock().await; + assert_eq!( + posts.len(), + 2, + "3×10 MiB attachments should split into 2 chunks under the 24 MiB cap" + ); + // Chunk 1: caption + 2 files (a, b). + let v0: serde_json::Value = serde_json::from_str(&posts[0].payload_json).unwrap(); + assert_eq!(v0["content"].as_str().unwrap_or(""), "cap"); + assert_eq!(posts[0].files.len(), 2, "first chunk holds first 2 files"); + // Chunk 2: no caption + 1 file (c). + let v1: serde_json::Value = serde_json::from_str(&posts[1].payload_json).unwrap(); + assert!( + v1.get("content").is_none(), + "second chunk must not carry the caption" + ); + assert_eq!(posts[1].files.len(), 1, "second chunk holds the 3rd file"); + } + + /// Direct unit test of the chunking helper: verifies count cap, byte cap, + /// and the oversize-single-attachment edge case (lands in its own chunk + /// instead of stalling progress). + #[test] + fn test_chunk_attachments_count_and_byte_caps() { + // 12 small files → 2 chunks of 10 + 2 (count cap dominates). + let small: Vec<_> = (0..12) + .map(|i| { + ( + bytes::Bytes::from(vec![0u8; 1024]), + format!("f{i}.bin"), + "application/octet-stream".to_string(), + ) + }) + .collect(); + let chunks = chunk_attachments(small); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 10); + assert_eq!(chunks[1].len(), 2); + + // Three 10 MiB items → 2 chunks (byte cap dominates: 20 + 10 ≤ 24). + let big_payload = bytes::Bytes::from(vec![0u8; 10 * 1024 * 1024]); + let big = vec![ + (big_payload.clone(), "a".to_string(), "x".to_string()), + (big_payload.clone(), "b".to_string(), "x".to_string()), + (big_payload.clone(), "c".to_string(), "x".to_string()), + ]; + let chunks = chunk_attachments(big); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 2); + assert_eq!(chunks[1].len(), 1); + + // One oversized attachment by itself → single chunk holding it. + // (Discord rejects, but the helper mustn't loop forever or drop it.) + let oversized = bytes::Bytes::from(vec![0u8; CHUNK_TOTAL_CAP_BYTES + 1]); + let solo = vec![(oversized, "huge".to_string(), "x".to_string())]; + let chunks = chunk_attachments(solo); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), 1); + + // Empty input → no chunks. + let chunks = chunk_attachments(Vec::new()); + assert!(chunks.is_empty()); + } +} + + +/// Build Discord `components` action rows from platform-neutral buttons. +/// Discord allows max 5 buttons per action row (and 5 rows); buttons are +/// chunked into rows of 5. Each button is component type 2 (Button). +fn build_action_rows(buttons: &[InteractiveButton]) -> Vec { + buttons + .chunks(5) + .map(|row| { + let comps: Vec = row + .iter() + .map(|b| { + serde_json::json!({ + "type": 2, + "style": b.style.discord_style(), + "label": b.label, + "custom_id": b.custom_id, + }) + }) + .collect(); + serde_json::json!({ "type": 1, "components": comps }) + }) + .collect() +} + +#[cfg(test)] +mod anai82_interactive_tests { + use super::*; + use crate::types::ButtonStyle; + + fn btn(custom_id: &str, label: &str, style: ButtonStyle) -> InteractiveButton { + InteractiveButton { + custom_id: custom_id.to_string(), + label: label.to_string(), + style, + } + } + + #[test] + fn discord_style_mapping_matches_api_v10() { + assert_eq!(ButtonStyle::Primary.discord_style(), 1); + assert_eq!(ButtonStyle::Secondary.discord_style(), 2); + assert_eq!(ButtonStyle::Success.discord_style(), 3); + assert_eq!(ButtonStyle::Danger.discord_style(), 4); + } + + #[test] + fn action_row_shape_and_button_fields() { + let rows = build_action_rows(&[ + btn("ap:abc123:n0", "Approve", ButtonStyle::Success), + btn("dn:abc123:n0", "Deny", ButtonStyle::Danger), + ]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["type"], 1); + let comps = rows[0]["components"].as_array().unwrap(); + assert_eq!(comps.len(), 2); + assert_eq!(comps[0]["type"], 2); + assert_eq!(comps[0]["style"], 3); + assert_eq!(comps[0]["label"], "Approve"); + assert_eq!(comps[0]["custom_id"], "ap:abc123:n0"); + assert_eq!(comps[1]["style"], 4); + assert_eq!(comps[1]["custom_id"], "dn:abc123:n0"); + } + + #[test] + fn buttons_chunk_into_rows_of_five() { + let buttons: Vec = (0..6) + .map(|i| btn(&format!("c{i}"), "b", ButtonStyle::Secondary)) + .collect(); + let rows = build_action_rows(&buttons); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["components"].as_array().unwrap().len(), 5); + assert_eq!(rows[1]["components"].as_array().unwrap().len(), 1); + } + + #[test] + fn degrade_to_text_collapses_interactive() { + let ic = ChannelContent::Interactive { + text: "Approve request abc? /approve abc".to_string(), + buttons: vec![btn("ap:abc:n0", "Approve", ButtonStyle::Success)], + }; + match ic.degrade_to_text() { + ChannelContent::Text(t) => assert_eq!(t, "Approve request abc? /approve abc"), + other => panic!("expected Text, got {other:?}"), + } + } } diff --git a/crates/openfang-channels/src/lib.rs b/crates/openfang-channels/src/lib.rs index 7b122d2a24..06cb827bf8 100644 --- a/crates/openfang-channels/src/lib.rs +++ b/crates/openfang-channels/src/lib.rs @@ -11,6 +11,8 @@ pub mod google_chat; pub mod irc; pub mod matrix; pub mod mattermost; +pub mod outbound_attach; +pub mod resolver_audit; pub mod rocketchat; pub mod router; pub mod signal; diff --git a/crates/openfang-channels/src/outbound_attach.rs b/crates/openfang-channels/src/outbound_attach.rs new file mode 100644 index 0000000000..1377f92cb0 --- /dev/null +++ b/crates/openfang-channels/src/outbound_attach.rs @@ -0,0 +1,1062 @@ +//! 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 caller-supplied allow-roots. The default allow-roots are +//! **empty** — callers MUST pass their per-agent `workspace_root` +//! explicitly via [`ParseOptions::allow_roots`]. Bridge and kernel both +//! compute `workspace_root = ~/.openfang/workspaces//` from the +//! agent identity and pass it through. +//! +//! ### Why relative paths require an explicit `base` +//! +//! `tokio::fs::canonicalize` resolves relative paths against the +//! **process CWD** — whatever launchd handed the daemon at spawn (likely +//! `/` or `~/.openfang`, definitely *not* the calling agent's workspace). +//! If we naively accepted relative paths, a directive like +//! `path="../etc/passwd"` would resolve against ambient process state and +//! could land inside an `allow_roots` entry purely by accident of how the +//! daemon was started. That's the same wide-default failure mode that +//! motivated `default_allow_roots() -> Vec::new()`, one layer down. +//! +//! Invariant: **path-resolution context must be explicit, never inherited +//! from ambient process state.** Relative directive paths resolve against +//! the caller-supplied [`ParseOptions::base`] only; if no base was +//! provided the directive is rejected. The resolved absolute path is +//! still subject to canonicalisation, the `allow_roots` `starts_with` +//! check, and the hard-deny rule below — so `base` is a *resolution* +//! input, not an *authorisation* input. +//! +//! A secondary hard-deny rule rejects any canonical path under +//! `$HOME/.openfang/` that is not also under `$HOME/.openfang/workspaces/`, +//! regardless of caller-supplied allow-roots. Belt-and-suspenders against a +//! caller that mistakenly opens too wide a root: secrets, daemon state, +//! channel configs, and other agents' workspaces remain unreachable even +//! if `allow_roots` accidentally includes `~/.openfang/` itself. +//! +//! ## 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, + /// Attachments whose directive parsed but whose resolution failed + /// (missing file, outside allow-roots, oversized, …). Each tuple + /// is `(directive_path, reason)`. The message body still sends; + /// callers that surface tool-call results back to the agent + /// should include this list so the agent can react to silent + /// drops. Empty when every directive resolved. + skipped: Vec<(String, String)>, + }, +} + +/// Options controlling outbound-attachment parsing and path resolution. +/// +/// Grouped into a struct so callers can extend the resolution context +/// without churning every call site. Today there are two knobs: +/// +/// - [`allow_roots`](Self::allow_roots): canonical roots a resolved +/// absolute path must lie under. `None` defers to +/// [`default_allow_roots`] (which is empty — fail-closed). +/// - [`base`](Self::base): explicit base directory used to resolve +/// *relative* directive paths. `None` rejects all relative paths; we +/// never fall back to process CWD (see the module-level "Why relative +/// paths require an explicit base" note). +/// +/// The two fields are independent: `allow_roots` governs authorisation, +/// `base` governs resolution. Bridge currently passes the same +/// `workspace_root` for both, but that's a caller convention — the parser +/// treats them separately. +#[derive(Clone, Copy, Debug, Default)] +pub struct ParseOptions<'a> { + /// Override for the per-agent allow-roots. `None` uses + /// [`default_allow_roots`] (empty / fail-closed). + pub allow_roots: Option<&'a [PathBuf]>, + /// Explicit base directory for resolving relative directive paths. + /// `None` rejects all relative paths (no CWD fallback). + pub base: Option<&'a Path>, +} + +fn marker_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"]*?)/>"#).expect("marker regex compiles") + }) +} + +/// Neutralize any `` marker *opener* in agent-controlled +/// text so [`parse`] cannot interpret it. +/// +/// Use this when composing operator-facing text (e.g. an approval prompt) out +/// of fields an agent controls. Without it, an agent could embed a marker so +/// that `parse` strips it and appends its `caption=` value — making the text a +/// human approves diverge from the real content it is supposed to represent. +/// +/// HTML-escapes the leading `<` of the opener (the same transform this module's +/// `parse` doc notes will "break detection"), so the marker is no longer parsed +/// but stays visible and faithful to the human. Case-insensitive for defense in +/// depth even though [`marker_regex`] is currently case-sensitive. +pub fn neutralize_markers(text: &str) -> String { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r#"(?i)<(openfang:attach)"#).expect("neutralize regex compiles") + }); + re.replace_all(text, "<$1").into_owned() +} + +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-roots: **empty**. Callers MUST pass `workspace_root` +/// explicitly (via `Some(&[…])`) to grant any access. A caller that forgets +/// to plumb its per-agent workspace receives zero allow-roots and every +/// directive is rejected — fail-closed against context loss. +/// +/// Historically this returned `$HOME/.openfang/` as a default, which gave a +/// caller-forgetting-to-plumb-context the union of every agent's workspace +/// *plus* the daemon's secrets directory. The wide default is the bug; the +/// `workspace_root` plumbing in `bridge.rs::send_agent_response` and the +/// kernel proactive-send path is the fix. +fn default_allow_roots() -> Vec { + Vec::new() +} + +/// Canonicalised `$HOME/.openfang/` if it exists. Used by the hard-deny rule +/// in [`resolve_directive`] to reject any path inside OpenFang's home that +/// is not also inside `~/.openfang/workspaces/`, regardless of what +/// `allow_roots` the caller passed. Belt-and-suspenders against a caller +/// that mistakenly opens up too wide an allow-root. +fn openfang_home() -> Option { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push(".openfang"); + std::fs::canonicalize(&p).ok() +} + +/// Returns `true` if `canon` is inside `home` (= `~/.openfang/`) but not +/// under `home/workspaces/`. The hard-deny rule keyed by this predicate +/// rejects access to OpenFang-internal state (secrets, daemon files, +/// other agents' workspaces' siblings) regardless of caller-supplied +/// allow-roots. Pure path logic — no I/O — so trivially testable. +fn is_hard_denied(canon: &Path, home: &Path) -> bool { + if !canon.starts_with(home) { + return false; + } + let workspaces = home.join("workspaces"); + !canon.starts_with(&workspaces) +} + +async fn resolve_directive( + d: &AttachDirective, + allow_roots: &[PathBuf], + base: Option<&Path>, +) -> Result { + let raw = PathBuf::from(&d.path); + // Relative paths are resolved against the caller-supplied `base` + // only — never against process CWD. See module docs for the + // threat model. + let raw = if raw.is_absolute() { + raw + } else { + match base { + Some(b) => b.join(&raw), + None => { + return Err(format!( + "relative path {} requires explicit base (no CWD fallback)", + 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())); + } + // Hard-deny anything under ~/.openfang/ that isn't a per-agent workspace, + // even if the caller's allow_roots somehow included a parent directory. + // The only OpenFang-internal area we expose to outbound attachments is + // `~/.openfang/workspaces//` — secrets, daemon state, channel + // configs, and other agents' workspaces remain unreachable regardless of + // caller misconfiguration. + if let Some(home) = openfang_home() { + if is_hard_denied(&canon, &home) { + return Err(format!( + "path {} is inside ~/.openfang/ but outside workspaces/ (hard-deny)", + 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() + )); + } + // Bounded read closes the TOCTOU window between the metadata size + // check above and the actual read. Even if an attacker (or a concurrent + // writer) swaps or grows the file after stat, tokio's File + take() + // caps the bytes copied into memory at MAX_FILE_BYTES, so we cannot be + // tricked into loading an unbounded payload. We use `MAX_FILE_BYTES + 1` + // as the take-limit so we can distinguish "file fit exactly" from + // "file grew past the cap" and return a clear error in the latter case. + let data = { + use tokio::io::AsyncReadExt; + let f = tokio::fs::File::open(&canon) + .await + .map_err(|e| format!("open {}: {e}", canon.display()))?; + let cap_hint = std::cmp::min(metadata.len(), MAX_FILE_BYTES) as usize; + let mut buf = Vec::with_capacity(cap_hint); + f.take(MAX_FILE_BYTES + 1) + .read_to_end(&mut buf) + .await + .map_err(|e| format!("read {}: {e}", canon.display()))?; + if buf.len() as u64 > MAX_FILE_BYTES { + return Err(format!( + "{} grew past {} byte cap during read (read {} bytes)", + canon.display(), + MAX_FILE_BYTES, + buf.len() + )); + } + buf + }; + 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 +/// `opts.allow_roots` (or the default empty allow-roots if `None`), and +/// return either `NoMarkers` or `WithAttachments`. +/// +/// Relative directive paths are resolved against `opts.base`. If `base` +/// is `None`, relative paths are rejected — process CWD is never used as +/// a fallback. See the module-level "Why relative paths require an +/// explicit base" note. +/// +/// 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, opts: ParseOptions<'_>) -> Parsed { + let re = marker_regex(); + if !re.is_match(text) { + return Parsed::NoMarkers; + } + let owned_default; + let allow_roots: &[PathBuf] = match opts.allow_roots { + 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()); + let mut skipped: Vec<(String, String)> = Vec::new(); + for d in &directives { + match resolve_directive(d, allow_roots, opts.base).await { + Ok(block) => files.push(block), + Err(e) => { + warn!("outbound_attach: skipping {}: {}", d.path, e); + skipped.push((d.path.clone(), e)); + } + } + } + + Parsed::WithAttachments { + stripped_text, + files, + skipped, + } +} + +#[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]) + } + + /// Build [`ParseOptions`] with allow_roots set and base unset. + /// Used by legacy tests that exercise the absolute-path code path. + fn opts_with_roots<'a>(roots: &'a [PathBuf]) -> ParseOptions<'a> { + ParseOptions { + allow_roots: Some(roots), + base: None, + } + } + + #[tokio::test] + async fn no_markers_returns_no_markers() { + let result = parse("just some prose, no markers here", ParseOptions::default()).await; + assert!(matches!(result, Parsed::NoMarkers)); + } + + #[tokio::test] + async fn neutralize_breaks_marker_detection() { + // An agent embeds a well-formed marker with a reassuring caption in a + // field that will be shown to a human approver. + let evil = "rm -rf /important \ + "; + let safe = neutralize_markers(evil); + + // After neutralization the parser sees no marker at all … + assert!(matches!( + parse(&safe, ParseOptions::default()).await, + Parsed::NoMarkers + )); + // … nothing was stripped and no caption was appended; the injection + // attempt stays visible verbatim (opener `<` escaped to `<`). + assert!(safe.contains("<openfang:attach")); + assert!(safe.contains("(dry-run only — nothing deleted)")); + } + + #[test] + fn neutralize_is_noop_without_marker_and_case_insensitive() { + assert_eq!( + neutralize_markers("plain text, no marker"), + "plain text, no marker" + ); + let mixed = neutralize_markers("x "); + assert!(mixed.contains("<OpenFang:Attach")); + } + + #[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, opts_with_roots(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + 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, opts_with_roots(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + 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, opts_with_roots(&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, opts_with_roots(&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, opts_with_roots(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + 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_without_base_is_rejected() { + // base=None ⇒ relative paths cannot resolve. Even with an + // allow-root set, the directive is dropped before any CWD + // fallback could kick in. + let (_keep, roots) = fixture_root(); + let result = parse( + "", + opts_with_roots(&roots), + ) + .await; + match result { + Parsed::WithAttachments { files, .. } => { + assert!( + files.is_empty(), + "relative path with no base must be rejected" + ); + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn relative_path_resolves_against_explicit_base() { + // base=Some(workspace) ⇒ relative path joins under the base, + // canonicalises, passes the allow_roots check, and resolves. + let (tmp, roots) = fixture_root(); + let base = roots[0].clone(); + let path = tmp.path().join("inside.txt"); + std::fs::write(&path, b"yo").unwrap(); + + let text = ""; + let opts = ParseOptions { + allow_roots: Some(&roots), + base: Some(&base), + }; + let result = parse(text, opts).await; + match result { + Parsed::WithAttachments { files, .. } => { + assert_eq!(files.len(), 1, "relative path with base must resolve"); + match &files[0] { + ChannelContent::FileData { data, filename, .. } => { + assert_eq!(data, b"yo"); + assert_eq!(filename, "inside.txt"); + } + _ => panic!("expected FileData"), + } + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn relative_path_with_dotdot_escape_is_rejected() { + // base.join("../escape.txt") canonicalises out of the workspace. + // The allow_roots `starts_with` check then rejects it. This proves + // the base is a *resolution* input, not an *authorisation* input — + // canonicalize + allow_roots still catch traversal. + let (_tmp, roots) = fixture_root(); + let base = roots[0].clone(); + // Create an "escape" file OUTSIDE the workspace. + let outside = std::env::temp_dir().join("openfang-outbound-attach-dotdot-escape.txt"); + std::fs::write(&outside, b"nope").unwrap(); + let outside_canon = std::fs::canonicalize(&outside).unwrap(); + // Build a relative path from `base` to `outside_canon`. We don't + // know the depth of `base` ahead of time, so walk up from base + // until we share a prefix with `outside_canon`'s parent. + let mut up = PathBuf::new(); + let mut cursor: &Path = &base; + while !outside_canon.starts_with(cursor) { + up.push(".."); + cursor = match cursor.parent() { + Some(p) => p, + None => break, + }; + } + // Suffix after the shared prefix. + let suffix = outside_canon.strip_prefix(cursor).unwrap_or(&outside_canon); + let rel = up.join(suffix); + let rel_str = rel.to_string_lossy().to_string(); + + let text = format!("", rel_str); + let opts = ParseOptions { + allow_roots: Some(&roots), + base: Some(&base), + }; + let result = parse(&text, opts).await; + match result { + Parsed::WithAttachments { files, .. } => { + assert!( + files.is_empty(), + "relative `..` escape must be caught by allow_roots after canonicalize" + ); + } + _ => panic!("expected WithAttachments"), + } + let _ = std::fs::remove_file(&outside); + } + + #[tokio::test] + async fn symlink_escape_inside_workspace_is_rejected() { + // A symlink inside the workspace pointing outside it must be + // rejected after canonicalize follows the link and the + // allow_roots check sees the target's location. + use std::os::unix::fs::symlink; + let (tmp, roots) = fixture_root(); + let base = roots[0].clone(); + + // Target lives outside the workspace. + let target = std::env::temp_dir().join("openfang-outbound-attach-symlink-target.txt"); + std::fs::write(&target, b"escape").unwrap(); + let target_canon = std::fs::canonicalize(&target).unwrap(); + // Sanity: target is outside our allow-root. + assert!(!target_canon.starts_with(&base)); + + // Symlink inside the workspace pointing at the outside target. + let link = tmp.path().join("escape_link.txt"); + symlink(&target_canon, &link).expect("symlink"); + + let text = ""; + let opts = ParseOptions { + allow_roots: Some(&roots), + base: Some(&base), + }; + let result = parse(text, opts).await; + match result { + Parsed::WithAttachments { files, .. } => { + assert!( + files.is_empty(), + "symlink escaping the workspace must be rejected" + ); + } + _ => panic!("expected WithAttachments"), + } + let _ = std::fs::remove_file(&target); + } + + #[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, opts_with_roots(&roots)).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + 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", + ParseOptions::default(), + ) + .await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + 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" + ); + } + + #[test] + fn default_allow_roots_is_empty() { + // Fail-closed: callers MUST plumb their per-agent workspace root. + // A caller that forgets and passes None receives no allow-roots + // and every directive is rejected. + assert!(default_allow_roots().is_empty()); + } + + #[tokio::test] + async fn parse_with_default_opts_drops_all_attachments() { + // Default ParseOptions has allow_roots=None (→ empty) and + // base=None. Even an absolute path pointing at an existing file + // is rejected because no allow-root contains it. Text portion + // still comes through. + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("oops.txt"); + std::fs::write(&path, b"x").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!( + "before after", + canon.display() + ); + let result = parse(&text, ParseOptions::default()).await; + match result { + Parsed::WithAttachments { + stripped_text, + files, + skipped: _, + } => { + assert_eq!(stripped_text, "before after"); + assert!( + files.is_empty(), + "default allow-roots is empty; attachment must be rejected" + ); + } + _ => panic!("expected WithAttachments (with empty files)"), + } + } + + #[test] + fn hard_deny_inside_openfang_outside_workspaces() { + // Synthetic paths — no I/O, pure policy check. + let home = Path::new("/Users/x/.openfang"); + assert!(is_hard_denied( + Path::new("/Users/x/.openfang/secrets/discord.toml"), + home + )); + assert!(is_hard_denied( + Path::new("/Users/x/.openfang/daemon/cron.db"), + home + )); + assert!(is_hard_denied(Path::new("/Users/x/.openfang/tmp/x"), home)); + } + + #[test] + fn hard_deny_inside_workspaces_is_allowed() { + // Paths under ~/.openfang/workspaces/ pass the hard-deny rule; + // the per-agent allow-roots check (in resolve_directive) further + // narrows to a single agent. + let home = Path::new("/Users/x/.openfang"); + assert!(!is_hard_denied( + Path::new("/Users/x/.openfang/workspaces/debra/audio.wav"), + home + )); + assert!(!is_hard_denied( + Path::new("/Users/x/.openfang/workspaces/coder-openfang/note.md"), + home + )); + } + + #[test] + fn hard_deny_outside_openfang_is_not_triggered() { + // The hard-deny rule only fires inside ~/.openfang/. Other + // locations are governed solely by the caller-supplied + // allow-roots check. + let home = Path::new("/Users/x/.openfang"); + assert!(!is_hard_denied(Path::new("/tmp/x.txt"), home)); + assert!(!is_hard_denied(Path::new("/Users/x/Documents/x.pdf"), home)); + assert!(!is_hard_denied(Path::new("/etc/passwd"), home)); + } + + #[tokio::test] + async fn parse_with_empty_explicit_roots_drops_attachments() { + // Caller explicitly passes Some(&[]) — same fail-closed outcome + // as None, but exercises the override branch. + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("a.txt"); + std::fs::write(&path, b"x").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!("", canon.display()); + let empty: &[PathBuf] = &[]; + let opts = ParseOptions { + allow_roots: Some(empty), + base: None, + }; + let result = parse(&text, opts).await; + match result { + Parsed::WithAttachments { files, .. } => assert!(files.is_empty()), + _ => panic!("expected WithAttachments"), + } + } + #[tokio::test] + async fn bounded_read_round_trips_multi_kib_file() { + // Regression guard for the TOCTOU bounded-read refactor: confirm a + // file larger than the default Vec capacity hint still reads to + // completion when its size is well under MAX_FILE_BYTES. If the + // take() limit were misconfigured (e.g. capped at metadata.len() + // instead of the hard ceiling) a file that grew slightly between + // stat and read would be truncated silently — this test pins the + // happy path so the cap-vs-truncate distinction stays visible in + // coverage. + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("big.bin"); + let payload: Vec = (0..(256u32 * 1024)).map(|i| (i % 251) as u8).collect(); + std::fs::write(&path, &payload).unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!("", canon.display()); + + let result = parse(&text, opts_with_roots(&roots)).await; + match result { + Parsed::WithAttachments { files, .. } => { + assert_eq!(files.len(), 1); + match &files[0] { + ChannelContent::FileData { data, .. } => { + assert_eq!(data.len(), payload.len()); + assert_eq!(data, &payload); + } + _ => panic!("expected FileData"), + } + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn missing_file_populates_skipped() { + // A directive whose path doesn't exist must populate `skipped` + // with (path, reason) so callers can surface it to the agent. + // The message still parses successfully and the text portion + // comes through — partial success is intentional (see module + // docs); the skipped vec is the channel for telling the caller + // *what* got dropped. + let (_tmp, roots) = fixture_root(); + let base = roots[0].clone(); + let text = "before after"; + let opts = ParseOptions { + allow_roots: Some(&roots), + base: Some(&base), + }; + match parse(text, opts).await { + Parsed::WithAttachments { + stripped_text, + files, + skipped, + } => { + assert_eq!(stripped_text, "before after"); + assert!(files.is_empty()); + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].0, "does-not-exist.txt"); + assert!( + skipped[0].1.contains("canonicalize"), + "expected canonicalize error, got: {}", + skipped[0].1 + ); + } + _ => panic!("expected WithAttachments"), + } + } + + #[tokio::test] + async fn resolved_attachment_leaves_skipped_empty() { + // Happy path: a resolvable directive yields an empty `skipped` + // vec. Pins the invariant that `skipped` is the *failure* + // channel, not a per-directive audit log. + let (tmp, roots) = fixture_root(); + let path = tmp.path().join("ok.txt"); + std::fs::write(&path, b"ok").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + let text = format!("", canon.display()); + match parse(&text, opts_with_roots(&roots)).await { + Parsed::WithAttachments { files, skipped, .. } => { + assert_eq!(files.len(), 1); + assert!( + skipped.is_empty(), + "happy path must not populate skipped, got: {:?}", + skipped + ); + } + _ => panic!("expected WithAttachments"), + } + } +} diff --git a/crates/openfang-channels/src/resolver_audit.rs b/crates/openfang-channels/src/resolver_audit.rs new file mode 100644 index 0000000000..1b671b34a0 --- /dev/null +++ b/crates/openfang-channels/src/resolver_audit.rs @@ -0,0 +1,232 @@ +//! Recipient-resolution audit log (ANAI-55, security follow-up). +//! +//! Writes one line per successful recipient resolution to a dedicated file +//! (`$OPENFANG_HOME/daemon/resolver_audit.log`, mode `0o600`), bypassing the +//! `tracing` subscriber stack entirely. +//! +//! ## Why a dedicated sink, not a `tracing` target? +//! +//! Security review (ANAI-55 §6.1, F1 disposition) required that the audit +//! stream be **structurally** isolated from payload logs so that a log scrape +//! cannot correlate "agent X resolved recipient Y" with adjacent message +//! bodies by timestamp. +//! +//! Routing an audit `tracing` target to a different file requires composing a +//! split `tracing-subscriber` layer graph at every binary entry point +//! (`openfang-cli`, `openfang-desktop`, ...). That correctness is fragile — +//! any future EnvFilter change or wildcard layer can silently re-merge the +//! streams. Writing directly to a dedicated file makes isolation a property +//! of the sink, not a consequence of the subscriber graph. +//! +//! ## Concurrency +//! +//! Resolutions are rare (cache-miss path), so `std::sync::Mutex` is +//! sufficient and avoids any drop-on-shutdown hazard an mpsc writer task +//! would introduce. The lock window is one `writeln!`. **Never** hold this +//! lock across an `.await` — the file write is synchronous by design. +//! +//! ## File mode +//! +//! Created with `0o600` on Unix (audit log MUST NOT be world-readable). On +//! non-Unix platforms the mode hint is dropped silently — those targets are +//! not supported for production daemon use today. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::OnceLock; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +/// Process-wide singleton. `None` if the audit log could not be opened on +/// first use (e.g., permission denied); resolution proceeds without auditing +/// rather than failing closed on a logging error. The failure is reported +/// via `tracing::error!` exactly once. +static AUDIT_FILE: OnceLock>> = OnceLock::new(); + +fn openfang_home() -> PathBuf { + if let Ok(h) = std::env::var("OPENFANG_HOME") { + return PathBuf::from(h); + } + if let Ok(h) = std::env::var("HOME") { + return PathBuf::from(h).join(".openfang"); + } + PathBuf::from(".openfang") +} + +fn audit_log_path() -> PathBuf { + openfang_home().join("daemon").join("resolver_audit.log") +} + +fn open_audit_file(path: &std::path::Path) -> Option> { + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::error!( + "resolver_audit: failed to create log directory {}: {}", + parent.display(), + e + ); + return None; + } + } + + let mut opts = OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + // O_NOFOLLOW: refuse to open if the audit path is a symlink. + // Defense-in-depth: $OPENFANG_HOME is user-owned, but a pre-staged + // symlink at the audit path would otherwise redirect daemon writes. + opts.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = match opts.open(path) { + Ok(f) => f, + Err(e) => { + tracing::error!( + "resolver_audit: failed to open log {}: {}", + path.display(), + e + ); + return None; + } + }; + + // `OpenOptions::mode` only applies on file *creation*. If the file + // pre-exists with looser permissions (manual touch, stray test, prior + // build under different umask), converge it to 0o600 unconditionally. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + tracing::error!( + "resolver_audit: failed to set mode 0o600 on {}: {}", + path.display(), + e + ); + // Continue: better to audit with looser perms than not at all. + // The error is recorded above for the operator. + } + } + + Some(Mutex::new(file)) +} + +/// Record a successful recipient resolution. Best-effort: a failure to write +/// the audit line is logged at error level but never propagated to the +/// caller. +/// +/// `input` is the raw recipient string as supplied by the agent. +/// `platform_id` is the resolved platform-native ID. +/// `via` is a short tag for the resolution path (e.g. `"snowflake"`, +/// `"channel_mention"`, `"username_cache"`). +pub fn record_resolution(adapter: &str, input: &str, platform_id: &str, via: &str) { + let slot = AUDIT_FILE.get_or_init(|| open_audit_file(&audit_log_path())); + let Some(mu) = slot else { + return; + }; + let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let line = format!( + "{ts} adapter={adapter} input={input:?} resolved_platform_id={platform_id} via={via}\n" + ); + // Recover from a poisoned mutex: with only `writeln!` in the critical + // section, panic-while-holding is nearly impossible, but if it ever + // happens we'd silently mute the audit log forever. Take the inner. + let mut f = mu.lock().unwrap_or_else(|p| p.into_inner()); + if let Err(e) = f.write_all(line.as_bytes()) { + tracing::error!("resolver_audit: write failed: {}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `audit_log_path()` composes `$OPENFANG_HOME/daemon/resolver_audit.log`. + /// We test this via env mutation in exactly one place; all other tests + /// pass an explicit path into `open_audit_file` to avoid env-var races + /// under parallel test execution. + #[test] + fn audit_log_path_composes_under_openfang_home() { + let tmp = tempfile::tempdir().unwrap(); + // Single-test env mutation; the result is consumed before any other + // test could observe it. Still, prefer the explicit-path tests below + // for new coverage. + let prev = std::env::var("OPENFANG_HOME").ok(); + std::env::set_var("OPENFANG_HOME", tmp.path()); + let p = audit_log_path(); + match prev { + Some(v) => std::env::set_var("OPENFANG_HOME", v), + None => std::env::remove_var("OPENFANG_HOME"), + } + assert!(p.starts_with(tmp.path())); + assert!(p.ends_with("daemon/resolver_audit.log")); + } + + /// `open_audit_file` creates the file with mode `0o600` on Unix and + /// creates the parent directory lazily. + #[test] + fn open_audit_file_creates_0600_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("daemon").join("resolver_audit.log"); + + assert!(open_audit_file(&path).is_some()); + assert!( + path.exists(), + "audit file not created at {}", + path.display() + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let md = std::fs::metadata(&path).unwrap(); + let mode = md.permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "audit file mode is {:o}, expected 0600", mode); + } + } + + /// Defense-in-depth: if the audit path pre-exists as a symlink, + /// `O_NOFOLLOW` must cause the open to fail closed (returns `None`) + /// instead of redirecting daemon writes to the symlink target. + #[cfg(unix)] + #[test] + fn open_audit_file_refuses_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("daemon").join("resolver_audit.log"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + + let target = tmp.path().join("symlink_target.log"); + std::fs::write(&target, b"").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + assert!( + open_audit_file(&path).is_none(), + "expected None when audit path is a symlink" + ); + assert_eq!( + std::fs::read(&target).unwrap(), + b"", + "symlink target was written to despite O_NOFOLLOW" + ); + } + + /// `OpenOptions::mode` only applies on creation. If the file pre-exists + /// with looser perms, we must converge it to 0o600 on open. + #[cfg(unix)] + #[test] + fn open_audit_file_converges_mode_to_0600() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("daemon").join("resolver_audit.log"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert!(open_audit_file(&path).is_some()); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected mode 0o600, got {:o}", mode); + } +} diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index 5341ed5a2e..08e029dbb7 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -540,6 +540,11 @@ impl TelegramAdapter { self.api_send_message(chat_id, text.trim(), thread_id) .await?; } + ChannelContent::Interactive { text, .. } => { + // Telegram has no interactive-button rendering here; degrade to + // the self-sufficient text body (which carries /approve ). + self.api_send_message(chat_id, &text, thread_id).await?; + } ChannelContent::Multipart(parts) => { // Send each child as its own Telegram message. Nested // Multipart is rejected by adapters; flatten defensively. diff --git a/crates/openfang-channels/src/types.rs b/crates/openfang-channels/src/types.rs index 035735134b..a96631ed6e 100644 --- a/crates/openfang-channels/src/types.rs +++ b/crates/openfang-channels/src/types.rs @@ -86,6 +86,16 @@ pub enum ChannelContent { /// content blocks. Implementations should not produce nested `Multipart` /// values; consumers may `debug_assert!` against nesting. Multipart(Vec), + /// An interactive message: a text body plus one or more action buttons. + /// Discord renders this as a message with an action row; adapters that do + /// not override [`ChannelAdapter::supports_interactive`] degrade to the + /// `text` body alone (callers MUST keep `text` self-sufficient, e.g. it + /// still contains `/approve ` for the #2a text path). `buttons` carry + /// an opaque `custom_id` only — never authorization (ANAI-82). + Interactive { + text: String, + buttons: Vec, + }, } /// A unified message from any channel. @@ -284,6 +294,70 @@ pub struct ChannelStatus { // Re-export policy/format types from openfang-types for convenience. pub use openfang_types::config::{DmPolicy, GroupPolicy, OutputFormat}; +/// Structured error returned by [`ChannelAdapter::resolve_recipient`] when +/// a recipient string cannot be turned into a platform-native identifier. +/// +/// These variants are surfaced verbatim through `ToolError::RecipientUnresolved` +/// so the calling agent can distinguish "you typed it wrong" from "we know +/// who you mean but can't reach them" from "this is ambiguous, please qualify". +#[derive(Debug, Clone)] +pub enum ResolutionError { + /// No channel or known user matches the recipient string. + /// + /// For Discord this typically means we have never seen an inbound + /// MESSAGE_CREATE from the named user, or GUILD_CREATE has not yet + /// landed channel metadata for the named channel. + UnknownRecipient { recipient: String }, + + /// A bare channel name (e.g. `"general"` or `"#general"`) matches more + /// than one guild's channel list. The agent must qualify with the + /// channel mention `<#…>` or upstream tooling. + AmbiguousChannel { name: String, guilds: Vec }, + + /// A bare username (no leading `@` or `<@id>` form) was passed as a DM + /// target. Refused by design to eliminate the username-collision class + /// where a legitimate Discord user shares a username with someone the + /// agent intends to message (see ANAI-55 security review, finding F1). + BareNameDmRefused { name: String }, + + /// The platform refused to open a DM channel with the resolved user + /// (e.g. Discord returned 403 because DMs are closed or the bot is + /// blocked). Fail-closed; no auto-retry. + DmOpenFailed { user_id: String, status: u16 }, +} + +impl std::fmt::Display for ResolutionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolutionError::UnknownRecipient { recipient } => write!( + f, + "No channel or known user matches `{recipient}`. The user must \ + have messaged us at least once before they can be addressed by name." + ), + ResolutionError::AmbiguousChannel { name, guilds } => write!( + f, + "Channel `#{name}` exists in {} guilds ({}). Use the channel \ + mention `<#…>` to disambiguate.", + guilds.len(), + guilds.join(", ") + ), + ResolutionError::BareNameDmRefused { name } => write!( + f, + "DM recipient must be qualified as `@{name}` or `<@user_id>`. \ + Bare names are not resolved for DM safety." + ), + ResolutionError::DmOpenFailed { user_id, status } => write!( + f, + "Platform refused to open a DM channel with user {user_id} \ + (status {status}). They may have DMs disabled or have blocked \ + the bot." + ), + } + } +} + +impl std::error::Error for ResolutionError {} + /// Trait that every channel adapter must implement. /// /// A channel adapter bridges a messaging platform to the OpenFang kernel by converting @@ -308,6 +382,16 @@ pub trait ChannelAdapter: Send + Sync { content: ChannelContent, ) -> Result<(), Box>; + /// Whether this adapter can render [`ChannelContent::Interactive`] + /// (action buttons). Default `false`: the dispatch path degrades + /// interactive content to its text body before calling `send`. Discord + /// overrides this to `true` (ANAI-82). Keeping the default `false` means + /// the ~50 other adapters need no change and never receive a variant they + /// cannot render. + fn supports_interactive(&self) -> bool { + false + } + /// Send a typing indicator (optional — default no-op). async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box> { Ok(()) @@ -367,6 +451,31 @@ pub trait ChannelAdapter: Send + Sync { fn suppress_error_responses(&self) -> bool { false } + + /// Resolve a free-form recipient string into a platform-native + /// [`ChannelUser`] before dispatch. + /// + /// The default implementation is **passthrough** — it wraps the input + /// string as both `platform_id` and `display_name`. This preserves the + /// pre-ANAI-55 behavior for every adapter that does not override the + /// method (i.e. every adapter except Discord, until others opt in). + /// + /// Adapters that override this method should: + /// - Accept platform-native IDs (e.g. Discord snowflakes) verbatim. + /// - Accept the platform's mention/handle forms where they exist. + /// - Fail closed with a structured [`ResolutionError`] on miss, + /// ambiguity, or any unsafe shorthand. + /// - Never blast a message at a fallback recipient on resolution failure. + /// + /// See ANAI-55 and the channel-send-attachments proposal for the + /// Discord-specific resolution matrix. + async fn resolve_recipient(&self, recipient: &str) -> Result { + Ok(ChannelUser { + platform_id: recipient.to_string(), + display_name: recipient.to_string(), + openfang_user: None, + }) + } } /// Split a message into chunks of at most `max_len` characters, @@ -593,3 +702,55 @@ mod tests { assert!(json.contains("Connection refused")); } } + + +/// A single button in a [`ChannelContent::Interactive`] action row. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InteractiveButton { + /// Opaque identifier echoed back verbatim on click (Discord `custom_id`). + /// Encodes the approval request id + nonce; MUST NOT carry authorization + /// (capability-in-URL antipattern — authz is the clicking user, checked + /// server-side at resolve time). Discord caps this at 100 chars. + pub custom_id: String, + /// Visible button label. + pub label: String, + /// Visual style hint; mapped to a native style by the adapter. + pub style: ButtonStyle, +} + +/// Visual style for an [`InteractiveButton`]. Names are platform-neutral; +/// adapters map them to native styles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ButtonStyle { + Primary, + Secondary, + Success, + Danger, +} + +impl ButtonStyle { + /// Discord Component button `style` integer (API v10): + /// Primary=1, Secondary=2, Success=3, Danger=4. + pub fn discord_style(self) -> u8 { + match self { + ButtonStyle::Primary => 1, + ButtonStyle::Secondary => 2, + ButtonStyle::Success => 3, + ButtonStyle::Danger => 4, + } + } +} + +impl ChannelContent { + /// Collapse a richer variant to a plain-text equivalent for adapters that + /// cannot render it. Used by the dispatch path to gracefully degrade an + /// [`ChannelContent::Interactive`] to text on adapters whose + /// [`ChannelAdapter::supports_interactive`] is `false`. All other variants + /// pass through unchanged. + pub fn degrade_to_text(self) -> ChannelContent { + match self { + ChannelContent::Interactive { text, .. } => ChannelContent::Text(text), + other => other, + } + } +} diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 62e728ba97..f9012362a1 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1566,7 +1566,21 @@ 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 +1616,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/approval.rs b/crates/openfang-kernel/src/approval.rs index 09be0360ce..41339e2be3 100644 --- a/crates/openfang-kernel/src/approval.rs +++ b/crates/openfang-kernel/src/approval.rs @@ -13,12 +13,19 @@ use uuid::Uuid; const MAX_PENDING_PER_AGENT: usize = 5; /// Max recent approval records to retain for history and UI visibility. const MAX_RECENT_APPROVALS: usize = 100; +/// Capacity of the additive approval-lifecycle broadcast channel. Sized to +/// absorb bursts without lagging slow surfacers; on overflow the oldest events +/// are dropped (lossy by design — decision semantics never depend on delivery). +const APPROVAL_EVENT_BUFFER: usize = 256; /// Manages approval requests with oneshot channels for blocking resolution. pub struct ApprovalManager { pending: DashMap, recent: std::sync::Mutex>, policy: std::sync::RwLock, + /// Additive lifecycle event source. Emitting requires no subscribers; if + /// none are listening, sends fail silently and approval flow is unaffected. + events: tokio::sync::broadcast::Sender, } struct PendingRequest { @@ -34,15 +41,49 @@ pub struct ApprovalRecord { pub decided_by: Option, } +/// Lifecycle events emitted by the [`ApprovalManager`] so out-of-band +/// surfacers (e.g. the channel bridge) can push prompts and post follow-ups. +/// +/// Purely additive: delivered on a `tokio::sync::broadcast` channel that +/// requires no subscribers. With no listener, sends are dropped silently and +/// the approve / deny / timeout decision path is completely unaffected. The +/// carried [`ApprovalRequest`] includes its `origin`, which a surfacer may use +/// to resolve a push destination — `origin` is audit/targeting only and is +/// never an authorization carrier. +#[derive(Debug, Clone)] +pub enum ApprovalEvent { + /// A new request was parked and is awaiting human resolution. + Submitted(ApprovalRequest), + /// A request was resolved by an operator (approved or denied). + Resolved { + request: ApprovalRequest, + decision: ApprovalDecision, + decided_by: Option, + }, + /// A request expired before any operator resolved it. + TimedOut(ApprovalRequest), +} + impl ApprovalManager { pub fn new(policy: ApprovalPolicy) -> Self { + let (events, _) = tokio::sync::broadcast::channel(APPROVAL_EVENT_BUFFER); Self { pending: DashMap::new(), recent: std::sync::Mutex::new(VecDeque::new()), policy: std::sync::RwLock::new(policy), + events, } } + /// Subscribe to the additive approval-lifecycle event stream. + /// + /// Returns a fresh `broadcast::Receiver`. Subscribing is the only way to + /// observe [`ApprovalEvent`]s; not subscribing leaves all existing behavior + /// untouched. The channel is lossy under load (see [`APPROVAL_EVENT_BUFFER`]). + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.events.subscribe() + } + /// Check if a tool requires approval based on current policy. pub fn requires_approval(&self, tool_name: &str) -> bool { let policy = self.policy.read().unwrap_or_else(|e| e.into_inner()); @@ -65,6 +106,7 @@ impl ApprovalManager { let timeout = std::time::Duration::from_secs(req.timeout_secs); let id = req.id; let req_for_timeout = req.clone(); + let req_for_event = req.clone(); let (tx, rx) = tokio::sync::oneshot::channel(); self.pending.insert( @@ -76,6 +118,8 @@ impl ApprovalManager { ); info!(request_id = %id, "Approval request submitted, waiting for resolution"); + // Additive surfacing hook: lossy, non-blocking, no-op without subscribers. + let _ = self.events.send(ApprovalEvent::Submitted(req_for_event)); match tokio::time::timeout(timeout, rx).await { Ok(Ok(decision)) => { @@ -88,6 +132,7 @@ impl ApprovalManager { .remove(&id) .map(|(_, pending)| pending.request) .unwrap_or(req_for_timeout); + let _ = self.events.send(ApprovalEvent::TimedOut(request.clone())); self.push_recent(request, ApprovalDecision::TimedOut, None, Utc::now()); warn!(request_id = %id, "Approval request timed out"); ApprovalDecision::TimedOut @@ -118,6 +163,12 @@ impl ApprovalManager { ); // Send decision to waiting agent (ignore error if receiver dropped) let _ = pending.sender.send(decision); + // Additive surfacing hook: lossy, non-blocking, no-op without subscribers. + let _ = self.events.send(ApprovalEvent::Resolved { + request: pending.request, + decision, + decided_by: response.decided_by.clone(), + }); info!(request_id = %request_id, ?decision, "Approval request resolved"); Ok(response) } @@ -211,6 +262,7 @@ mod tests { risk_level: RiskLevel::High, requested_at: Utc::now(), timeout_secs, + origin: None, } } @@ -332,6 +384,97 @@ mod tests { assert_eq!(mgr.pending_count(), 0); } + // ----------------------------------------------------------------------- + // subscribe — additive lifecycle events (Y step 5 substrate) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_subscribe_receives_submitted_and_resolved() { + let mgr = Arc::new(default_manager()); + let mut rx = mgr.subscribe(); + let req = make_request("agent-evt", "shell_exec", 60); + let id = req.id; + + let mgr2 = Arc::clone(&mgr); + let join = tokio::spawn(async move { mgr2.request_approval(req).await }); + + match rx.recv().await.expect("submitted event") { + ApprovalEvent::Submitted(r) => assert_eq!(r.id, id), + other => panic!("expected Submitted, got {other:?}"), + } + + mgr.resolve(id, ApprovalDecision::Approved, Some("ben".to_string())) + .expect("resolve"); + + match rx.recv().await.expect("resolved event") { + ApprovalEvent::Resolved { + request, + decision, + decided_by, + } => { + assert_eq!(request.id, id); + assert_eq!(decision, ApprovalDecision::Approved); + assert_eq!(decided_by.as_deref(), Some("ben")); + } + other => panic!("expected Resolved, got {other:?}"), + } + + assert_eq!(join.await.unwrap(), ApprovalDecision::Approved); + } + + #[tokio::test(start_paused = true)] + async fn test_subscribe_receives_timed_out() { + let mgr = Arc::new(default_manager()); + let mut rx = mgr.subscribe(); + let req = make_request("agent-evt", "shell_exec", 10); + let id = req.id; + + let decision = mgr.request_approval(req).await; + assert_eq!(decision, ApprovalDecision::TimedOut); + + match rx.recv().await.expect("submitted event") { + ApprovalEvent::Submitted(r) => assert_eq!(r.id, id), + other => panic!("expected Submitted, got {other:?}"), + } + match rx.recv().await.expect("timed-out event") { + ApprovalEvent::TimedOut(r) => assert_eq!(r.id, id), + other => panic!("expected TimedOut, got {other:?}"), + } + } + + #[tokio::test] + async fn test_subscribe_origin_carried_on_submitted() { + // origin rides the event verbatim — audit/targeting only, never authz. + let mgr = Arc::new(default_manager()); + let mut rx = mgr.subscribe(); + let mut req = make_request("agent-evt", "shell_exec", 60); + req.origin = Some(openfang_types::approval::ApprovalOrigin { + channel_type: "discord".to_string(), + channel_id: Some("chan-123".to_string()), + thread_id: Some("thread-9".to_string()), + recipient: Some("peer-7".to_string()), + }); + let id = req.id; + + let mgr2 = Arc::clone(&mgr); + let join = tokio::spawn(async move { mgr2.request_approval(req).await }); + + match rx.recv().await.expect("submitted event") { + ApprovalEvent::Submitted(r) => { + assert_eq!(r.id, id); + let o = r.origin.expect("origin present"); + assert_eq!(o.channel_type, "discord"); + assert_eq!(o.channel_id.as_deref(), Some("chan-123")); + assert_eq!(o.thread_id.as_deref(), Some("thread-9")); + assert_eq!(o.recipient.as_deref(), Some("peer-7")); + } + other => panic!("expected Submitted, got {other:?}"), + } + + mgr.resolve(id, ApprovalDecision::Denied, None).expect("resolve"); + assert_eq!(join.await.unwrap(), ApprovalDecision::Denied); + } + // ----------------------------------------------------------------------- // request_approval — timeout // ----------------------------------------------------------------------- diff --git a/crates/openfang-kernel/src/auth.rs b/crates/openfang-kernel/src/auth.rs index 6bc4a538c2..004ebd50e5 100644 --- a/crates/openfang-kernel/src/auth.rs +++ b/crates/openfang-kernel/src/auth.rs @@ -65,6 +65,8 @@ pub enum Action { ViewUsage, /// Manage users (create, delete, change roles). ManageUsers, + /// Resolve (approve/deny) a pending execution-approval request. + ApproveExecution, } impl Action { @@ -79,6 +81,10 @@ impl Action { Action::InstallSkill => UserRole::Admin, Action::ModifyConfig => UserRole::Owner, Action::ManageUsers => UserRole::Owner, + // Approving a dangerous tool call is an operator action; Admin is + // the floor. (#2a binding-owner gate — role-based proxy until + // per-binding ownership lands as its own schema change.) + Action::ApproveExecution => UserRole::Admin, } } } @@ -297,6 +303,75 @@ mod tests { assert!(manager.authorize(fake_id, &Action::ChatWithAgent).is_err()); } + /// Build a config with one user per role, all bound on discord. + fn rbac_role_configs() -> Vec { + let bind = |id: &str| { + let mut m = HashMap::new(); + m.insert("discord".to_string(), id.to_string()); + m + }; + vec![ + UserConfig { + name: "Owner".to_string(), + role: "owner".to_string(), + channel_bindings: bind("owner-1"), + api_key_hash: None, + }, + UserConfig { + name: "Admin".to_string(), + role: "admin".to_string(), + channel_bindings: bind("admin-1"), + api_key_hash: None, + }, + UserConfig { + name: "Member".to_string(), + role: "user".to_string(), + channel_bindings: bind("user-1"), + api_key_hash: None, + }, + UserConfig { + name: "Viewer".to_string(), + role: "viewer".to_string(), + channel_bindings: bind("viewer-1"), + api_key_hash: None, + }, + ] + } + + // #2a (ANAI-81) M3: the ApproveExecution gate authorizes approval + // resolution at an Admin floor. Recognized Admin/Owner resolve; recognized + // below-floor roles are denied. + #[test] + fn test_approve_execution_admin_floor() { + let manager = AuthManager::new(&rbac_role_configs()); + + let owner = manager.identify("discord", "owner-1").unwrap(); + let admin = manager.identify("discord", "admin-1").unwrap(); + assert!(manager.authorize(owner, &Action::ApproveExecution).is_ok()); + assert!(manager.authorize(admin, &Action::ApproveExecution).is_ok()); + + let member = manager.identify("discord", "user-1").unwrap(); + let viewer = manager.identify("discord", "viewer-1").unwrap(); + assert!(manager + .authorize(member, &Action::ApproveExecution) + .is_err()); + assert!(manager + .authorize(viewer, &Action::ApproveExecution) + .is_err()); + } + + // #2a (ANAI-81) M3: unrecognized channel identity yields no UserId (the + // resolve path rejects before authorize), and an unknown UserId is denied. + #[test] + fn test_approve_execution_unrecognized_denied() { + let manager = AuthManager::new(&rbac_role_configs()); + + assert!(manager.identify("discord", "stranger").is_none()); + + let fake = UserId::new(); + assert!(manager.authorize(fake, &Action::ApproveExecution).is_err()); + } + #[test] fn test_no_users_means_disabled() { let manager = AuthManager::new(&[]); diff --git a/crates/openfang-kernel/src/heartbeat.rs b/crates/openfang-kernel/src/heartbeat.rs index 75554b1da6..3463e34849 100644 --- a/crates/openfang-kernel/src/heartbeat.rs +++ b/crates/openfang-kernel/src/heartbeat.rs @@ -318,6 +318,7 @@ mod tests { id: AgentId::new(), name: name.to_string(), manifest: AgentManifest { + file_policy: None, name: name.to_string(), version: "0.1.0".to_string(), description: "test".to_string(), diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c97..6001a0d563 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,14 @@ pub struct OpenFangKernel { agent_msg_locks: dashmap::DashMap>>, /// Weak self-reference for trigger dispatch (set after Arc wrapping). self_handle: OnceLock>, + /// 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>>, } /// Bounded in-memory delivery receipt tracker. @@ -587,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() @@ -699,7 +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. - let primary_result = drivers::create_driver(&driver_config); + // 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 { @@ -723,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) { + match drivers::create_driver(&auto_config, token_issuer.clone()) { Ok(d) => { info!( provider = %provider, @@ -772,7 +808,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, token_issuer.clone()) { Ok(d) => { info!( provider = %fb.provider, @@ -1220,6 +1256,11 @@ impl OpenFangKernel { fallback_providers_override: std::sync::RwLock::new(None), agent_msg_locks: dashmap::DashMap::new(), self_handle: OnceLock::new(), + // 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 @@ -1273,6 +1314,7 @@ impl OpenFangKernel { // had no effect on agents whose manifests cached the older // inherited Allowlist policy at spawn time). let mut disk_has_exec_policy_override = false; + let mut disk_has_file_policy_override = false; // Check if TOML on disk is newer/different — if so, update from file let mut entry = entry; @@ -1295,6 +1337,9 @@ impl OpenFangKernel { if disk_manifest.exec_policy.is_some() { disk_has_exec_policy_override = true; } + if disk_manifest.file_policy.is_some() { + disk_has_file_policy_override = true; + } // Compare key fields to detect changes. // IMPORTANT: keep this list in sync with AgentManifest // fields that users may legitimately edit in agent.toml. @@ -1329,7 +1374,9 @@ impl OpenFangKernel { || disk_manifest.autonomous != entry.manifest.autonomous || disk_manifest.resources != entry.manifest.resources || disk_manifest.exec_policy - != entry.manifest.exec_policy; + != entry.manifest.exec_policy + || disk_manifest.file_policy + != entry.manifest.file_policy; if changed { info!( agent = %name, @@ -1406,6 +1453,22 @@ impl OpenFangKernel { Some(kernel.config.exec_policy.clone()); } + // F2: re-resolve file_policy under the current global floor + // on every restart (mirrors exec_policy #1132) so config + // edits propagate and the transient floor — which is never + // persisted — is re-derived. A disk override is kept as the + // narrowing layer; absent one, global applies wholesale. + let fp_override = if disk_has_file_policy_override { + restored_entry.manifest.file_policy.take() + } else { + None + }; + restored_entry.manifest.file_policy = + Some(openfang_types::config::FilePolicy::resolve_under_floor( + &kernel.config.file_policy, + fp_override, + )); + // Apply global budget defaults to restored agents apply_budget_defaults( &kernel.config.budget, @@ -1624,6 +1687,15 @@ impl OpenFangKernel { } info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved"); + // F2: resolve file_policy under the global floor. The global + // `[file_policy]` is a hard floor — a per-agent override may only + // narrow it, never widen past it. With no override the global applies + // wholesale (closes the dead-global-config defect). + manifest.file_policy = Some(openfang_types::config::FilePolicy::resolve_under_floor( + &self.config.file_policy, + manifest.file_policy.take(), + )); + // Overlay kernel default_model onto agent if agent didn't explicitly choose. // Treat empty or "default" as "use the kernel's configured default_model". // This allows bundled agents to defer to the user's configured provider/model, @@ -2378,6 +2450,7 @@ impl OpenFangKernel { ctx_window, Some(&kernel_clone.process_manager), content_blocks, + None, // origin (Piece 2 plumbing — populated at gated emit step) ) .await; @@ -2899,6 +2972,8 @@ impl OpenFangKernel { temperature: manifest.model.temperature, 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!( @@ -2969,6 +3044,7 @@ impl OpenFangKernel { ctx_window, Some(&self.process_manager), content_blocks, + None, // origin (Piece 2 plumbing — populated at gated emit step) ) .await .map_err(KernelError::OpenFang)?; @@ -3293,6 +3369,18 @@ impl OpenFangKernel { { manifest_for_disk.exec_policy = None; } + // F2: same treatment for the inherited file_policy. The wholesale + // global-inherit case (== global) is stripped so a later config + // edit is not shadowed by a stale snapshot. A genuine per-agent + // override carries a transient floor and so never compares equal to + // the bare global, and is therefore preserved. + if manifest_for_disk + .file_policy + .as_ref() + .is_some_and(|p| p == &self.config.file_policy) + { + manifest_for_disk.file_policy = None; + } match toml::to_string_pretty(&manifest_for_disk) { Ok(toml_str) => { if let Err(e) = std::fs::create_dir_all(&dir) { @@ -4071,6 +4159,24 @@ 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. @@ -5655,7 +5761,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 @@ -5739,7 +5845,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); @@ -5774,7 +5880,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))); } @@ -6145,7 +6251,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>, @@ -6751,6 +6857,9 @@ pub(crate) fn merge_disk_manifest_preserving_kernel_defaults( if disk.exec_policy.is_none() && entry.exec_policy.is_some() { disk.exec_policy = entry.exec_policy.clone(); } + if disk.file_policy.is_none() && entry.file_policy.is_some() { + disk.file_policy = entry.file_policy.clone(); + } disk } @@ -7011,7 +7120,7 @@ async fn cron_deliver_response( .structured_set(agent_id, "delivery.last_channel", kv_val); // Deliver via the registered channel adapter kernel - .send_channel_message(channel, to, response, None) + .send_channel_message(channel, to, response, None, None) .await .map(|_| { tracing::info!(channel = %channel, to = %to, "Cron: delivered to channel"); @@ -7031,7 +7140,7 @@ async fn cron_deliver_response( let recipient = val["recipient"].as_str().unwrap_or(""); if !channel.is_empty() && !recipient.is_empty() { kernel - .send_channel_message(channel, recipient, response, None) + .send_channel_message(channel, recipient, response, None, None) .await .map(|_| { tracing::info!(channel = %channel, recipient = %recipient, "Cron: delivered to last channel"); @@ -7108,7 +7217,7 @@ impl openfang_channels::bridge::ChannelBridgeHandle for KernelCronBridge { message: &str, ) -> Result<(), String> { self.kernel - .send_channel_message(channel_type, recipient, message, None) + .send_channel_message(channel_type, recipient, message, None, None) .await .map(|_| ()) } @@ -7162,8 +7271,187 @@ async fn cron_fan_out_targets( } } +/// Resolved destination for a proactive approval-prompt push. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ApprovalPushTarget { + pub channel_type: String, + pub channel_id: String, + pub thread_id: Option, +} + +/// Decide whether — and where — to proactively surface an approval prompt. +/// +/// Fail-closed: returns `None` (⇒ no proactive push; the self-sufficient text +/// `/approve ` body that already accompanied the request stands) unless the +/// request carries an `origin` with BOTH a non-empty `channel_type` and a +/// concrete, non-empty `channel_id`. Anything less can only resolve to a +/// broader or ambiguous destination, which would be a cross-channel leak. +/// +/// `recipient` (the triggering peer_id) is deliberately NOT part of the target: +/// it is audit/targeting metadata only, never an authorization or routing input +/// here. The resolved push goes to the *conversation* (`channel_id`), and the +/// operator who later acts on the prompt is re-authorized from the +/// platform-attested interaction identity, not from `origin`. +pub(crate) fn approval_push_target( + req: &openfang_types::approval::ApprovalRequest, +) -> Option { + let origin = req.origin.as_ref()?; + if origin.channel_type.is_empty() { + return None; + } + let channel_id = origin.channel_id.as_deref()?; + if channel_id.is_empty() { + return None; + } + Some(ApprovalPushTarget { + channel_type: origin.channel_type.clone(), + channel_id: channel_id.to_string(), + thread_id: origin.thread_id.clone(), + }) +} + +impl OpenFangKernel { + /// Surface a freshly-submitted approval request back to the channel / + /// conversation that triggered the run (§8 step 5 — the emit consumer). + /// + /// Pure side-effect: this only *pushes a prompt*. It NEVER resolves the + /// request. Approval still happens via the text `/approve ` command or + /// the button-interaction handler, both of which re-authorize the operator + /// from the platform-attested interaction identity. `origin` (including + /// `recipient`) is audit/targeting only and is never an authorization + /// carrier — nothing here reaches `classify_approver`. + /// + /// Fail-closed: with no resolvable [`ApprovalPushTarget`] or a failed push, + /// we log and return. We never best-effort to a broader or different + /// channel. + pub async fn surface_approval_prompt(&self, req: &openfang_types::approval::ApprovalRequest) { + let Some(target) = approval_push_target(req) else { + tracing::debug!( + request_id = %req.id, + "approval surfacer: no resolvable push target; text /approve path stands" + ); + return; + }; + + // Self-sufficient prompt body: STILL carries `/approve ` so it + // degrades correctly on adapters that cannot render anything richer. + let id = req.id; + // Agent-controlled fields flow through `send_channel_message` → + // `outbound_attach::parse`. Neutralize any `` marker + // so a requesting agent cannot strip text / inject a `caption=` line and + // make the rendered prompt diverge from the real action (security-openfang + // MEDIUM, ANAI-82). The marker stays visible (opener `<` escaped) but is + // never interpreted; file exfil is independently blocked (no allow_roots). + let neutralize = openfang_channels::outbound_attach::neutralize_markers; + let message = format!( + "🔐 Approval needed — agent `{agent}` wants to run `{tool}`.\n{summary}\n\nApprove: `/approve {id}` · Reject: `/reject {id}`", + agent = neutralize(&req.agent_id), + tool = neutralize(&req.tool_name), + summary = neutralize(&req.action_summary), + ); + + // `target.channel_id` is the addressing target (the originating + // conversation). `origin.recipient` is intentionally absent from the + // target and is never passed as an authz input. + match self + .send_channel_message( + &target.channel_type, + &target.channel_id, + &message, + target.thread_id.as_deref(), + None, + ) + .await + { + Ok(_) => { + tracing::debug!(request_id = %id, "approval prompt pushed to origin") + } + Err(e) => tracing::warn!( + request_id = %id, + error = %e, + "approval surfacer: push failed; text /approve path stands" + ), + } + } +} + +#[cfg(test)] +mod approval_surface_tests { + use super::approval_push_target; + use openfang_types::approval::{ApprovalOrigin, ApprovalRequest, RiskLevel}; + + fn req_with(origin: Option) -> ApprovalRequest { + ApprovalRequest { + id: uuid::Uuid::new_v4(), + agent_id: "agent-x".to_string(), + tool_name: "shell_exec".to_string(), + description: "run a command".to_string(), + action_summary: "rm -rf /tmp/x".to_string(), + risk_level: RiskLevel::High, + requested_at: chrono::Utc::now(), + timeout_secs: 300, + origin, + } + } + + #[test] + fn no_target_when_origin_absent() { + assert_eq!(approval_push_target(&req_with(None)), None); + } + + #[test] + fn no_target_when_channel_type_empty() { + let o = ApprovalOrigin { + channel_type: String::new(), + channel_id: Some("C123".to_string()), + thread_id: None, + recipient: Some("U999".to_string()), + }; + assert_eq!(approval_push_target(&req_with(Some(o))), None); + } + + #[test] + fn no_target_when_channel_id_missing_or_empty() { + let missing = ApprovalOrigin { + channel_type: "discord".to_string(), + channel_id: None, + thread_id: None, + recipient: Some("U999".to_string()), + }; + assert_eq!(approval_push_target(&req_with(Some(missing))), None); + + let empty = ApprovalOrigin { + channel_type: "discord".to_string(), + channel_id: Some(String::new()), + thread_id: None, + recipient: Some("U999".to_string()), + }; + assert_eq!(approval_push_target(&req_with(Some(empty))), None); + } + + #[test] + fn target_carries_conversation_and_thread_but_not_recipient() { + let o = ApprovalOrigin { + channel_type: "discord".to_string(), + channel_id: Some("C123".to_string()), + thread_id: Some("T456".to_string()), + recipient: Some("U999".to_string()), + }; + let target = approval_push_target(&req_with(Some(o))).expect("resolvable"); + assert_eq!(target.channel_type, "discord"); + assert_eq!(target.channel_id, "C123"); + assert_eq!(target.thread_id.as_deref(), Some("T456")); + // recipient (peer_id) must never be carried into the push target. + // (Structurally enforced: ApprovalPushTarget has no recipient field.) + } +} + #[async_trait] impl KernelHandle for OpenFangKernel { + fn token_issuer(&self) -> Option> { + OpenFangKernel::token_issuer(self) + } + async fn spawn_agent( &self, manifest_toml: &str, @@ -7578,6 +7866,7 @@ impl KernelHandle for OpenFangKernel { agent_id: &str, tool_name: &str, action_summary: &str, + origin: Option<&openfang_types::approval::ApprovalOrigin>, ) -> Result { use openfang_types::approval::{ApprovalDecision, ApprovalRequest as TypedRequest}; @@ -7602,6 +7891,7 @@ impl KernelHandle for OpenFangKernel { risk_level: crate::approval::ApprovalManager::classify_risk(tool_name), requested_at: chrono::Utc::now(), timeout_secs: policy.timeout_secs, + origin: origin.cloned(), }; let decision = self.approval_manager.request_approval(req).await; @@ -7657,6 +7947,7 @@ impl KernelHandle for OpenFangKernel { recipient: &str, message: &str, thread_id: Option<&str>, + workspace_root: Option<&std::path::Path>, ) -> Result { let adapter = self .channel_adapters @@ -7674,40 +7965,57 @@ impl KernelHandle for OpenFangKernel { })? .clone(); - let user = openfang_channels::types::ChannelUser { - platform_id: recipient.to_string(), - display_name: recipient.to_string(), - openfang_user: None, - }; + // ANAI-55: turn the free-form recipient string into a platform-native + // ChannelUser via the adapter's resolver. Discord overrides this to + // accept snowflakes, `<#…>` / `<@…>` mentions, `#channel-name` (when + // unambiguous across guilds), and `@username`. Bare names are refused. + // Every other adapter still uses the trait's default passthrough. + let user = adapter + .resolve_recipient(recipient) + .await + .map_err(|e| crate::tool_error::ToolError::RecipientUnresolved(e).to_string())?; - let formatted = if channel == "wecom" { - let output_format = self - .config + // Pick the same default OutputFormat the bridge reply-path uses for + // this channel, with the wecom-specific config override applied. + let output_format = if channel == "wecom" { + self.config .channels .wecom .as_ref() .and_then(|c| c.overrides.output_format) - .unwrap_or(OutputFormat::PlainText); - openfang_channels::formatter::format_for_wecom(message, output_format) + .unwrap_or(OutputFormat::PlainText) } else { - message.to_string() + openfang_channels::bridge::default_output_format_for_channel(channel) }; - let content = openfang_channels::types::ChannelContent::Text(formatted); + // Delegate to the shared parse+format+dispatch helper so that + // `` markers in proactive sends are handled + // identically to bridge reply-path responses. The workspace_root + // (when supplied by the caller — typically the channel_send tool) + // scopes outbound attachments to the calling agent's workspace. + let skipped = openfang_channels::bridge::send_parsed( + adapter.as_ref(), + &user, + message.to_string(), + thread_id, + output_format, + openfang_channels::bridge::SendOptions { + workspace_root: workspace_root.map(|p| p.to_path_buf()), + }, + ) + .await; - if let Some(tid) = thread_id { - adapter - .send_in_thread(&user, content, tid) - .await - .map_err(|e| format!("Channel send failed: {e}"))?; - } else { - adapter - .send(&user, content) - .await - .map_err(|e| format!("Channel send failed: {e}"))?; + let mut out = format!("Message sent to {} via {}", recipient, channel); + if !skipped.is_empty() { + // Surface dropped attachments so the agent can react. The + // message body still sent; the underlying WARN log in + // outbound_attach::parse remains the operator-facing record. + out.push_str("\n\nSkipped attachments:"); + for (path, reason) in &skipped { + out.push_str(&format!("\n- {}: {}", path, reason)); + } } - - Ok(format!("Message sent to {} via {}", recipient, channel)) + Ok(out) } async fn send_channel_media( @@ -7736,11 +8044,11 @@ impl KernelHandle for OpenFangKernel { })? .clone(); - let user = openfang_channels::types::ChannelUser { - platform_id: recipient.to_string(), - display_name: recipient.to_string(), - openfang_user: None, - }; + // ANAI-55 — see send_channel_message for rationale. + let user = adapter + .resolve_recipient(recipient) + .await + .map_err(|e| crate::tool_error::ToolError::RecipientUnresolved(e).to_string())?; let content = match media_type { "image" => openfang_channels::types::ChannelContent::Image { @@ -7803,11 +8111,11 @@ impl KernelHandle for OpenFangKernel { })? .clone(); - let user = openfang_channels::types::ChannelUser { - platform_id: recipient.to_string(), - display_name: recipient.to_string(), - openfang_user: None, - }; + // ANAI-55 — see send_channel_message for rationale. + let user = adapter + .resolve_recipient(recipient) + .await + .map_err(|e| crate::tool_error::ToolError::RecipientUnresolved(e).to_string())?; let content = openfang_channels::types::ChannelContent::FileData { data, @@ -7942,6 +8250,7 @@ mod tests { #[test] fn test_manifest_to_capabilities() { let mut manifest = AgentManifest { + file_policy: None, name: "test".to_string(), version: "0.1.0".to_string(), description: "test".to_string(), @@ -7986,6 +8295,7 @@ mod tests { #[test] fn test_merge_preserves_workspace_when_disk_omits_it() { let entry = AgentManifest { + file_policy: None, name: "demo".to_string(), version: "0.1.0".to_string(), description: "old".to_string(), @@ -8037,6 +8347,7 @@ mod tests { #[test] fn test_merge_respects_explicit_disk_workspace() { let entry = AgentManifest { + file_policy: None, name: "demo".to_string(), version: "0.1.0".to_string(), description: "x".to_string(), @@ -8094,6 +8405,7 @@ mod tests { ..Default::default() }; let mut restored_manifest = AgentManifest { + file_policy: None, name: "demo".to_string(), version: "0.1.0".to_string(), description: "x".to_string(), @@ -8208,6 +8520,7 @@ mod tests { fn test_manifest(name: &str, description: &str, tags: Vec) -> AgentManifest { AgentManifest { + file_policy: None, name: name.to_string(), version: "0.1.0".to_string(), description: description.to_string(), @@ -9412,4 +9725,84 @@ 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(); + } } diff --git a/crates/openfang-kernel/src/lib.rs b/crates/openfang-kernel/src/lib.rs index 289e183ad2..ebf6bcf4d1 100644 --- a/crates/openfang-kernel/src/lib.rs +++ b/crates/openfang-kernel/src/lib.rs @@ -21,6 +21,7 @@ pub mod pairing; pub mod registry; pub mod scheduler; pub mod supervisor; +pub mod tool_error; pub mod triggers; pub mod whatsapp_gateway; pub mod wizard; diff --git a/crates/openfang-kernel/src/registry.rs b/crates/openfang-kernel/src/registry.rs index 124bbfe728..d448bb10be 100644 --- a/crates/openfang-kernel/src/registry.rs +++ b/crates/openfang-kernel/src/registry.rs @@ -387,6 +387,7 @@ mod tests { id: AgentId::new(), name: name.to_string(), manifest: AgentManifest { + file_policy: None, name: name.to_string(), version: "0.1.0".to_string(), description: "test".to_string(), diff --git a/crates/openfang-kernel/src/tool_error.rs b/crates/openfang-kernel/src/tool_error.rs new file mode 100644 index 0000000000..fe9e07999d --- /dev/null +++ b/crates/openfang-kernel/src/tool_error.rs @@ -0,0 +1,103 @@ +//! Typed errors for kernel-side tool implementations (ANAI-55 follow-up). +//! +//! Today the `KernelHandle` trait returns `Result` for the +//! channel-send tool surface, which means every error is a free-form string +//! and every caller that wants to discriminate must do prefix-matching. That +//! is the contract this enum is on the path to replacing. +//! +//! Scope as of ANAI-55: only the recipient-resolution failure mode is +//! modeled, because that is the one the resolver produces and the runner +//! benefits most from being able to identify (e.g., to surface a structured +//! tool-call error to the agent rather than a stringly-typed one when the +//! trait boundary migrates). +//! +//! The trait boundary stays `Result<_, String>` for now; converting that is a +//! ripple through `KernelHandle`, the API bridge, the IPC bridge, and the +//! cron-delivery test doubles. This file is the foothold for that migration. +//! +//! Display impls are stable: the prefix tokens (e.g. `RECIPIENT_UNRESOLVED:`) +//! are part of the contract for any caller that round-trips through +//! `to_string()` and wants to recover the variant. + +use openfang_channels::types::ResolutionError; +use std::fmt; + +/// Errors a kernel tool implementation can surface to the runtime layer. +#[derive(Debug)] +pub enum ToolError { + /// The channel adapter could not resolve the recipient string to a + /// platform-native identity (ANAI-55). Carries the underlying + /// `ResolutionError` so the runtime can choose its surface form. + RecipientUnresolved(ResolutionError), + + /// Catch-all for anything not yet modeled. New variants should be + /// introduced as call sites need to discriminate them. + Other(String), +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ToolError::RecipientUnresolved(e) => { + // Stable prefix — see module docs. + write!(f, "RECIPIENT_UNRESOLVED: {e}") + } + ToolError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for ToolError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ToolError::RecipientUnresolved(e) => Some(e), + ToolError::Other(_) => None, + } + } +} + +impl From for ToolError { + fn from(e: ResolutionError) -> Self { + ToolError::RecipientUnresolved(e) + } +} + +impl From for ToolError { + fn from(s: String) -> Self { + ToolError::Other(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recipient_unresolved_display_has_stable_prefix() { + let inner = ResolutionError::UnknownRecipient { + recipient: "ghost".to_string(), + }; + let err = ToolError::RecipientUnresolved(inner); + let s = err.to_string(); + assert!( + s.starts_with("RECIPIENT_UNRESOLVED: "), + "display lost stable prefix: {s}" + ); + } + + #[test] + fn from_resolution_error_yields_recipient_unresolved() { + let inner = ResolutionError::BareNameDmRefused { + name: "benhoverter".to_string(), + }; + let err: ToolError = inner.into(); + assert!(matches!(err, ToolError::RecipientUnresolved(_))); + } + + #[test] + fn from_string_yields_other() { + let err: ToolError = "boom".to_string().into(); + assert!(matches!(err, ToolError::Other(ref s) if s == "boom")); + assert_eq!(err.to_string(), "boom"); + } +} diff --git a/crates/openfang-kernel/src/wizard.rs b/crates/openfang-kernel/src/wizard.rs index 4338830af2..5b30a271df 100644 --- a/crates/openfang-kernel/src/wizard.rs +++ b/crates/openfang-kernel/src/wizard.rs @@ -181,6 +181,7 @@ impl SetupWizard { profile: None, fallback_models: vec![], exec_policy: None, + file_policy: None, tool_allowlist: vec![], tool_blocklist: vec![], cache_context: false, diff --git a/crates/openfang-mcp-bridge/Cargo.toml b/crates/openfang-mcp-bridge/Cargo.toml new file mode 100644 index 0000000000..d7a0b38c0f --- /dev/null +++ b/crates/openfang-mcp-bridge/Cargo.toml @@ -0,0 +1,46 @@ +[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 } +tempfile = { 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 new file mode 100644 index 0000000000..e0092dc207 --- /dev/null +++ b/crates/openfang-mcp-bridge/src/lib.rs @@ -0,0 +1,781 @@ +//! # 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 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 +//! +//! 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. +//! +//! 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; + +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 +/// 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. +/// +/// **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. + /// + /// For ANAI-30 this is the static four-tool slice; ANAI-31+ will derive + /// 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. + async fn call( + &self, + tool_name: &str, + args: serde_json::Value, + ) -> 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. +#[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), +} + +/// 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. +/// +/// 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. +/// +/// 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 +/// 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] = &[ + "file_read", + "file_list", + "file_write", + "create_directory", + "web_fetch", + "agent_list", + "channel_send", + "agent_send", + "memory_store", + "memory_recall", + "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; + + 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"] + })), + ), + // 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` → `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. + 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.", + 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. Use \ + `attachments` (array of file paths, workspace-relative preferred) to \ + attach files alongside the message; paths resolve against the agent \ + workspace and the same allow-root rules apply as for inline \ + `` directives.", + 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" }, + "attachments": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional file paths to attach. Workspace-relative paths are preferred (resolved against the agent's workspace root); absolute paths are also accepted. Same security gating as inline `` directives." + }, + "thread_id": { "type": "string", "description": "Thread/topic ID to reply in" } + }, + "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"] + })), + ), + // 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` → `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( + "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"] + })), + ), + // 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` → `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`. + 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"] + })), + ), + ] +} + +/// The MCP server handler — wraps a [`ToolDispatcher`] and serves the +/// four-tool ANAI-30 surface over MCP. +/// +/// 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 { + dispatcher: Arc, +} + +impl Bridge { + pub fn new(dispatcher: Arc) -> Self { + Self { dispatcher } + } + + /// Tools the bridge will both advertise and accept calls for, given the + /// dispatcher's allowed set, plus any daemon-forwarded upstream MCP tools. + fn permitted_tools(&self) -> Vec { + let allowed = self.dispatcher.allowed_tools(); + let mut tools: Vec = built_in_tools() + .into_iter() + .filter(|t| allowed.iter().any(|a| a.as_str() == t.name.as_ref())) + .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 { + 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. Exposes OpenFang's tool surface to MCP clients, \ + scoped to a single parent agent's identity and capabilities. \ + Per-agent gating via OPENFANG_BRIDGE_ALLOWED narrows the advertised \ + set to the calling agent's agent.toml capabilities." + .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 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(); + 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" + ))])); + } + + 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, + 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 { + &self.agent + } + fn allowed_tools(&self) -> Vec { + self.allowed.clone() + } + fn upstream_tools(&self) -> Vec { + self.upstream.clone() + } + async fn call( + &self, + _tool_name: &str, + _args: serde_json::Value, + ) -> Result { + Ok(self.canned.clone()) + } + } + + #[test] + 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", + "file_write", + "create_directory", + "web_fetch", + "agent_list", + "channel_send", + "agent_send", + "agent_spawn", + "agent_kill", + "agent_activate", + "memory_store", + "memory_recall", + "agent_find", + "shell_exec", + "apply_patch", + "web_search", + ], + "surface drift — update both this test and the runtime tool_runner \ + schema when adding or removing built-in bridge tools" + ); + } + + #[test] + fn permitted_tools_intersects_with_dispatcher_allowed() { + 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. + vec!["file_read".into(), "not_a_real_tool".into()], + 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()]); + } + + #[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 new file mode 100644 index 0000000000..e315c96f6d --- /dev/null +++ b/crates/openfang-mcp-bridge/src/main.rs @@ -0,0 +1,740 @@ +//! Stdio entrypoint for the OpenFang MCP bridge. +//! +//! ## Topology (ANAI-30 step 3) +//! +//! ```text +//! 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. + +// 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::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] +use std::sync::Arc; + +#[cfg(unix)] +use anyhow::{anyhow, bail, Context, Result}; +#[cfg(unix)] +use openfang_mcp_bridge::{ + protocol::{ + codec, CallRequest, CallResult, Frame, Hello, HelloAck, ListUpstreamRequest, + UpstreamListResult, UpstreamToolDef, PROTOCOL_VERSION, SOCKET_ENV_VAR, TOKEN_ENV_VAR, + }, + Bridge, DispatchOk, ToolDispatchError, ToolDispatcher, DEFAULT_ALLOWED, +}; +#[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 +/// 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"; + +#[cfg(unix)] +#[tokio::main] +async fn main() -> Result<()> { + // Tracing → 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(); + + 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" + ); + + // --- Connect + handshake --- + let mut stream = UnixStream::connect(&socket_path) + .await + .with_context(|| format!("connect to daemon socket {socket_path}"))?; + + 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(), + upstream_tools, + ); + + // --- 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"))?; + + service.waiting().await?; + Ok(()) +} + +/// 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); + + 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:?}"), + } +} + +/// 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); + + 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)] +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, +} + +/// 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, + /// 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, +} + +#[cfg(unix)] +#[async_trait::async_trait] +impl ToolDispatcher for IpcDispatcher { + fn agent_id(&self) -> &str { + &self.agent_id + } + + fn allowed_tools(&self) -> Vec { + self.allowed.clone() + } + + fn upstream_tools(&self) -> Vec { + self.upstream.clone() + } + + async fn call( + &self, + tool_name: &str, + args: serde_json::Value, + ) -> Result { + // 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())); + } + + 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. +#[cfg(unix)] +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())); + + 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, + upstream, + tx, + next_id: AtomicU64::new(1), + } +} + +/// 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}; + 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()], + Vec::new(), + ); + + // 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; + } + + /// 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; + } +} diff --git a/crates/openfang-mcp-bridge/src/protocol.rs b/crates/openfang-mcp-bridge/src/protocol.rs new file mode 100644 index 0000000000..0679f41935 --- /dev/null +++ b/crates/openfang-mcp-bridge/src/protocol.rs @@ -0,0 +1,407 @@ +//! 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 }, +} + +/// 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 +/// 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), + /// 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")] +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")); + } + + #[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"); + } +} diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 615fbfdb73..373e00d5a5 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; @@ -312,6 +313,7 @@ pub async fn run_agent_loop( context_window_tokens: Option, process_manager: Option<&crate::process_manager::ProcessManager>, user_content_blocks: Option>, + origin: Option<&openfang_types::approval::ApprovalOrigin>, ) -> OpenFangResult { info!(agent = %manifest.name, "Starting agent loop"); @@ -535,6 +537,15 @@ 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()), + // 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 @@ -556,6 +567,7 @@ pub async fn run_agent_loop( Some(provider_name), None, &manifest.fallback_models, + kernel.as_ref().and_then(|k| k.token_issuer()), ) .await?; @@ -922,6 +934,7 @@ pub async fn run_agent_loop( // Resolve effective exec policy (per-agent override or global) let effective_exec_policy = manifest.exec_policy.as_ref(); + let effective_file_policy = manifest.file_policy.as_ref(); // Timeout-wrapped execution. `tool_timeout_for` returns None // when the operator disabled the timeout (issue #1125). @@ -945,9 +958,11 @@ pub async fn run_agent_loop( workspace_root, media_engine, effective_exec_policy, + effective_file_policy, tts_engine, docker_config, process_manager, + origin, ); let result = match timeout_opt { Some(timeout) => { @@ -1158,6 +1173,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) { @@ -1268,19 +1284,25 @@ async fn call_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - let fb_driver = match crate::drivers::create_driver(&fb_config) { - 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; - } - }; + // 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!( + 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!( @@ -1343,6 +1365,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) { @@ -1452,19 +1475,25 @@ async fn stream_with_retry( skip_permissions: true, subprocess_timeout_secs: None, }; - let fb_driver = match crate::drivers::create_driver(&fb_config) { - 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; - } - }; + // 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!( + 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!( @@ -1540,6 +1569,7 @@ pub async fn run_agent_loop_streaming( context_window_tokens: Option, process_manager: Option<&crate::process_manager::ProcessManager>, user_content_blocks: Option>, + origin: Option<&openfang_types::approval::ApprovalOrigin>, ) -> OpenFangResult { info!(agent = %manifest.name, "Starting streaming agent loop"); @@ -1772,6 +1802,15 @@ 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()), + // 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 @@ -1800,6 +1839,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?; @@ -2131,6 +2171,7 @@ pub async fn run_agent_loop_streaming( // Resolve effective exec policy (per-agent override or global) let effective_exec_policy = manifest.exec_policy.as_ref(); + let effective_file_policy = manifest.file_policy.as_ref(); // Timeout-wrapped execution. `tool_timeout_for` returns None // when the operator disabled the timeout (issue #1125). @@ -2154,9 +2195,11 @@ pub async fn run_agent_loop_streaming( workspace_root, media_engine, effective_exec_policy, + effective_file_policy, tts_engine, docker_config, process_manager, + origin, ); let result = match timeout_opt { Some(timeout) => { @@ -3602,6 +3645,7 @@ mod tests { ContentBlock::Image { media_type: "image/png".to_string(), data: "aGVsbG8=".to_string(), + source_url: None, } } @@ -3821,6 +3865,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should complete without error"); @@ -3874,6 +3919,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should complete without error"); @@ -3929,6 +3975,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should complete without error"); @@ -3982,6 +4029,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should complete without error"); @@ -4028,6 +4076,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Streaming loop should complete without error"); @@ -4152,6 +4201,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should recover via retry"); @@ -4199,6 +4249,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Loop should complete with fallback"); @@ -4254,6 +4305,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Streaming loop should complete without error"); @@ -5230,6 +5282,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Agent loop should complete"); @@ -5300,6 +5353,7 @@ mod tests { None, None, None, + None, // origin ) .await .expect("Agent loop should recover nested XML tool calls"); @@ -5372,6 +5426,7 @@ mod tests { None, None, None, // user_content_blocks + None, // origin ) .await .expect("Normal loop should complete"); @@ -5435,6 +5490,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // origin ) .await .expect("Streaming loop should complete"); 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..3afa90ed9a --- /dev/null +++ b/crates/openfang-runtime/src/agent_tool_context.rs @@ -0,0 +1,195 @@ +//! 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 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); + 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/apply_patch.rs b/crates/openfang-runtime/src/apply_patch.rs index 4ea734e931..3d032e928c 100644 --- a/crates/openfang-runtime/src/apply_patch.rs +++ b/crates/openfang-runtime/src/apply_patch.rs @@ -271,41 +271,90 @@ pub fn parse_patch(input: &str) -> Result, String> { Ok(ops) } -/// Resolve a patch path through workspace confinement. -fn resolve_patch_path(raw: &str, workspace_root: &Path) -> Result { - crate::workspace_sandbox::resolve_sandbox_path(raw, workspace_root) +/// Resolve a patch path through workspace confinement and `file_policy`. +/// Patch targets are always writes; prompt-tier is fail-closed here because +/// apply_patch is multi-path and per-target interactive approval is out of +/// scope for v1. +fn resolve_patch_path( + raw: &str, + workspace_root: &Path, + file_policy: Option<&openfang_types::config::FilePolicy>, +) -> Result { + crate::workspace_sandbox::resolve_with_policy(raw, workspace_root, file_policy, true, false) +} + +/// Raw target path(s) an op will touch — for the pre-application policy pass. +fn op_targets(op: &PatchOp) -> Vec<&str> { + match op { + PatchOp::AddFile { path, .. } | PatchOp::DeleteFile { path } => vec![path.as_str()], + PatchOp::UpdateFile { path, move_to, .. } => { + let mut v = vec![path.as_str()]; + if let Some(m) = move_to { + v.push(m.as_str()); + } + v + } + } } /// Apply parsed patch operations against the filesystem. /// -/// All file paths are confined to `workspace_root` via sandbox resolution. -pub async fn apply_patch(ops: &[PatchOp], workspace_root: &Path) -> PatchResult { +/// All file paths are confined to `workspace_root` via sandbox resolution and +/// governed by `file_policy` when active. +/// +/// F3: a policy pre-pass validates *every* target against workspace +/// confinement and `file_policy` before any write, so a denied target later in +/// the patch cannot leave earlier targets partially written. This is a policy +/// pre-check, not filesystem-transactional rollback — a mid-apply I/O failure +/// can still leave partial writes (std offers no clean rollback) — but no write +/// occurs past a policy-*denied* target. +pub async fn apply_patch( + ops: &[PatchOp], + workspace_root: &Path, + file_policy: Option<&openfang_types::config::FilePolicy>, +) -> PatchResult { let mut result = PatchResult::default(); + // F3: policy pre-pass — reject the whole patch if any target is denied. + let mut precheck_errors = Vec::new(); + for op in ops { + for raw in op_targets(op) { + if let Err(e) = resolve_patch_path(raw, workspace_root, file_policy) { + precheck_errors.push(format!("{}: {}", raw, e)); + } + } + } + if !precheck_errors.is_empty() { + result.errors = precheck_errors; + return result; + } + for op in ops { match op { - PatchOp::AddFile { path, content } => match resolve_patch_path(path, workspace_root) { - Ok(resolved) => { - if let Some(parent) = resolved.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - result.errors.push(format!("mkdir {}: {}", path, e)); - continue; + PatchOp::AddFile { path, content } => { + match resolve_patch_path(path, workspace_root, file_policy) { + Ok(resolved) => { + if let Some(parent) = resolved.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + result.errors.push(format!("mkdir {}: {}", path, e)); + continue; + } + } + match tokio::fs::write(&resolved, content).await { + Ok(()) => result.files_added += 1, + Err(e) => result.errors.push(format!("write {}: {}", path, e)), } } - match tokio::fs::write(&resolved, content).await { - Ok(()) => result.files_added += 1, - Err(e) => result.errors.push(format!("write {}: {}", path, e)), - } + Err(e) => result.errors.push(format!("{}: {}", path, e)), } - Err(e) => result.errors.push(format!("{}: {}", path, e)), - }, + } PatchOp::UpdateFile { path, move_to, hunks, } => { - let resolved = match resolve_patch_path(path, workspace_root) { + let resolved = match resolve_patch_path(path, workspace_root, file_policy) { Ok(r) => r, Err(e) => { result.errors.push(format!("{}: {}", path, e)); @@ -327,7 +376,7 @@ pub async fn apply_patch(ops: &[PatchOp], workspace_root: &Path) -> PatchResult Ok(patched) => { // Determine target path (move or in-place) let target = if let Some(new_path) = move_to { - match resolve_patch_path(new_path, workspace_root) { + match resolve_patch_path(new_path, workspace_root, file_policy) { Ok(t) => { result.files_moved += 1; t @@ -364,15 +413,17 @@ pub async fn apply_patch(ops: &[PatchOp], workspace_root: &Path) -> PatchResult } } - PatchOp::DeleteFile { path } => match resolve_patch_path(path, workspace_root) { - Ok(resolved) => match tokio::fs::remove_file(&resolved).await { - Ok(()) => result.files_deleted += 1, - Err(e) => { - result.errors.push(format!("delete {}: {}", path, e)); - } - }, - Err(e) => result.errors.push(format!("{}: {}", path, e)), - }, + PatchOp::DeleteFile { path } => { + match resolve_patch_path(path, workspace_root, file_policy) { + Ok(resolved) => match tokio::fs::remove_file(&resolved).await { + Ok(()) => result.files_deleted += 1, + Err(e) => { + result.errors.push(format!("delete {}: {}", path, e)); + } + }, + Err(e) => result.errors.push(format!("{}: {}", path, e)), + } + } } } @@ -736,7 +787,7 @@ mod tests { }, ]; - let result = apply_patch(&ops, &dir).await; + let result = apply_patch(&ops, &dir, None).await; assert!(result.is_ok()); assert_eq!(result.files_added, 1); assert_eq!(result.files_updated, 1); @@ -770,11 +821,57 @@ mod tests { path: "doomed.txt".to_string(), }]; - let result = apply_patch(&ops, &dir).await; + let result = apply_patch(&ops, &dir, None).await; assert!(result.is_ok()); assert_eq!(result.files_deleted, 1); assert!(!dir.join("doomed.txt").exists()); let _ = tokio::fs::remove_dir_all(&dir).await; } + + #[tokio::test] + async fn test_apply_patch_f3_denied_target_blocks_all_writes() { + use openfang_types::config::{FileAccessTier, FilePolicy, FileRule}; + let dir = std::env::temp_dir().join("openfang_patch_f3_test"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("secret")).await.unwrap(); + + // Workspace writable, but a `secret/` subtree is denied. + let policy = FilePolicy::new( + true, + FileAccessTier::Write, + vec![FileRule { + path: "secret".to_string(), + tier: FileAccessTier::Deny, + }], + ); + + // First op is allowed; second targets the denied subtree. + let ops = vec![ + PatchOp::AddFile { + path: "allowed.txt".to_string(), + content: "hi".to_string(), + }, + PatchOp::AddFile { + path: "secret/leak.txt".to_string(), + content: "x".to_string(), + }, + ]; + + let result = apply_patch(&ops, &dir, Some(&policy)).await; + assert!( + !result.is_ok(), + "patch must be rejected when any target is policy-denied" + ); + assert_eq!( + result.files_added, 0, + "no writes past a policy-denied target (pre-pass)" + ); + assert!( + !dir.join("allowed.txt").exists(), + "the allowed target must not be written when the patch is rejected" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } } 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/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 3602ff244a..e76d17adbd 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -400,8 +400,30 @@ 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 { .. } => {} @@ -469,6 +491,8 @@ async fn summarize_messages( .to_string(), ), thinking: None, + caller_agent_id: None, + allowed_tools: None, }; // Retry logic for transient failures @@ -588,6 +612,8 @@ async fn summarize_in_chunks( .to_string(), ), thinking: None, + caller_agent_id: None, + allowed_tools: None, }; match driver.complete(merge_request).await { @@ -1273,6 +1299,7 @@ mod tests { content: MessageContent::Blocks(vec![ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }]), ..Default::default() }, @@ -1286,6 +1313,76 @@ 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()), + }]), + ..Default::default() + }]; + 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()), + }]), + ..Default::default() + }]; + 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()), + }]), + ..Default::default() + }]; + 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 { diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index 10a606e6a7..042db1ff04 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -695,7 +695,9 @@ 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 897d12ed4b..11eada3a7b 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -8,15 +8,188 @@ //! 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::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 dashmap::DashMap; +use openfang_types::agent::AgentId; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::str::FromStr; 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"; +/// 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 +/// 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, + } +} + +/// 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"; + +/// 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`. +/// - `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. +/// - `Skill` — the same skill-invocation substrate as `SlashCommand`, +/// reached by name rather than slash. Reconciled with it: the native CC +/// skills behind it (`schedule`, `loop`, `update-config`, `run`) are +/// OpenFang-First violations; OF's `skill_execute` is the canonical path. +/// - `Task` — CC's subagent spawner, a parallel orchestration plane. +/// OpenFang owns orchestration via `agent_spawn`/`agent_send`; denied so +/// a subprocess can't fork an unobserved CC subagent tree. +/// - `AskUserQuestion` — interactive multiple-choice picker. Inert by +/// construction under OF's launch mode (`-p`, stdin nulled, no TTY): it +/// can never reach the user and dead-ends silently. Denied so CC returns +/// an explicit deny `tool_result` instead of a silent black hole the +/// model may confabulate around. +/// - `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`, `EnterPlanMode`/`ExitPlanMode`, +/// `TaskOutput`/`TaskStop`, `ToolSearch` — agent-internal control flow / +/// bookkeeping the agent loop depends on, 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", + "NotebookRead", + "Glob", + "Grep", + // Worktree (FS escape) + "EnterWorktree", + "ExitWorktree", + // Network + "WebFetch", + "WebSearch", + // Skill / command substrate (OpenFang-First: OF skill curation is canonical) + "SlashCommand", + "Skill", + // Subagent orchestration (OpenFang-First: OF owns agent_spawn/agent_send) + "Task", + // Headless-inert interactive picker (no TTY under -p / stdin-null) + "AskUserQuestion", + // Scheduling / remote control plane (OpenFang-First) + "CronCreate", + "CronDelete", + "CronList", + "ScheduleWakeup", + "RemoteTrigger", + // Comms routing + "PushNotification", +]; + /// 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 @@ -52,6 +225,11 @@ const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; /// Default subprocess timeout in seconds (5 minutes). const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; +// 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 { cli_path: String, @@ -61,6 +239,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 { @@ -78,6 +271,9 @@ impl ClaudeCodeDriver { ); } + // Best-effort sweep of stale image tmpfiles, once per process. + spawn_sweep_once(); + Self { cli_path: cli_path .filter(|s| !s.is_empty()) @@ -85,9 +281,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, @@ -139,6 +346,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 { @@ -147,7 +355,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}")); } @@ -158,12 +366,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 @@ -176,11 +389,40 @@ 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 { + // 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() + )); + } + } + // 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 { .. } @@ -194,6 +436,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); @@ -214,6 +458,17 @@ 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); + 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_") { @@ -230,6 +485,317 @@ 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, + /// 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 { + 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, + allowed_tools: Option<&[String]>, +) -> 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()), + ); + // 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( + "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) +} + +/// 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() +} + +/// 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 + /// 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>, + 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. + 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), + }; + + 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. + 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_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`. /// /// The CLI may return the response text in different fields depending on @@ -326,6 +892,50 @@ 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 = self + .try_build_bridge_mcp_config( + request.caller_agent_id.as_deref(), + request.allowed_tools.as_deref(), + ) + .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 + // the OpenFang bridge for this invocation, nothing else. + cmd.arg("--strict-mcp-config"); + // 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"); + } + }); + 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()).inspect(|s| { + 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); // Inject HOME so the CLI can find its credentials (~/.claude/) when @@ -338,7 +948,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, 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| { @@ -455,6 +1065,27 @@ impl LlmDriver for ClaudeCodeDriver { info!(model = %pid_label, "Claude Code CLI subprocess completed successfully"); + // 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. 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() + .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 @@ -522,6 +1153,36 @@ 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 = self + .try_build_bridge_mcp_config( + request.caller_agent_id.as_deref(), + request.allowed_tools.as_deref(), + ) + .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"); + } + }); + 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()).inspect(|s| { + 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); // Same HOME and stdin hygiene as the non-streaming path. @@ -532,7 +1193,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, native_deny_wired, "Spawning Claude Code CLI (streaming)"); let mut child = cmd.spawn().map_err(|e| { LlmError::Http(format!( @@ -554,6 +1215,19 @@ impl LlmDriver for ClaudeCodeDriver { LlmError::Http("No stdout from claude CLI".to_string()) })?; + // 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(); + 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(); @@ -662,16 +1336,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, @@ -691,6 +1361,23 @@ impl LlmDriver for ClaudeCodeDriver { }); } + // Optional diagnostic: log CC stderr tail when bridge debug is on. + if bridge_debug { + 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, @@ -710,6 +1397,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() @@ -764,6 +1497,8 @@ mod tests { temperature: 0.7, system: Some("You are helpful.".to_string()), thinking: None, + caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -791,6 +1526,7 @@ mod tests { ContentBlock::Image { media_type: "image/png".to_string(), data: fake_b64, + source_url: None, }, ]), ..Default::default() @@ -800,6 +1536,8 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -808,9 +1546,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}" ); } @@ -825,6 +1567,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() }], @@ -833,6 +1576,8 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, }; let prompt = ClaudeCodeDriver::build_prompt(&request); @@ -902,6 +1647,377 @@ 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 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"); + 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"); + 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 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 + | BRIDGE_ALLOWED_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", + None, + ); + + // 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 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()) + .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!( + 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] + 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(), + _guard: None, + }; + assert!(path.exists(), "file present while guard held"); + } + + 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", + "EnterPlanMode", + "ExitPlanMode", + "TaskOutput", + "TaskStop", + "ToolSearch", + ] { + assert!( + !CC_NATIVE_DENY.contains(&must_allow), + "{must_allow} must NOT be denied (agent-internal control flow)" + ); + } + } + + #[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)" + ); + + // Skill + Task — parallel control-plane substrates. Reconciled + // with SlashCommand (same skill room) and OF orchestration. + assert!( + CC_NATIVE_DENY.contains(&"Skill"), + "Skill must be denied (parallel skill substrate; reconciles with SlashCommand)" + ); + assert!( + CC_NATIVE_DENY.contains(&"Task"), + "Task must be denied (OpenFang-First: OF owns subagent orchestration)" + ); + + // AskUserQuestion — headless-inert interactive picker. Deny + // replaces a silent dead-end with an explicit deny tool_result. + assert!( + CC_NATIVE_DENY.contains(&"AskUserQuestion"), + "AskUserQuestion must be denied (headless-inert; no TTY under -p/stdin-null)" + ); + + // 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 + // 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 + // 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 driver = ClaudeCodeDriver::new(None, true); + let cfg = driver.try_build_bridge_mcp_config(Some("agent-x"), None); + 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 diff --git a/crates/openfang-runtime/src/drivers/fallback.rs b/crates/openfang-runtime/src/drivers/fallback.rs index 63958ce375..74d140579d 100644 --- a/crates/openfang-runtime/src/drivers/fallback.rs +++ b/crates/openfang-runtime/src/drivers/fallback.rs @@ -161,6 +161,8 @@ mod tests { temperature: 0.0, 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 aee4e30c78..caa8c8a709 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -298,7 +298,9 @@ 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(), @@ -1340,6 +1342,8 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, }; let tools = convert_tools(&request); @@ -1358,6 +1362,8 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, }; let tools = convert_tools(&request); 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"); } } diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 73210f2757..1655c352dc 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -525,7 +525,9 @@ 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/qwen_code.rs b/crates/openfang-runtime/src/drivers/qwen_code.rs index c68329e734..276b20a55c 100644 --- a/crates/openfang-runtime/src/drivers/qwen_code.rs +++ b/crates/openfang-runtime/src/drivers/qwen_code.rs @@ -464,6 +464,8 @@ mod tests { temperature: 0.7, 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/drivers/vertex.rs b/crates/openfang-runtime/src/drivers/vertex.rs index 9f04841634..7cb042aafb 100644 --- a/crates/openfang-runtime/src/drivers/vertex.rs +++ b/crates/openfang-runtime/src/drivers/vertex.rs @@ -356,7 +356,9 @@ 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-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs new file mode 100644 index 0000000000..2a2a183c34 --- /dev/null +++ b/crates/openfang-runtime/src/image_cache.rs @@ -0,0 +1,446 @@ +//! 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` (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").or_else(|_| std::env::var("USERPROFILE")) { + 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). +/// +/// `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()?; + 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 ext = ext_for_mime(media_type); + + // Cache-hit: if any file with this hash prefix already exists (with or + // without a name suffix, regardless of which name it carries), reuse + // it. Two callers feeding different names for the same bytes converge + // on whichever named the file first; the alternative would be writing + // multiple copies of identical bytes to disk for cosmetic reasons. + if let Some(existing) = find_existing_for_hash(dir, &hex, ext) { + if let Err(e) = touch_mtime(&existing) { + debug!(path = ?existing, error = %e, "failed to refresh image tmpfile mtime"); + } + return Some(existing); + } + + let filename = match original_name.and_then(sanitize_for_filename) { + Some(sanitized) => 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 + // referenced. Without this, a long-running conversation that + // outlives `IMAGE_TMP_TTL_SECS` would lose its image bytes + // mid-thread, even though the content block is still in scope. + // Best-effort: any failure is debug-logged and the cached path + // is returned anyway — the worst case is the legacy 24h-GC + // behavior we just had. + if let Err(e) = touch_mtime(&path) { + debug!(path = ?path, error = %e, "failed to refresh image tmpfile mtime"); + } + 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) +} + +/// Sanitize a candidate filename fragment so it is safe to embed in a +/// path under `image_tmp_dir()`. Lowercases ASCII, replaces anything +/// outside `[a-z0-9._-]` with `_`, collapses runs of `_`, strips leading +/// dots (no hidden files), drops the extension if present (the caller +/// supplies the canonical extension from MIME), and caps length at 60. +/// Returns `None` if the result would be empty. +pub fn sanitize_for_filename(name: &str) -> Option { + // Drop any path components defensively — Discord filenames shouldn't + // contain `/`, but a malicious or malformed source could try to. + let leaf = name.rsplit(['/', '\\']).next().unwrap_or(name); + // Strip the trailing extension if any — we'll let the caller tack on + // the canonical one from media_type. `foo.tar.gz` → `foo.tar`, which + // is fine: the visual hint survives. + let stem = match leaf.rsplit_once('.') { + Some((s, _)) if !s.is_empty() => s, + _ => leaf, + }; + let mut out = String::with_capacity(stem.len()); + let mut last_underscore = false; + for c in stem.chars() { + let lc = c.to_ascii_lowercase(); + let keep = lc.is_ascii_alphanumeric() || matches!(lc, '.' | '-'); + if keep { + out.push(lc); + last_underscore = false; + } else if !last_underscore { + out.push('_'); + last_underscore = true; + } + } + let trimmed = out.trim_matches(|c: char| c == '_' || c == '.').to_string(); + if trimmed.is_empty() { + return None; + } + // Cap at 60 chars to keep total path length reasonable. + let capped: String = trimmed.chars().take(60).collect(); + Some(capped) +} + +/// Look for a previously-materialized tmpfile carrying the given content +/// hash, regardless of any human-readable name suffix that may have been +/// appended. Returns the first match found; in practice there is at most +/// one because the writer enforces uniqueness on collision via the rename +/// step. Best-effort: read errors return `None` and the caller falls +/// through to a fresh write. +fn find_existing_for_hash(dir: &Path, hex: &str, ext: &str) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + let dot_ext = format!(".{ext}"); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.ends_with(&dot_ext) { + continue; + } + // Match `.` or `__.`. + let stem = name.trim_end_matches(&dot_ext); + if stem == hex || stem.starts_with(&format!("{hex}__")) { + return Some(path); + } + } + None +} + +/// Refresh the mtime of `path` to "now" so it survives the next TTL +/// sweep. Uses `File::set_modified`, which on Unix calls `futimens(2)` +/// and on Windows calls `SetFileTime`. Windows requires the handle to +/// have write access (`FILE_WRITE_ATTRIBUTES`); Unix only requires the +/// caller own the file. Open with `.write(true)` for portability. +fn touch_mtime(path: &Path) -> std::io::Result<()> { + let f = std::fs::OpenOptions::new().write(true).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) { + 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)); + }); +} + +#[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, None) + .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::OpenOptions::new().write(true).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, None) + .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, None).unwrap(); + + let stale = SystemTime::now() - Duration::from_secs(IMAGE_TMP_TTL_SECS + 3600); + let f = std::fs::OpenOptions::new().write(true).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(), None).is_none()); + } + + #[test] + fn materialize_image_appends_sanitized_name() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + let path = materialize_image( + "image/png", + TINY_PNG_B64, + dir, + Some("My Vacation Photo.PNG"), + ) + .expect("first materialization"); + let name = path.file_name().unwrap().to_str().unwrap(); + assert!( + name.contains("__my_vacation_photo.png"), + "expected sanitized name suffix, got {name}" + ); + let stem_hex: String = name.chars().take(16).collect(); + assert!( + stem_hex.chars().all(|c| c.is_ascii_hexdigit()), + "expected leading hex hash, got {stem_hex}" + ); + } + + #[test] + fn materialize_image_cache_hit_finds_named_file() { + // Same bytes materialized first WITH a name, then again with no + // name: the second call must reuse the named file rather than + // writing a duplicate `.png`. + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + let first = materialize_image("image/png", TINY_PNG_B64, dir, Some("hello.png")).unwrap(); + let second = materialize_image("image/png", TINY_PNG_B64, dir, None).unwrap(); + assert_eq!(first, second, "cache lookup should find the named file"); + + let count = std::fs::read_dir(dir) + .unwrap() + .filter(|e| { + e.as_ref() + .ok() + .and_then(|e| e.metadata().ok()) + .map(|m| m.is_file()) + .unwrap_or(false) + }) + .count(); + assert_eq!(count, 1, "no duplicate tmpfile on cache hit"); + } + + #[test] + fn sanitize_for_filename_basic_cases() { + assert_eq!( + sanitize_for_filename("Hello World.png").as_deref(), + Some("hello_world") + ); + assert_eq!( + sanitize_for_filename("/etc/passwd").as_deref(), + Some("passwd") + ); + assert_eq!( + sanitize_for_filename("foo___bar.txt").as_deref(), + Some("foo_bar") + ); + // All-punctuation/dot input → None. + assert_eq!(sanitize_for_filename("...png").as_deref(), None); + // Trailing extension is stripped. + assert_eq!( + sanitize_for_filename("smoke-test.pdf").as_deref(), + Some("smoke-test") + ); + // Length cap at 60. + let long = "a".repeat(200); + let result = sanitize_for_filename(&format!("{long}.png")).unwrap(); + assert_eq!(result.len(), 60); + // Non-ASCII bytes collapse to a single `_`. + let s = sanitize_for_filename("café.jpg").unwrap(); + assert!(s.starts_with("caf"), "got {s}"); + } + + // 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); + } +} diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index ec57efe1f9..4084e7a814 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)] @@ -139,8 +142,9 @@ pub trait KernelHandle: Send + Sync { agent_id: &str, tool_name: &str, action_summary: &str, + origin: Option<&openfang_types::approval::ApprovalOrigin>, ) -> Result { - let _ = (agent_id, tool_name, action_summary); + let _ = (agent_id, tool_name, action_summary, origin); Ok(true) // Default: auto-approve } @@ -207,8 +211,9 @@ pub trait KernelHandle: Send + Sync { recipient: &str, message: &str, thread_id: Option<&str>, + workspace_root: Option<&std::path::Path>, ) -> Result { - let _ = (channel, recipient, message, thread_id); + let _ = (channel, recipient, message, thread_id, workspace_root); Err("Channel send not available".to_string()) } @@ -253,6 +258,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`. diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index bde54ab199..8a17bef0ea 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -10,9 +10,11 @@ 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; +pub mod bridge_auth; pub mod browser; pub mod command_lane; pub mod compactor; @@ -25,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; diff --git a/crates/openfang-runtime/src/llm_driver.rs b/crates/openfang-runtime/src/llm_driver.rs index 500192c839..cea2c3f708 100644 --- a/crates/openfang-runtime/src/llm_driver.rs +++ b/crates/openfang-runtime/src/llm_driver.rs @@ -65,6 +65,36 @@ 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, + /// 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. @@ -183,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, @@ -328,6 +369,8 @@ mod tests { temperature: 0.0, 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/mcp.rs b/crates/openfang-runtime/src/mcp.rs index 13f5902845..3afe17e8f1 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,110 @@ 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))); + } + } } diff --git a/crates/openfang-runtime/src/routing.rs b/crates/openfang-runtime/src/routing.rs index bf8086a0bd..e098002ef4 100644 --- a/crates/openfang-runtime/src/routing.rs +++ b/crates/openfang-runtime/src/routing.rs @@ -188,6 +188,8 @@ mod tests { temperature: 0.7, system: None, thinking: None, + caller_agent_id: None, + allowed_tools: None, } } diff --git a/crates/openfang-runtime/src/subprocess_sandbox.rs b/crates/openfang-runtime/src/subprocess_sandbox.rs index 673d5d6cfc..66b322503d 100644 --- a/crates/openfang-runtime/src/subprocess_sandbox.rs +++ b/crates/openfang-runtime/src/subprocess_sandbox.rs @@ -209,60 +209,460 @@ 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; + +/// 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 +/// 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(()) +} + +/// 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 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() { @@ -277,19 +677,71 @@ fn extract_inner_script_commands(script: &str) -> Vec { } } } - 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()); + // 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); + } + // 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; } rest = &rest[earliest_pos + earliest_len..]; } - commands + 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> { @@ -339,6 +791,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 +803,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 { @@ -357,14 +814,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!( @@ -1224,10 +1695,207 @@ 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 @@ -1237,4 +1905,270 @@ 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()); + } } diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 16695616ee..8a3df779c9 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -18,6 +18,31 @@ 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. @@ -121,9 +146,11 @@ pub async fn execute_tool( workspace_root: Option<&Path>, media_engine: Option<&crate::media_understanding::MediaEngine>, exec_policy: Option<&openfang_types::config::ExecPolicy>, + file_policy: Option<&openfang_types::config::FilePolicy>, tts_engine: Option<&crate::tts::TtsEngine>, docker_config: Option<&openfang_types::config::DockerSandboxConfig>, process_manager: Option<&crate::process_manager::ProcessManager>, + origin: Option<&openfang_types::approval::ApprovalOrigin>, ) -> ToolResult { // Normalize the tool name through compat mappings so LLM-hallucinated aliases // (e.g. "fs-write" → "file_write") resolve to the canonical OpenFang name. @@ -172,7 +199,7 @@ pub async fn execute_tool( tool_name, openfang_types::truncate_str(&input_str, 200) ); - match kh.request_approval(agent_id_str, tool_name, &summary).await { + match kh.request_approval(agent_id_str, tool_name, &summary, origin).await { Ok(true) => { debug!(tool_name, "Approval granted — proceeding with execution"); } @@ -199,14 +226,107 @@ pub async fn execute_tool( } } + // file_policy prompt-tier pre-pass (D1-a / Q1=B): for single-path fs tools + // whose resolved path falls under a `prompt` rule, route through the same + // approval primitive the shell path uses. Approve => proceed (the path + // helper treats an already-approved prompt as write); deny / timeout / + // no-kernel => fail closed. apply_patch is multi-path and is governed + // per-target in its helper (prompt-tier fails closed there for v1). + let mut prevalidated_path: Option = None; + if let Some(fp) = file_policy { + if fp.is_active() { + if let Some((_needs_write, raw_path)) = fs_tool_single_path(tool_name, input) { + if let Some(root) = workspace_root { + if let (Ok(canon), Ok(canon_root)) = ( + crate::workspace_sandbox::sandbox_floor(raw_path, root), + root.canonicalize(), + ) { + if fp.tier_for(&canon, &canon_root) + == openfang_types::config::FileAccessTier::Prompt + { + let approved = match kernel { + Some(kh) => { + let summary = format!( + "{} -> {} (file_policy prompt tier)", + tool_name, + canon.display() + ); + matches!( + kh.request_approval( + caller_agent_id.unwrap_or("unknown"), + tool_name, + &summary, + None, + ) + .await, + Ok(true) + ) + } + None => false, + }; + if !approved { + warn!(tool_name, "file_policy prompt tier denied or unapproved"); + return ToolResult { + tool_use_id: tool_use_id.to_string(), + content: format!( + "Execution denied: '{}' targets a prompt-tier path and approval was denied, timed out, or was unavailable.", + tool_name + ), + is_error: true, + }; + } + } + // F5: remember the canonical path validated (and prompt- + // approved) here so the tool can assert its own I/O + // target matches — a swap during the approval window + // makes them differ and fails closed. + prevalidated_path = Some(canon); + } + } + } + } + } + debug!(tool_name, "Executing tool"); let result = match tool_name { // Filesystem tools - "file_read" => tool_file_read(input, workspace_root).await, - "file_write" => tool_file_write(input, workspace_root).await, - "file_list" => tool_file_list(input, workspace_root).await, - "create_directory" => tool_create_directory(input, workspace_root).await, - "apply_patch" => tool_apply_patch(input, workspace_root).await, + "file_read" => { + tool_file_read( + input, + workspace_root, + file_policy, + prevalidated_path.as_deref(), + ) + .await + } + "file_write" => { + tool_file_write( + input, + workspace_root, + file_policy, + prevalidated_path.as_deref(), + ) + .await + } + "file_list" => { + tool_file_list( + input, + workspace_root, + file_policy, + prevalidated_path.as_deref(), + ) + .await + } + "create_directory" => { + tool_create_directory( + input, + workspace_root, + file_policy, + prevalidated_path.as_deref(), + ) + .await + } + "apply_patch" => tool_apply_patch(input, workspace_root, file_policy).await, // Web tools (upgraded: multi-provider search, SSRF-protected fetch) "web_fetch" => { @@ -263,6 +383,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!( @@ -1107,7 +1228,7 @@ pub fn builtin_tool_definitions() -> Vec { // --- Channel send tool (proactive outbound messaging) --- ToolDefinition { name: "channel_send".to_string(), - description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url, file_url, or file_path to send an image or file instead of (or alongside) text. Use thread_id to reply in a specific thread/topic.".to_string(), + description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url, file_url, or file_path to send an image or file instead of (or alongside) text. Use `attachments` to send one or more local files alongside the message (workspace-relative paths preferred). Use thread_id to reply in a specific thread/topic.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -1119,6 +1240,11 @@ pub fn builtin_tool_definitions() -> Vec { "file_url": { "type": "string", "description": "URL of a file to send as attachment" }, "file_path": { "type": "string", "description": "Local file path to send as attachment (reads from disk; use instead of file_url for local files)" }, "filename": { "type": "string", "description": "Filename for file attachments (defaults to the basename of file_path, or 'file')" }, + "attachments": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of file paths to attach alongside the message. Workspace-relative paths are preferred (resolved against the agent's workspace root); absolute paths are also accepted. Routes through the outbound attachment parser with the same `allow_roots` security gating as inline `` directives. Composes with inline directives in `message`." + }, "thread_id": { "type": "string", "description": "Thread/topic ID to reply in (e.g., Telegram message_thread_id, Slack thread_ts)" } }, "required": ["channel", "recipient"] @@ -1366,10 +1492,41 @@ fn validate_path(path: &str) -> Result<&str, String> { Ok(path) } -/// Resolve a file path through the workspace sandbox (if available) or legacy validation. -fn resolve_file_path(raw_path: &str, workspace_root: Option<&Path>) -> Result { +/// For single-path filesystem tools, return `(needs_write, path)` so the +/// file_policy prompt pre-pass can evaluate the target's tier. Multi-path +/// tools (apply_patch) return None and are governed per-target in their helper. +fn fs_tool_single_path<'a>( + tool_name: &str, + input: &'a serde_json::Value, +) -> Option<(bool, &'a str)> { + let needs_write = match tool_name { + "file_read" | "file_list" => false, + "file_write" | "create_directory" => true, + _ => return None, + }; + input["path"].as_str().map(|p| (needs_write, p)) +} + +/// Resolve a file path through the workspace sandbox (if available) or legacy +/// validation. When a `file_policy` is active it governs the resolved path via +/// tier evaluation; otherwise the legacy workspace clamp applies. `needs_write` +/// distinguishes read verbs from mutating verbs for the `read` tier. The prompt +/// tier is pre-approved by execute_tool's pre-pass for single-path tools, so +/// `prompt_preapproved = true` here. +fn resolve_file_path( + raw_path: &str, + workspace_root: Option<&Path>, + file_policy: Option<&openfang_types::config::FilePolicy>, + needs_write: bool, +) -> Result { if let Some(root) = workspace_root { - crate::workspace_sandbox::resolve_sandbox_path(raw_path, root) + crate::workspace_sandbox::resolve_with_policy( + raw_path, + root, + file_policy, + needs_write, + true, + ) } else { let _ = validate_path(raw_path)?; Ok(PathBuf::from(raw_path)) @@ -1379,9 +1536,12 @@ fn resolve_file_path(raw_path: &str, workspace_root: Option<&Path>) -> Result, + file_policy: Option<&openfang_types::config::FilePolicy>, + prevalidated: Option<&Path>, ) -> Result { let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?; - let resolved = resolve_file_path(raw_path, workspace_root)?; + let resolved = resolve_file_path(raw_path, workspace_root, file_policy, false)?; + crate::workspace_sandbox::assert_prevalidated(&resolved, prevalidated)?; tokio::fs::read_to_string(&resolved) .await .map_err(|e| format!("Failed to read file: {e}")) @@ -1390,9 +1550,12 @@ async fn tool_file_read( async fn tool_file_write( input: &serde_json::Value, workspace_root: Option<&Path>, + file_policy: Option<&openfang_types::config::FilePolicy>, + prevalidated: Option<&Path>, ) -> Result { let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?; - let resolved = resolve_file_path(raw_path, workspace_root)?; + let resolved = resolve_file_path(raw_path, workspace_root, file_policy, true)?; + crate::workspace_sandbox::assert_prevalidated(&resolved, prevalidated)?; let content = input["content"] .as_str() .ok_or("Missing 'content' parameter")?; @@ -1475,12 +1638,57 @@ fn resolve_directory_path_for_create( async fn tool_create_directory( input: &serde_json::Value, workspace_root: Option<&Path>, + file_policy: Option<&openfang_types::config::FilePolicy>, + prevalidated: Option<&Path>, ) -> Result { let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?; if raw_path.is_empty() { return Err("'path' parameter is empty".to_string()); } let resolved = resolve_directory_path_for_create(raw_path, workspace_root)?; + crate::workspace_sandbox::assert_prevalidated(&resolved, prevalidated)?; + // Sensitive-path floor parity: resolve_directory_path_for_create has its own + // clamp and does not run sandbox_floor, so apply the sensitive-path deny here. + if let Some(reason) = crate::workspace_sandbox::is_sensitive_openfang_path(&resolved) + .or_else(|| crate::workspace_sandbox::is_sensitive_home_path(&resolved)) + { + return Err(format!( + "Access denied: path '{}' resolves to a protected resource ({reason}). \ + These paths are never accessible to agents.", + resolved.display() + )); + } + // file_policy governs directory creation as a write verb (prompt-tier is + // pre-approved by execute_tool's pre-pass for this single-path tool). + // F4: enforce via is_active() (so a disabled per-agent override cannot + // escape an enabled global floor) and fail closed when an active policy has + // no workspace root to evaluate against — previously this path fell open. + if let Some(fp) = file_policy { + if fp.is_active() { + let Some(root) = workspace_root else { + return Err("Access denied: create_directory requires a workspace root when a file_policy is active".to_string()); + }; + let canon_root = root + .canonicalize() + .map_err(|e| format!("Failed to resolve workspace root: {e}"))?; + match fp.tier_for(&resolved, &canon_root) { + openfang_types::config::FileAccessTier::Write + | openfang_types::config::FileAccessTier::Prompt => {} + openfang_types::config::FileAccessTier::Read => { + return Err(format!( + "Access denied by file_policy: '{}' is read-only", + resolved.display() + )); + } + openfang_types::config::FileAccessTier::Deny => { + return Err(format!( + "Access denied by file_policy: '{}' (deny tier)", + resolved.display() + )); + } + } + } + } tokio::fs::create_dir_all(&resolved) .await .map_err(|e| format!("Failed to create directory: {e}"))?; @@ -1490,9 +1698,12 @@ async fn tool_create_directory( async fn tool_file_list( input: &serde_json::Value, workspace_root: Option<&Path>, + file_policy: Option<&openfang_types::config::FilePolicy>, + prevalidated: Option<&Path>, ) -> Result { let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?; - let resolved = resolve_file_path(raw_path, workspace_root)?; + let resolved = resolve_file_path(raw_path, workspace_root, file_policy, false)?; + crate::workspace_sandbox::assert_prevalidated(&resolved, prevalidated)?; let mut entries = tokio::fs::read_dir(&resolved) .await .map_err(|e| format!("Failed to list directory: {e}"))?; @@ -1521,11 +1732,12 @@ async fn tool_file_list( async fn tool_apply_patch( input: &serde_json::Value, workspace_root: Option<&Path>, + file_policy: Option<&openfang_types::config::FilePolicy>, ) -> Result { let patch_str = input["patch"].as_str().ok_or("Missing 'patch' parameter")?; let root = workspace_root.ok_or("apply_patch requires a workspace root")?; let ops = crate::apply_patch::parse_patch(patch_str)?; - let result = crate::apply_patch::apply_patch(&ops, root).await; + let result = crate::apply_patch::apply_patch(&ops, root, file_policy).await; if result.is_ok() { Ok(result.summary()) } else { @@ -2520,7 +2732,7 @@ async fn tool_channel_send( // Local file attachment: read from disk and send as FileData if let Some(raw_path) = file_path { - let resolved = resolve_file_path(raw_path, workspace_root)?; + let resolved = resolve_file_path(raw_path, workspace_root, None, false)?; let data = tokio::fs::read(&resolved) .await .map_err(|e| format!("Failed to read file '{}': {e}", resolved.display()))?; @@ -2573,13 +2785,26 @@ async fn tool_channel_send( .await; } - // Text-only message - let message = input["message"] - .as_str() - .ok_or("Missing 'message' parameter (required for text messages)")?; - - if message.is_empty() { - return Err("Message cannot be empty".to_string()); + // Text + optional `attachments: string[]` (ANAI-53). The attachments + // array is sugar over the inline `` directive + // mechanism — we synthesize directives here, prepend them to the + // message body, and let `kernel::send_channel_message` → + // `bridge::send_parsed` → `outbound_attach::parse` handle resolution, + // workspace allow-root gating, and multipart construction. Composes + // with any inline directives the agent already wrote into `message`. + let message_raw = input["message"].as_str().unwrap_or(""); + let attachments_raw = input.get("attachments"); + let has_attachments = attachments_raw + .and_then(|v| v.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + + if message_raw.is_empty() && !has_attachments { + return Err( + "Missing 'message' parameter (required for text-only sends; \ + pass non-empty 'attachments' to send files without a body)" + .to_string(), + ); } // For email channels, validate email format and prepend subject @@ -2589,19 +2814,101 @@ async fn tool_channel_send( } if let Some(subject) = input["subject"].as_str() { if !subject.is_empty() { - format!("Subject: {subject}\n\n{message}") + format!("Subject: {subject}\n\n{message_raw}") } else { - message.to_string() + message_raw.to_string() } } else { - message.to_string() + message_raw.to_string() } } else { - message.to_string() + message_raw.to_string() }; - kh.send_channel_message(&channel, recipient, &final_message, thread_id) - .await + let final_message = synthesize_attach_directives(&final_message, attachments_raw)?; + + kh.send_channel_message( + &channel, + recipient, + &final_message, + thread_id, + workspace_root, + ) + .await +} + +/// Prepend synthesized `` directives from an +/// `attachments` JSON array to the message body. +/// +/// The outbound parser downstream extracts the directives, resolves each +/// path against the calling agent's `workspace_root`, applies the same +/// `allow_roots` security gating used by inline directives, and dispatches +/// multipart payloads on the wire. This helper is pure sugar over that +/// mechanism — no security policy here, just text synthesis. +/// +/// Behavior: +/// - `None` or `Null` `attachments` → returns `message` unchanged. +/// - `attachments` present but not a JSON array → error (caller passed +/// wrong shape). +/// - Empty array → returns `message` unchanged (explicit no-op). +/// - Each element must be a non-empty string. Path characters that would +/// break the directive boundary (`"`, `<`, `>`, `\n`, `\r`) are +/// rejected outright rather than silently escaped — these are +/// pathological in filenames and silent escaping would obscure the +/// failure mode. +/// - Synthesized directives are prepended to `message` (one per line, +/// followed by the original body). Composes additively with any inline +/// directives the caller already embedded in `message`. +/// +/// Path resolution and authorisation happen entirely downstream in +/// `outbound_attach::resolve_directive` — this helper does not touch the +/// filesystem. +fn synthesize_attach_directives( + message: &str, + attachments: Option<&serde_json::Value>, +) -> Result { + let arr = match attachments { + Some(v) if v.is_null() => return Ok(message.to_string()), + Some(v) => v + .as_array() + .ok_or("'attachments' must be an array of file path strings")?, + None => return Ok(message.to_string()), + }; + if arr.is_empty() { + return Ok(message.to_string()); + } + + let mut directives = String::new(); + for (i, item) in arr.iter().enumerate() { + let path = item + .as_str() + .ok_or_else(|| format!("'attachments[{i}]' must be a string"))?; + if path.is_empty() { + return Err(format!("'attachments[{i}]' is an empty string")); + } + if let Some(bad) = path + .chars() + .find(|c| matches!(c, '"' | '<' | '>' | '\n' | '\r')) + { + return Err(format!( + "'attachments[{i}]' contains character {bad:?} which would break \ + the directive boundary; use an inline `` \ + directive or the `file_path` parameter instead" + )); + } + directives.push_str("\n"); + } + + if message.is_empty() { + // Trim the trailing newline so an attachments-only send doesn't + // emit a phantom blank line through the formatter. + Ok(directives.trim_end().to_string()) + } else { + directives.push_str(message); + Ok(directives) + } } // --------------------------------------------------------------------------- @@ -3223,7 +3530,7 @@ async fn tool_speech_to_text( let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?; let _language = input["language"].as_str(); - let resolved = resolve_file_path(raw_path, workspace_root)?; + let resolved = resolve_file_path(raw_path, workspace_root, None, false)?; // Read the audio file let data = tokio::fs::read(&resolved) @@ -3382,6 +3689,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 \ @@ -3922,9 +4230,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!( @@ -3951,9 +4261,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -3977,9 +4289,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4003,9 +4317,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4016,7 +4332,13 @@ mod tests { async fn test_create_directory_creates_nested() { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path(); - let result = tool_create_directory(&serde_json::json!({"path": "a/b/c"}), Some(root)).await; + let result = tool_create_directory( + &serde_json::json!({"path": "a/b/c"}), + Some(root), + None, + None, + ) + .await; assert!(result.is_ok(), "Expected Ok, got: {:?}", result); let expected = root.join("a").join("b").join("c"); assert!( @@ -4031,16 +4353,71 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let root = tmp.path(); // First create - let r1 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await; + let r1 = tool_create_directory( + &serde_json::json!({"path": "data/logs"}), + Some(root), + None, + None, + ) + .await; assert!(r1.is_ok()); // Second create on existing dir should also succeed - let r2 = tool_create_directory(&serde_json::json!({"path": "data/logs"}), Some(root)).await; + let r2 = tool_create_directory( + &serde_json::json!({"path": "data/logs"}), + Some(root), + None, + None, + ) + .await; assert!(r2.is_ok(), "Expected idempotent success, got: {:?}", r2); } + #[tokio::test] + async fn test_create_directory_f4_denied_tier_blocks() { + use openfang_types::config::{FileAccessTier, FilePolicy, FileRule}; + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path(); + let policy = FilePolicy::new( + true, + FileAccessTier::Write, + vec![FileRule { + path: "secret".to_string(), + tier: FileAccessTier::Deny, + }], + ); + let r = tool_create_directory( + &serde_json::json!({"path": "secret/sub"}), + Some(root), + Some(&policy), + None, + ) + .await; + assert!(r.is_err(), "deny-tier directory must be refused: {:?}", r); + assert!(!root.join("secret").join("sub").exists()); + } + + #[tokio::test] + async fn test_create_directory_f4_active_policy_no_root_fails_closed() { + use openfang_types::config::{FileAccessTier, FilePolicy}; + let policy = FilePolicy::new(true, FileAccessTier::Write, vec![]); + let r = tool_create_directory( + &serde_json::json!({"path": "anywhere"}), + None, + Some(&policy), + None, + ) + .await; + assert!( + r.is_err(), + "active policy with no workspace root must fail closed: {:?}", + r + ); + assert!(r.unwrap_err().contains("workspace root")); + } + #[tokio::test] async fn test_create_directory_missing_path_param() { - let result = tool_create_directory(&serde_json::json!({}), None).await; + let result = tool_create_directory(&serde_json::json!({}), None, None, None).await; assert!(result.is_err()); let msg = result.unwrap_err(); assert!(msg.contains("Missing 'path'"), "got: {msg}"); @@ -4065,9 +4442,11 @@ mod tests { Some(root.as_path()), // workspace_root None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!( @@ -4095,9 +4474,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4121,9 +4502,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; // web_search now attempts a real fetch; may succeed or fail depending on network @@ -4147,9 +4530,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4173,9 +4558,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4200,9 +4587,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4231,9 +4620,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; // Should fail for file-not-found, NOT for permission denied @@ -4276,9 +4667,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; // Should NOT be the capability-enforcement "Permission denied" — it should @@ -4311,9 +4704,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4480,9 +4875,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -4525,9 +4922,11 @@ mod tests { None, None, // media_engine None, // exec_policy + None, // file_policy None, // tts_engine None, // docker_config None, // process_manager + None, // origin ) .await; assert!(result.is_error); @@ -5011,4 +5410,127 @@ mod tests { .unwrap_err(); assert!(err.to_lowercase().contains("kernel")); } + + // ----------------------------------------------------------------- + // ANAI-53: synthesize_attach_directives — pure helper tests + // ----------------------------------------------------------------- + + #[test] + fn synth_attach_none_is_noop() { + let out = super::synthesize_attach_directives("hello", None).unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn synth_attach_null_is_noop() { + let v = serde_json::Value::Null; + let out = super::synthesize_attach_directives("hello", Some(&v)).unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn synth_attach_empty_array_is_noop() { + let v = serde_json::json!([]); + let out = super::synthesize_attach_directives("hello", Some(&v)).unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn synth_attach_single_relative_path() { + let v = serde_json::json!(["report.pdf"]); + let out = super::synthesize_attach_directives("see attached", Some(&v)).unwrap(); + assert_eq!(out, "\nsee attached"); + } + + #[test] + fn synth_attach_single_absolute_path() { + let v = serde_json::json!(["/tmp/file.png"]); + let out = super::synthesize_attach_directives("", Some(&v)).unwrap(); + // Empty message → trailing newline trimmed. + assert_eq!(out, ""); + } + + #[test] + fn synth_attach_multiple_paths_preserve_order() { + let v = serde_json::json!(["a.pdf", "b.png", "c.opus"]); + let out = super::synthesize_attach_directives("caption", Some(&v)).unwrap(); + assert_eq!( + out, + "\n\n\ncaption" + ); + } + + #[test] + fn synth_attach_composes_with_inline_directive() { + // Caller wrote one inline directive, then also passed an + // attachments param. Both must survive; downstream parser sees + // two directives in the final message. + let v = serde_json::json!(["b.pdf"]); + let msg = "\nhere are two files"; + let out = super::synthesize_attach_directives(msg, Some(&v)).unwrap(); + assert_eq!( + out, + "\n\nhere are two files" + ); + // And the parser regex finds both. + let re = regex_lite::Regex::new(r#"]*?)/>"#).unwrap(); + assert_eq!(re.find_iter(&out).count(), 2); + } + + #[test] + fn synth_attach_rejects_non_array() { + let v = serde_json::json!("not-an-array"); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("must be an array"), "err = {err}"); + } + + #[test] + fn synth_attach_rejects_non_string_element() { + let v = serde_json::json!(["ok.pdf", 42]); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("attachments[1]"), "err = {err}"); + assert!(err.contains("must be a string"), "err = {err}"); + } + + #[test] + fn synth_attach_rejects_empty_path_element() { + let v = serde_json::json!(["ok.pdf", ""]); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("attachments[1]"), "err = {err}"); + assert!(err.contains("empty"), "err = {err}"); + } + + #[test] + fn synth_attach_rejects_quote_in_path() { + let v = serde_json::json!(["weird\".pdf"]); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("directive boundary"), "err = {err}"); + } + + #[test] + fn synth_attach_rejects_newline_in_path() { + let v = serde_json::json!(["line1\nline2.pdf"]); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("directive boundary"), "err = {err}"); + } + + #[test] + fn synth_attach_rejects_angle_bracket_in_path() { + let v = serde_json::json!(["foo.pdf"]); + let err = super::synthesize_attach_directives("hi", Some(&v)).unwrap_err(); + assert!(err.contains("directive boundary"), "err = {err}"); + } + + #[test] + fn synth_attach_traversal_path_still_synthesized() { + // Synthesis is pure — no path policy here. The downstream + // outbound_attach parser is what rejects traversal/escape paths + // (covered by its own test suite). This test pins down that the + // helper does NOT pre-filter — security is downstream. + let v = serde_json::json!(["../../../etc/hosts"]); + let out = super::synthesize_attach_directives("hi", Some(&v)).unwrap(); + assert!(out.contains("")); + // Defence in depth lives in outbound_attach::resolve_directive, + // which canonicalises and applies allow_roots — see its tests. + } } diff --git a/crates/openfang-runtime/src/workspace_sandbox.rs b/crates/openfang-runtime/src/workspace_sandbox.rs index 306c49f2a5..16f881f7de 100644 --- a/crates/openfang-runtime/src/workspace_sandbox.rs +++ b/crates/openfang-runtime/src/workspace_sandbox.rs @@ -2,17 +2,146 @@ //! //! 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) and under +//! `$HOME` (e.g. `~/.mcp-auth/` OAuth tokens). The deny-list fires regardless of +//! `workspace_root`, so a misconfigured manifest (e.g. `workspace_root = +//! "~/.openfang"`) or a future tool surface that bypasses workspace confinement +//! cannot exfiltrate these files. +//! +//! This sensitive-path floor ships alongside `file_policy` (path-tier +//! governance): the floor is absolute and runs before any tier evaluation, so +//! no `file_policy` rule can ever widen access past a sensitive-path deny. +use openfang_types::config::{FileAccessTier, FilePolicy}; use std::path::{Path, PathBuf}; -/// Resolve a user-supplied path within a workspace sandbox. +/// Resolve the OpenFang home directory. /// -/// - Rejects `..` components outright. -/// - Relative paths are joined with `workspace_root`. -/// - 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. -pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result { +/// 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, + } +} + +/// Returns `Some(reason)` if `path` resolves to a sensitive credential +/// directory/file under the user's `$HOME` but outside `$OPENFANG_HOME` that +/// must never be read or written by an agent, regardless of `workspace_root`. +/// +/// This is the curated **hard-deny floor** (Layer 1): an absolute, curated set +/// of credential-bearing paths. It fires for *any* agent on *any* workspace, +/// including misconfigured ones, and no `file_policy` allow rule can widen past +/// it. Other `$HOME` dotfiles are NOT hard-denied here — they fall to the +/// `file_policy` prompt tier (Layer 2), so a coarse allow rule cannot silently +/// reach them. +/// +/// Matching is component-sequence aware: single-component entries (e.g. +/// `.ssh`) deny the whole subtree; two-deep entries (e.g. `.config/gcloud`) +/// deny only that named child so sibling `.config/*` reads still fall to +/// prompt rather than being hard-denied. +/// +/// The set is hardcoded for v1 (not config-extensible — YAGNI). Add new +/// entries conservatively: 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 mut comps = rel.components(); + let first = comps.next()?.as_os_str().to_str()?; + + match first { + // OAuth tokens for mcp-remote and similar MCP clients. + ".mcp-auth" => Some("oauth-tokens"), + // SSH private keys, known_hosts, client config. + ".ssh" => Some("ssh"), + // Cloud / cluster credentials. + ".aws" => Some("aws-credentials"), + ".gnupg" => Some("gpg-keyring"), + ".kube" => Some("kube-config"), + // Single-file credential stores. + ".netrc" => Some("netrc"), + ".git-credentials" => Some("git-credentials"), + ".npmrc" => Some("npmrc"), + ".pypirc" => Some("pypirc"), + ".pgpass" => Some("pgpass"), + // Two-deep credential dirs/files: match on the second component so + // sibling entries (e.g. `.config/nvim`) fall through to prompt. + ".config" => match comps.next()?.as_os_str().to_str()? { + "gcloud" => Some("gcloud-credentials"), + "gh" => Some("gh-token"), + _ => None, + }, + ".docker" => match comps.next()?.as_os_str().to_str()? { + "config.json" => Some("docker-config"), + _ => None, + }, + ".cargo" => match comps.next()?.as_os_str().to_str()? { + "credentials" | "credentials.toml" => Some("cargo-credentials"), + _ => None, + }, + _ => None, + } +} + +/// The sandbox *floor*: `..`-rejection + canonicalization + the sensitive-path +/// deny-list. Absolute and non-overridable — no `file_policy` tier rule can +/// widen past it. Returns the canonical candidate WITHOUT applying the +/// workspace clamp (that is [`enforce_workspace_clamp`]'s job). +pub(crate) fn sandbox_floor(user_path: &str, workspace_root: &Path) -> Result { let path = Path::new(user_path); // Reject any `..` components @@ -29,11 +158,6 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result Result Result { + let canon_root = workspace_root + .canonicalize() + .map_err(|e| format!("Failed to resolve workspace root: {e}"))?; + if !canon_candidate.starts_with(&canon_root) { return Err(format!( "Access denied: path '{}' resolves outside workspace. \ @@ -61,13 +232,97 @@ pub fn resolve_sandbox_path(user_path: &str, workspace_root: &Path) -> Result Result { + let canon = sandbox_floor(user_path, workspace_root)?; + enforce_workspace_clamp(canon, workspace_root) +} + +/// Resolve a user-supplied path under an optional `file_policy`. +/// +/// - The floor always runs first (`..`-reject, canonicalize, sensitive-path deny). +/// - `None` or a disabled policy => legacy workspace clamp (back-compat). +/// - An enabled policy => longest-prefix tier evaluation against the canonical +/// path. `Deny` rejects; `Read` rejects writes; `Write` allows; `Prompt` +/// allows only when `prompt_preapproved` (the caller's pre-pass gated it), +/// otherwise fails closed. +pub fn resolve_with_policy( + user_path: &str, + workspace_root: &Path, + file_policy: Option<&FilePolicy>, + needs_write: bool, + prompt_preapproved: bool, +) -> Result { + let canon = sandbox_floor(user_path, workspace_root)?; + match file_policy { + None => enforce_workspace_clamp(canon, workspace_root), + Some(fp) if !fp.is_active() => enforce_workspace_clamp(canon, workspace_root), + Some(fp) => { + let canon_root = workspace_root + .canonicalize() + .map_err(|e| format!("Failed to resolve workspace root: {e}"))?; + match fp.tier_for(&canon, &canon_root) { + FileAccessTier::Deny => Err(format!( + "Access denied by file_policy: '{}' (deny tier)", + canon.display() + )), + FileAccessTier::Read if needs_write => Err(format!( + "Access denied by file_policy: '{}' is read-only", + canon.display() + )), + FileAccessTier::Read | FileAccessTier::Write => Ok(canon), + FileAccessTier::Prompt if prompt_preapproved => Ok(canon), + FileAccessTier::Prompt => Err(format!( + "Access denied by file_policy: '{}' requires approval \ + (prompt tier) and was not pre-approved", + canon.display() + )), + } + } + } +} + +/// F5 TOCTOU guard. Confirm the path resolved for the actual filesystem +/// operation matches the canonical path validated (and possibly approval-gated) +/// earlier in the same tool call. A mismatch means the target changed between +/// validation and use — e.g. a symlink swapped during a human approval window — +/// so fail closed. +/// +/// This closes the approval-window TOCTOU. A narrow residual remains between +/// this final resolution and the kernel `open()` (the canonicalize->open +/// window); eliminating it needs `openat`/`O_NOFOLLOW` semantics that std does +/// not expose portably, tracked as a fast-follow. +pub(crate) fn assert_prevalidated( + resolved: &Path, + prevalidated: Option<&Path>, +) -> Result<(), String> { + match prevalidated { + Some(expected) if expected != resolved => Err(format!( + "Access denied: target changed during the validation/approval window (validated '{}', now resolves to '{}') — possible TOCTOU; failing closed.", + expected.display(), + resolved.display() + )), + _ => Ok(()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -145,4 +400,379 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("Access denied")); } + + // ------------------------------------------------------------------- + // Sensitive-path deny-list tests + // + // These tests stand up a fake OpenFang home via OPENFANG_HOME and verify + // that `is_sensitive_openfang_path` classifies each tier correctly. + // + // We use a process-wide env mutex because OPENFANG_HOME is global state. + // ------------------------------------------------------------------- + + use std::sync::Mutex; + static ENV_LOCK: Mutex<()> = 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_sensitive_mcp_auth_blocked() { + // ~/.mcp-auth/ holds OAuth tokens for mcp-remote and similar; must + // be denied regardless of where the workspace sits. + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .and_then(|h| h.canonicalize().ok()); + let Some(home) = home else { + return; // $HOME unset or unreadable on this host; skip. + }; + assert_eq!( + is_sensitive_home_path(&home.join(".mcp-auth").join("mcp-remote-0.1.37")), + Some("oauth-tokens") + ); + // Sibling dotdirs must not be over-denied. + assert_eq!(is_sensitive_home_path(&home.join(".cache")), None); + // Paths outside $HOME must not be classified. + assert_eq!( + is_sensitive_home_path(std::path::Path::new("/tmp/foo")), + None + ); + } + + #[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); + } + + // ------------------------------------------------------------------- + // file_policy tier-evaluation tests (resolve_with_policy) + // ------------------------------------------------------------------- + + use openfang_types::config::{FileAccessTier, FilePolicy, FileRule}; + + fn policy(default_tier: FileAccessTier, rules: Vec<(&str, FileAccessTier)>) -> FilePolicy { + FilePolicy::new( + true, + default_tier, + rules + .into_iter() + .map(|(p, t)| FileRule { + path: p.to_string(), + tier: t, + }) + .collect(), + ) + } + + #[test] + fn test_policy_none_matches_legacy_clamp() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let with_none = resolve_with_policy("f.txt", dir.path(), None, false, true); + let legacy = resolve_sandbox_path("f.txt", dir.path()); + assert_eq!(with_none.is_ok(), legacy.is_ok()); + assert_eq!(with_none.unwrap(), legacy.unwrap()); + } + + #[test] + fn test_policy_disabled_matches_legacy_clamp() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let mut fp = policy(FileAccessTier::Deny, vec![]); + fp.enabled = false; + let r = resolve_with_policy("f.txt", dir.path(), Some(&fp), true, false); + assert!(r.is_ok(), "disabled policy must fall back to legacy clamp"); + } + + #[test] + fn test_policy_default_deny_blocks_unmatched() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let fp = policy(FileAccessTier::Deny, vec![]); + let r = resolve_with_policy("f.txt", dir.path(), Some(&fp), false, false); + assert!(r.is_err()); + assert!(r.unwrap_err().contains("deny tier")); + } + + #[test] + fn test_policy_read_tier_blocks_write_allows_read() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let fp = policy(FileAccessTier::Read, vec![]); + assert!(resolve_with_policy("f.txt", dir.path(), Some(&fp), false, false).is_ok()); + let w = resolve_with_policy("f.txt", dir.path(), Some(&fp), true, false); + assert!(w.is_err()); + assert!(w.unwrap_err().contains("read-only")); + } + + #[test] + fn test_policy_longest_prefix_wins() { + let dir = TempDir::new().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("f.txt"), "x").unwrap(); + std::fs::write(dir.path().join("top.txt"), "x").unwrap(); + // whole workspace = read, but sub/ = deny (longer prefix wins). + let fp = policy( + FileAccessTier::Deny, + vec![(".", FileAccessTier::Read), ("sub", FileAccessTier::Deny)], + ); + assert!(resolve_with_policy("top.txt", dir.path(), Some(&fp), false, false).is_ok()); + assert!(resolve_with_policy("sub/f.txt", dir.path(), Some(&fp), false, false).is_err()); + } + + #[test] + fn test_policy_prompt_fail_closed_without_preapproval() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let fp = policy(FileAccessTier::Prompt, vec![]); + // Not pre-approved => denied. + assert!(resolve_with_policy("f.txt", dir.path(), Some(&fp), true, false).is_err()); + // Pre-approved (execute_tool's pre-pass gated it) => allowed. + assert!(resolve_with_policy("f.txt", dir.path(), Some(&fp), true, true).is_ok()); + } + + #[test] + fn test_sensitive_home_credential_floor() { + // Curated Layer-1 hard-deny set: credential-bearing $HOME paths that + // must deny regardless of workspace_root, joining .mcp-auth. + let Some(home) = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .and_then(|h| h.canonicalize().ok()) + else { + return; // $HOME unset/unreadable; skip. + }; + + // Single-component dirs hard-deny their whole subtree. + assert_eq!( + is_sensitive_home_path(&home.join(".ssh").join("id_ed25519")), + Some("ssh") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".aws").join("credentials")), + Some("aws-credentials") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".gnupg").join("secring.gpg")), + Some("gpg-keyring") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".kube").join("config")), + Some("kube-config") + ); + // Single-file credential stores. + assert_eq!(is_sensitive_home_path(&home.join(".netrc")), Some("netrc")); + assert_eq!( + is_sensitive_home_path(&home.join(".git-credentials")), + Some("git-credentials") + ); + assert_eq!(is_sensitive_home_path(&home.join(".npmrc")), Some("npmrc")); + assert_eq!( + is_sensitive_home_path(&home.join(".pypirc")), + Some("pypirc") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".pgpass")), + Some("pgpass") + ); + + // Two-deep entries hard-deny only the named child... + assert_eq!( + is_sensitive_home_path(&home.join(".config").join("gcloud").join("creds.db")), + Some("gcloud-credentials") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".config").join("gh").join("hosts.yml")), + Some("gh-token") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".docker").join("config.json")), + Some("docker-config") + ); + assert_eq!( + is_sensitive_home_path(&home.join(".cargo").join("credentials.toml")), + Some("cargo-credentials") + ); + + // ...while sibling .config/* and .docker/* fall through to None + // (Layer-2 prompt territory, NOT hard-denied). + assert_eq!( + is_sensitive_home_path(&home.join(".config").join("nvim").join("init.lua")), + None + ); + assert_eq!(is_sensitive_home_path(&home.join(".config")), None); + assert_eq!( + is_sensitive_home_path(&home.join(".docker").join("contexts")), + None + ); + // A non-credential dotfile is not hard-denied (prompt backstops it). + assert_eq!(is_sensitive_home_path(&home.join(".gitconfig")), None); + } + + #[test] + fn test_assert_prevalidated_toctou_guard() { + // No pre-validated path => always ok (legacy / non-policy path). + assert!(assert_prevalidated(Path::new("/a/b"), None).is_ok()); + // Matching => ok. + assert!(assert_prevalidated(Path::new("/a/b"), Some(Path::new("/a/b"))).is_ok()); + // Diverged after validation => fail closed. + let e = assert_prevalidated(Path::new("/evil"), Some(Path::new("/a/b"))); + assert!(e.is_err()); + assert!(e.unwrap_err().contains("TOCTOU")); + } } 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/agent.rs b/crates/openfang-types/src/agent.rs index 5589b1e35e..cb546d11a8 100644 --- a/crates/openfang-types/src/agent.rs +++ b/crates/openfang-types/src/agent.rs @@ -497,6 +497,9 @@ pub struct AgentManifest { /// Accepts string shorthand ("allow", "deny", "full", "allowlist") or full table. #[serde(default, deserialize_with = "crate::serde_compat::exec_policy_lenient")] pub exec_policy: Option, + /// Per-agent file policy override. If None, uses global file_policy. + #[serde(default, deserialize_with = "crate::serde_compat::file_policy_lenient")] + pub file_policy: Option, /// Tool allowlist — only these tools are available (empty = all tools). #[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")] pub tool_allowlist: Vec, @@ -565,6 +568,7 @@ impl Default for AgentManifest { state_dir: None, generate_identity_files: true, exec_policy: None, + file_policy: None, tool_allowlist: Vec::new(), tool_blocklist: Vec::new(), cache_context: false, @@ -825,6 +829,7 @@ mod tests { state_dir: None, generate_identity_files: true, exec_policy: None, + file_policy: None, tool_allowlist: Vec::new(), tool_blocklist: Vec::new(), cache_context: false, diff --git a/crates/openfang-types/src/approval.rs b/crates/openfang-types/src/approval.rs index 157efed270..d837f63685 100644 --- a/crates/openfang-types/src/approval.rs +++ b/crates/openfang-types/src/approval.rs @@ -28,6 +28,9 @@ const MIN_TIMEOUT_SECS: u64 = 10; /// Maximum approval timeout in seconds. const MAX_TIMEOUT_SECS: u64 = 300; +/// Maximum length of an origin routing field (channel_id / thread_id / recipient), chars. +const MAX_ORIGIN_FIELD_LEN: usize = 256; + // --------------------------------------------------------------------------- // RiskLevel // --------------------------------------------------------------------------- @@ -67,6 +70,31 @@ pub enum ApprovalDecision { TimedOut, } +// --------------------------------------------------------------------------- +// ApprovalOrigin +// --------------------------------------------------------------------------- + +/// Where an agent run originated — carried so an approval prompt can be pushed +/// back to the exact channel/conversation that triggered the run. +/// +/// `None` on [`ApprovalRequest::origin`] ⇒ a non-channel trigger (cron, API +/// direct, agent_send): the emit site must fall back to the text +/// `/approve ` path (no proactive push). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApprovalOrigin { + /// Adapter key, e.g. `"discord"`, `"slack"`. Maps to `channel_type_str()`. + pub channel_type: String, + /// Per-channel/conversation routing ID (Discord channel/thread, Slack + /// conversation, Telegram chat). Sourced from `ChannelMessage::channel_id()`. + pub channel_id: Option, + /// Thread/sub-conversation, if the trigger arrived inside one. + pub thread_id: Option, + /// Platform user identity of the triggering sender (peer_id). Used ONLY for + /// audit/recipient targeting — NEVER as an authz carrier (the clicker is + /// re-authorized from the platform-attested interaction identity). + pub recipient: Option, +} + // --------------------------------------------------------------------------- // ApprovalRequest // --------------------------------------------------------------------------- @@ -84,6 +112,9 @@ pub struct ApprovalRequest { pub requested_at: DateTime, /// Auto-deny timeout in seconds. pub timeout_secs: u64, + /// Origin of the triggering run. `None` ⇒ fall back to the text approve path. + #[serde(default)] + pub origin: Option, } impl ApprovalRequest { @@ -141,6 +172,30 @@ impl ApprovalRequest { )); } + // -- origin (optional) -- + if let Some(origin) = &self.origin { + if origin.channel_type.len() > MAX_ORIGIN_FIELD_LEN { + return Err(format!( + "origin.channel_type too long ({} chars, max {MAX_ORIGIN_FIELD_LEN})", + origin.channel_type.len() + )); + } + for (label, value) in [ + ("origin.channel_id", &origin.channel_id), + ("origin.thread_id", &origin.thread_id), + ("origin.recipient", &origin.recipient), + ] { + if let Some(v) = value { + if v.len() > MAX_ORIGIN_FIELD_LEN { + return Err(format!( + "{label} too long ({} chars, max {MAX_ORIGIN_FIELD_LEN})", + v.len() + )); + } + } + } + } + Ok(()) } } @@ -299,6 +354,7 @@ mod tests { risk_level: RiskLevel::High, requested_at: Utc::now(), timeout_secs: 60, + origin: None, } } @@ -392,6 +448,20 @@ mod tests { assert!(err.contains("too long"), "{err}"); } + #[test] + fn request_origin_channel_type_too_long_rejected() { + let mut req = valid_request(); + req.origin = Some(ApprovalOrigin { + channel_type: "a".repeat(257), + channel_id: None, + thread_id: None, + recipient: None, + }); + let err = req.validate().unwrap_err(); + assert!(err.contains("origin.channel_type"), "{err}"); + assert!(err.contains("too long"), "{err}"); + } + #[test] fn request_tool_name_64_chars_ok() { let mut req = valid_request(); @@ -677,6 +747,82 @@ mod tests { assert_eq!(back.timeout_secs, req.timeout_secs); } + // ----------------------------------------------------------------------- + // ApprovalOrigin + // ----------------------------------------------------------------------- + + #[test] + fn origin_serde_roundtrip() { + let origin = ApprovalOrigin { + channel_type: "discord".into(), + channel_id: Some("123456789".into()), + thread_id: Some("987654321".into()), + recipient: Some("user-42".into()), + }; + let json = serde_json::to_string(&origin).unwrap(); + let back: ApprovalOrigin = serde_json::from_str(&json).unwrap(); + assert_eq!(back, origin); + } + + #[test] + fn request_with_origin_roundtrip() { + let mut req = valid_request(); + req.origin = Some(ApprovalOrigin { + channel_type: "discord".into(), + channel_id: Some("chan-1".into()), + thread_id: None, + recipient: Some("peer-1".into()), + }); + let json = serde_json::to_string(&req).unwrap(); + let back: ApprovalRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(back.origin, req.origin); + } + + #[test] + fn request_legacy_json_without_origin_defaults_none() { + // A persisted/in-flight request from before the `origin` field existed + // must still deserialize (→ None) via #[serde(default)]. + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "agent_id": "agent-001", + "tool_name": "shell_exec", + "description": "legacy", + "action_summary": "rm -rf /tmp/x", + "risk_level": "high", + "requested_at": "2026-06-14T00:00:00Z", + "timeout_secs": 60 + }"#; + let req: ApprovalRequest = serde_json::from_str(legacy).unwrap(); + assert_eq!(req.origin, None); + assert!(req.validate().is_ok()); + } + + #[test] + fn request_origin_channel_id_too_long_rejected() { + let mut req = valid_request(); + req.origin = Some(ApprovalOrigin { + channel_type: "discord".into(), + channel_id: Some("a".repeat(257)), + thread_id: None, + recipient: None, + }); + let err = req.validate().unwrap_err(); + assert!(err.contains("origin.channel_id"), "{err}"); + assert!(err.contains("too long"), "{err}"); + } + + #[test] + fn request_origin_field_max_len_ok() { + let mut req = valid_request(); + req.origin = Some(ApprovalOrigin { + channel_type: "discord".into(), + channel_id: Some("a".repeat(256)), + thread_id: Some("b".repeat(256)), + recipient: Some("c".repeat(256)), + }); + assert!(req.validate().is_ok()); + } + // ----------------------------------------------------------------------- // Full serde roundtrip — ApprovalPolicy // ----------------------------------------------------------------------- diff --git a/crates/openfang-types/src/bridge_auth.rs b/crates/openfang-types/src/bridge_auth.rs new file mode 100644 index 0000000000..e1b60d6230 --- /dev/null +++ b/crates/openfang-types/src/bridge_auth.rs @@ -0,0 +1,183 @@ +//! 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/config.rs b/crates/openfang-types/src/config.rs index 25df9b0059..211168f72c 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -934,6 +934,230 @@ impl Default for CanvasConfig { } } +/// Per-tier filesystem access level for a path rule. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FileAccessTier { + /// No access: file_read / file_list / file_write / apply_patch / + /// create_directory are all rejected for matching paths. + Deny, + /// Read-only: file_read and file_list allowed; mutating verbs rejected. + Read, + /// Full read + write. + Write, + /// Gated: route through the human-approval surface before allowing. + /// Fail-closed (== Deny) when the approval surface is unavailable. + #[default] + Prompt, +} + +impl FileAccessTier { + /// Restrictiveness rank — higher means less access. Used to pick the more + /// restrictive of two tiers when imposing a hard floor (F2). + const fn restrictiveness(self) -> u8 { + match self { + Self::Write => 0, + Self::Read => 1, + Self::Prompt => 2, + Self::Deny => 3, + } + } + + /// Return the more restrictive (lower-access) of `self` and `other`. + #[must_use] + pub fn floor(self, other: Self) -> Self { + if other.restrictiveness() > self.restrictiveness() { + other + } else { + self + } + } +} + +/// Expand a leading `~` (`~` or `~/...`) in a file-rule path against the user's +/// home directory, turning it into an absolute path. Done once at load time +/// (see [`deserialize_tilde_path`]) so the longest-prefix matcher in +/// [`FilePolicy::own_tier_for`] compares an absolute rule against canonical +/// targets instead of silently joining `~` onto the workspace root — the cause +/// of grants like `path = "~/work"` never matching under `default_tier = deny`. +/// +/// Only a *leading* tilde is special; `~user` and embedded tildes are left +/// untouched (no shell-style user lookup). When the home directory cannot be +/// resolved the path is returned unchanged. +fn expand_tilde(path: &str) -> String { + expand_tilde_with(path, dirs::home_dir()) +} + +/// Pure core of [`expand_tilde`] with an injectable home dir for testing. +fn expand_tilde_with(path: &str, home: Option) -> String { + let Some(home) = home else { + return path.to_string(); + }; + if path == "~" { + return home.to_string_lossy().into_owned(); + } + match path.strip_prefix("~/") { + Some(rest) => home.join(rest).to_string_lossy().into_owned(), + None => path.to_string(), + } +} + +/// Serde hook: expand a leading `~` in a [`FileRule`] path during +/// deserialization. Option B — normalize once at load time rather than at every +/// match — so a config like `path = "~/work"` becomes an absolute prefix the +/// matcher can actually hit. +fn deserialize_tilde_path<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + Ok(expand_tilde(&raw)) +} + +/// One filesystem path rule: a path prefix and the access tier granted to it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FileRule { + /// Path prefix. Relative entries resolve against the agent's workspace + /// root at match time; absolute entries match canonical absolute paths. + /// A leading `~` is expanded to the user's home directory at load time. + #[serde(deserialize_with = "deserialize_tilde_path")] + pub path: String, + /// Access tier for paths under `path`. + pub tier: FileAccessTier, +} + +/// Filesystem access policy. Mirrors `ExecPolicy`'s config ergonomics. +/// +/// When `enabled` is false (the default) the policy is inert and the legacy +/// workspace clamp applies — existing agents are unaffected. When enabled, the +/// clamp is replaced by longest-prefix tier evaluation against the resolved +/// canonical path; an absolute, non-overridable sensitive-path floor still +/// runs first and always wins. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct FilePolicy { + /// Opt-in switch. False => inert (legacy workspace clamp), byte-for-byte. + pub enabled: bool, + /// Tier for any path not matched by a rule. Defaults to `Deny` + /// (fail-closed) so an enabled policy never silently widens. + pub default_tier: FileAccessTier, + /// Path rules. Longest matching `path` prefix wins (not file order). + pub rules: Vec, + /// Runtime-resolved global floor (F2). When set, this policy may only + /// *narrow* access relative to the floor — never widen past it. Populated + /// by the kernel at spawn/restart from the global `[file_policy]`; never + /// serialized, and re-derived from current config on every boot. + #[serde(skip)] + floor: Option>, +} + +impl Default for FilePolicy { + fn default() -> Self { + Self { + enabled: false, + default_tier: FileAccessTier::Deny, + rules: Vec::new(), + floor: None, + } + } +} + +impl FilePolicy { + /// Construct a policy from its public configuration. No floor is attached; + /// the kernel attaches one at spawn/restart via [`Self::resolve_under_floor`]. + #[must_use] + pub fn new(enabled: bool, default_tier: FileAccessTier, rules: Vec) -> Self { + Self { + enabled, + default_tier, + rules, + floor: None, + } + } + + /// Longest-prefix tier match for `canon_path`, capped by any hard floor. + /// + /// Evaluates this policy's own rules and, when a global floor is attached + /// (F2), returns the *more restrictive* of the two — a per-agent policy can + /// only narrow access, never widen past the floor. A disabled policy + /// imposes no cap of its own and defers entirely to its floor. + pub fn tier_for( + &self, + canon_path: &std::path::Path, + canon_workspace_root: &std::path::Path, + ) -> FileAccessTier { + let own = if self.enabled { + self.own_tier_for(canon_path, canon_workspace_root) + } else { + FileAccessTier::Write + }; + match &self.floor { + Some(floor) => own.floor(floor.tier_for(canon_path, canon_workspace_root)), + None => own, + } + } + + /// True when this policy (or its floor) must be enforced rather than + /// falling back to the legacy workspace clamp. + #[must_use] + pub fn is_active(&self) -> bool { + self.enabled || self.floor.as_ref().is_some_and(|f| f.enabled) + } + + /// Attach `floor` as a hard floor beneath this override. A disabled floor + /// imposes no constraint and is dropped. + #[must_use] + pub fn with_floor(mut self, floor: FilePolicy) -> Self { + if floor.enabled { + self.floor = Some(Box::new(floor)); + } + self + } + + /// Resolve the effective in-memory policy for an agent: the global + /// `[file_policy]` is a hard floor beneath the agent's optional manifest + /// override. With no override the global applies wholesale; with one, the + /// override may only narrow it (F2). + #[must_use] + pub fn resolve_under_floor( + global: &FilePolicy, + manifest_override: Option, + ) -> FilePolicy { + match manifest_override { + None => global.clone(), + Some(ov) => ov.with_floor(global.clone()), + } + } + + /// Raw longest-prefix match against this policy's own rules (no floor). + /// Relative rule paths resolve against `canon_workspace_root`; absolute + /// rule paths match as-is. Returns `default_tier` when no rule matches. + fn own_tier_for( + &self, + canon_path: &std::path::Path, + canon_workspace_root: &std::path::Path, + ) -> FileAccessTier { + let mut best: Option<(usize, FileAccessTier)> = None; + for rule in &self.rules { + let rule_path = std::path::Path::new(&rule.path); + let resolved = if rule_path.is_absolute() { + rule_path.to_path_buf() + } else if rule.path.is_empty() || rule.path == "." { + canon_workspace_root.to_path_buf() + } else { + canon_workspace_root.join(rule_path) + }; + if canon_path.starts_with(&resolved) { + let len = resolved.as_os_str().len(); + if best.is_none_or(|(blen, _)| len > blen) { + best = Some((len, rule.tier)); + } + } + } + best.map_or(self.default_tier, |(_, t)| t) + } +} + /// Shell/exec security mode. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -1229,6 +1453,9 @@ pub struct KernelConfig { /// Shell/exec security policy. #[serde(default)] pub exec_policy: ExecPolicy, + /// Filesystem access policy (path-tier governance). + #[serde(default)] + pub file_policy: FilePolicy, /// Agent bindings for multi-account routing. #[serde(default)] pub bindings: Vec, @@ -1523,6 +1750,7 @@ impl Default for KernelConfig { max_cron_jobs: default_max_cron_jobs(), include: Vec::new(), exec_policy: ExecPolicy::default(), + file_policy: FilePolicy::default(), bindings: Vec::new(), broadcast: BroadcastConfig::default(), auto_reply: AutoReplyConfig::default(), @@ -1635,6 +1863,7 @@ impl std::fmt::Debug for KernelConfig { .field("max_cron_jobs", &self.max_cron_jobs) .field("include", &format!("{} file(s)", self.include.len())) .field("exec_policy", &self.exec_policy.mode) + .field("file_policy", &self.file_policy.enabled) .field("bindings", &format!("{} binding(s)", self.bindings.len())) .field( "broadcast", @@ -1975,7 +2204,9 @@ pub struct DiscordConfig { pub allowed_users: Vec, /// Default agent name to route messages to. pub default_agent: Option, - /// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT). + /// Gateway intents bitmask (default: 37377 = GUILDS | GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT). + /// GUILDS is required for GUILD_CREATE / CHANNEL_* events, which populate the + /// (guild_id, channel_name) → channel_id cache used by ANAI-55 recipient resolution. pub intents: u64, /// Ignore messages from other bots (default: true). /// Set to false to allow bot-to-bot interactions in multi-agent setups. @@ -2004,7 +2235,7 @@ impl Default for DiscordConfig { allowed_guilds: vec![], allowed_users: vec![], default_agent: None, - intents: 37376, + intents: 37377, ignore_bots: true, default_channel_id: None, free_response_channels: vec![], @@ -4038,7 +4269,7 @@ mod tests { let dc = DiscordConfig::default(); assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN"); assert!(dc.allowed_guilds.is_empty()); - assert_eq!(dc.intents, 37376); + assert_eq!(dc.intents, 37377); assert!(dc.ignore_bots); } @@ -4698,4 +4929,220 @@ shell_env_passthrough = ["*"] let policy: ExecPolicy = toml::from_str(toml_str).unwrap(); assert_eq!(policy.shell_env_passthrough, vec!["*"]); } + + // ── F2: file_policy hard-floor inheritance ──────────────────────── + + fn frule(path: &str, tier: FileAccessTier) -> FileRule { + FileRule { + path: path.to_string(), + tier, + } + } + + #[test] + fn file_access_tier_floor_picks_more_restrictive() { + use FileAccessTier::{Deny, Prompt, Read, Write}; + assert_eq!(Write.floor(Read), Read); + assert_eq!(Read.floor(Write), Read); + assert_eq!(Read.floor(Prompt), Prompt); + assert_eq!(Prompt.floor(Deny), Deny); + assert_eq!(Write.floor(Deny), Deny); + assert_eq!(Read.floor(Read), Read); + } + + #[test] + fn file_policy_no_override_inherits_global_wholesale() { + let global = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Deny, + rules: vec![frule("/data", FileAccessTier::Read)], + floor: None, + }; + let eff = FilePolicy::resolve_under_floor(&global, None); + assert_eq!(eff, global); + } + + #[test] + fn file_policy_override_cannot_widen_past_global() { + let root = std::path::Path::new("/ws"); + let global = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Deny, + rules: vec![frule("/data", FileAccessTier::Read)], + floor: None, + }; + // Agent tries to widen /data to Write and open /secret (global-denied). + let ov = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Write, + rules: vec![ + frule("/data", FileAccessTier::Write), + frule("/secret", FileAccessTier::Write), + ], + floor: None, + }; + let eff = FilePolicy::resolve_under_floor(&global, Some(ov)); + // /data: global Read caps the agent's Write. + assert_eq!( + eff.tier_for(std::path::Path::new("/data/x"), root), + FileAccessTier::Read + ); + // /secret: global default Deny caps the agent's Write. + assert_eq!( + eff.tier_for(std::path::Path::new("/secret/x"), root), + FileAccessTier::Deny + ); + } + + #[test] + fn file_policy_override_may_narrow() { + let root = std::path::Path::new("/ws"); + let global = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Write, + rules: vec![], + floor: None, + }; + let ov = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Read, + rules: vec![frule("/secret", FileAccessTier::Deny)], + floor: None, + }; + let eff = FilePolicy::resolve_under_floor(&global, Some(ov)); + assert_eq!( + eff.tier_for(std::path::Path::new("/x"), root), + FileAccessTier::Read + ); + assert_eq!( + eff.tier_for(std::path::Path::new("/secret/y"), root), + FileAccessTier::Deny + ); + } + + #[test] + fn file_policy_disabled_override_cannot_escape_enabled_floor() { + let root = std::path::Path::new("/ws"); + let global = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Read, + rules: vec![], + floor: None, + }; + // A disabled override must NOT drop back to the legacy clamp when an + // enabled global floor exists. + let ov = FilePolicy { + enabled: false, + default_tier: FileAccessTier::Write, + rules: vec![], + floor: None, + }; + let eff = FilePolicy::resolve_under_floor(&global, Some(ov)); + assert!( + eff.is_active(), + "enabled global floor must force enforcement" + ); + assert_eq!( + eff.tier_for(std::path::Path::new("/anything"), root), + FileAccessTier::Read + ); + } + + #[test] + fn file_policy_disabled_global_imposes_no_floor() { + let root = std::path::Path::new("/ws"); + let global = FilePolicy::default(); // enabled = false + let ov = FilePolicy { + enabled: true, + default_tier: FileAccessTier::Write, + rules: vec![], + floor: None, + }; + let eff = FilePolicy::resolve_under_floor(&global, Some(ov.clone())); + // Disabled floor is dropped; the override stands unmodified. + assert_eq!(eff, ov); + assert_eq!( + eff.tier_for(std::path::Path::new("/x"), root), + FileAccessTier::Write + ); + } + + // ---- tilde-path expansion (Option B: normalize at load time) ---- + + #[test] + fn expand_tilde_leading_slash_form() { + assert_eq!( + expand_tilde_with("~/work/proj", Some(PathBuf::from("/home/ben"))), + "/home/ben/work/proj" + ); + } + + #[test] + fn expand_tilde_bare_tilde() { + assert_eq!( + expand_tilde_with("~", Some(PathBuf::from("/home/ben"))), + "/home/ben" + ); + } + + #[test] + fn expand_tilde_trailing_slash_home_no_double_sep() { + // PathBuf::join normalizes the separator regardless of a trailing slash. + assert_eq!( + expand_tilde_with("~/x", Some(PathBuf::from("/home/ben/"))), + "/home/ben/x" + ); + } + + #[test] + fn expand_tilde_user_form_left_literal() { + // No shell-style `~user` lookup: only a leading `~` or `~/` is special. + assert_eq!( + expand_tilde_with("~secret/x", Some(PathBuf::from("/home/ben"))), + "~secret/x" + ); + } + + #[test] + fn expand_tilde_absolute_and_relative_unchanged() { + let home = Some(PathBuf::from("/home/ben")); + assert_eq!(expand_tilde_with("/abs/path", home.clone()), "/abs/path"); + assert_eq!(expand_tilde_with("rel/path", home), "rel/path"); + } + + #[test] + fn expand_tilde_no_home_passthrough() { + // Fail-safe: when home can't be resolved the path is left untouched + // (it simply won't match — never silently widens). + assert_eq!(expand_tilde_with("~/foo", None), "~/foo"); + } + + #[test] + fn file_rule_tilde_expands_on_deserialize() { + let rule: FileRule = toml::from_str("path = \"~/work\"\ntier = \"write\"").unwrap(); + if let Some(home) = dirs::home_dir() { + assert_eq!(rule.path, home.join("work").to_string_lossy()); + assert!(std::path::Path::new(&rule.path).is_absolute()); + } + assert_eq!(rule.tier, FileAccessTier::Write); + } + + #[test] + fn tilde_rule_matches_target_after_expansion() { + // The regression: under `default_tier = deny`, a `~/grant` write rule + // must actually grant write to a canonical path under $HOME/grant. + let Some(home) = dirs::home_dir() else { + return; // no home in this env; nothing to prove + }; + let fp: FilePolicy = toml::from_str( + "enabled = true\ndefault_tier = \"deny\"\n[[rules]]\npath = \"~/grant\"\ntier = \"write\"\n", + ) + .unwrap(); + let target = home.join("grant").join("file.txt"); + let ws = std::path::Path::new("/some/other/workspace"); + assert_eq!(fp.tier_for(&target, ws), FileAccessTier::Write); + // A sibling outside the grant still hits the deny default. + let ungranted = home.join("ungranted").join("file.txt"); + assert_eq!(fp.tier_for(&ungranted, ws), FileAccessTier::Deny); + } } 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; 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); diff --git a/crates/openfang-types/src/serde_compat.rs b/crates/openfang-types/src/serde_compat.rs index 0cad84c7f6..cfe76bc843 100644 --- a/crates/openfang-types/src/serde_compat.rs +++ b/crates/openfang-types/src/serde_compat.rs @@ -227,6 +227,47 @@ where deserializer.deserialize_any(ExecPolicyVisitor) } +/// Lenient deserializer for an optional `FilePolicy` (per-agent override). +/// +/// Mirrors [`exec_policy_lenient`]: a `[file_policy]` table deserializes to +/// `Some(FilePolicy)`, an absent/null value to `None`. Kept as a sibling so the +/// agent-manifest field reads exactly like `exec_policy`. +pub fn file_policy_lenient<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct FilePolicyVisitor; + + impl<'de> Visitor<'de> for FilePolicyVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a FilePolicy table or null") + } + + fn visit_map(self, map: A) -> Result + where + A: MapAccess<'de>, + { + let policy = + crate::config::FilePolicy::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Some(policy)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(FilePolicyVisitor) +} + #[cfg(test)] mod tests { use super::*; 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