From 27b0f5595b31115f190e29550e118d558e057204 Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Wed, 28 Oct 2026 21:12:01 -0500 Subject: [PATCH 1/4] Enforce protection checks in the Claude harness plugin Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- .github/workflows/ci.yml | 7 + scripts/install-protected-claude-test.mjs | 59 ++ src-tauri/src/host_process.rs | 85 ++- src-tauri/src/host_process/claude_hooks.rs | 239 ++++++++ src/bundled/claude-code/claude-sh.test.ts | 47 ++ src/bundled/claude-code/claude.sh | 26 +- src/bundled/claude-code/claude.ts | 374 ++++++++++-- src/bundled/claude-code/index.test.ts | 2 + src/bundled/claude-code/index.ts | 19 +- .../claude-code/protection.live.test.ts | 392 +++++++++++++ src/bundled/claude-code/run.ts | 8 +- src/bundled/claude-code/runtime.test.ts | 121 +++- src/bundled/claude-code/runtime.ts | 85 ++- src/bundled/claude-code/security.test.ts | 542 ++++++++++++++++++ src/bundled/claude-code/security.ts | 184 ++++++ src/bundled/claude-code/sessions.ts | 19 +- src/bundled/claude-code/setup.ts | 76 +++ src/bundled/claude-code/tabs.test.tsx | 3 +- 18 files changed, 2216 insertions(+), 72 deletions(-) create mode 100644 scripts/install-protected-claude-test.mjs create mode 100644 src-tauri/src/host_process/claude_hooks.rs create mode 100644 src/bundled/claude-code/protection.live.test.ts create mode 100644 src/bundled/claude-code/security.test.ts create mode 100644 src/bundled/claude-code/security.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78a749071e..745ebcdf5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,13 @@ jobs: with: key: agent-protection save-if: ${{ github.event_name == 'push' }} + - name: Install exact protected Claude fixture + run: ./bin/node scripts/install-protected-claude-test.mjs + - name: Native Claude protection boundary + env: + BUZZ_TEST_WORKERS: "2" + CLAUDE_PROTECTION_BIN: ${{ github.workspace }}/target/protected-claude-test/claude + run: ./bin/node scripts/ci-test-report.mjs kind=vitest report=test-results/protection/native-vitest.json evidence=test-results/protection/native-timing.json title="Native security hooks" -- ./bin/pnpm exec vitest run src/bundled/claude-code/protection.live.test.ts --reporter=default --reporter=json --outputFile=test-results/protection/native-vitest.json - name: Native approval-state containment run: | mkdir -p test-results/protection diff --git a/scripts/install-protected-claude-test.mjs b/scripts/install-protected-claude-test.mjs new file mode 100644 index 0000000000..d96f864bc9 --- /dev/null +++ b/scripts/install-protected-claude-test.mjs @@ -0,0 +1,59 @@ +// CI's exact native fixture, without changing the user's Claude installation. +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { chmod, mkdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { PROTECTED_CLAUDE_VERSION as version } from "../src/bundled/claude-code/security.ts"; + +const platform = `darwin-${process.arch === "arm64" ? "arm64" : "x64"}`; +if (process.platform !== "darwin") + throw new Error("This fixture requires macOS"); +const base = `https://downloads.claude.ai/claude-code-releases/${version}`; +function download(path, destination) { + const result = spawnSync( + "curl", + [ + "-fsSL", + "--connect-timeout", + "15", + "--max-time", + "300", + "--speed-limit", + "1024", + "--speed-time", + "60", + ...(destination ? ["-o", destination] : []), + `${base}/${path}`, + ], + { encoding: "utf8", timeout: 310000, maxBuffer: 1024 * 1024 }, + ); + if (result.status !== 0) + throw new Error( + `Claude fixture download failed: ${result.stderr || result.error || result.status}`, + ); + return result.stdout; +} +const manifest = JSON.parse(download("manifest.json")); +const checksum = manifest.platforms?.[platform]?.checksum; +if (typeof checksum !== "string" || !/^[a-f0-9]{64}$/.test(checksum)) + throw new Error("The pinned release has no valid platform checksum"); +const root = join(process.cwd(), "target/protected-claude-test"); +await mkdir(root, { recursive: true }); +const binary = join(root, "claude"); +console.log(`Downloading pinned Claude ${version} (${platform})`); +download(`${platform}/claude`, binary); +const hash = createHash("sha256"); +for await (const chunk of createReadStream(binary)) hash.update(chunk); +if (hash.digest("hex") !== checksum) + throw new Error("The Claude fixture checksum does not match"); +await chmod(binary, 0o755); +const result = spawnSync(binary, ["--version"], { + encoding: "utf8", + timeout: 15000, +}); +if (result.status !== 0 || !result.stdout.startsWith(`${version} `)) + throw new Error( + "The pinned Claude fixture did not start with the expected version", + ); +console.log(`Claude ${version} verified at ${binary}`); diff --git a/src-tauri/src/host_process.rs b/src-tauri/src/host_process.rs index e19c91b860..11e108e2c2 100644 --- a/src-tauri/src/host_process.rs +++ b/src-tauri/src/host_process.rs @@ -5,8 +5,10 @@ //! app exits. A page that reloaded hears nothing of it after. It runs with the //! user's own access, with optional native containment for protected workers. mod artifact; +mod claude_hooks; mod protection; use artifact::verify_artifact; +use claude_hooks::ClaudeHooks; use protection::ProcessProtection; use crate::host_command::{effective_path, load_login_shell_path, resolve_program}; @@ -64,6 +66,7 @@ pub(crate) enum ProcessEvent { } struct Entry { + hooks: Option, plugin: String, /// Its process group, which is its own pid. #[cfg_attr(not(unix), allow(dead_code))] @@ -108,13 +111,25 @@ impl HostProcesses { } /// Starts `command`, owned by `plugin`, and streams its output to `on_event`, /// unless the page that asked during `page` has since gone. + #[cfg(test)] fn start( + &self, + page: u64, + plugin: String, + program: &str, + command: Command, + on_event: Channel, + ) -> Result { + self.start_guarded(page, plugin, program, command, on_event, false) + } + fn start_guarded( &self, page: u64, plugin: String, program: &str, mut command: Command, on_event: Channel, + claude_hooks: bool, ) -> Result { let state = self.clone(); let mut registry = self.registry(); @@ -137,11 +152,19 @@ impl HostProcesses { .spawn() .map_err(|error| format!("Could not start {program}: {error}"))?; let handle = registry.next; + let hooks = if claude_hooks { + Some(ClaudeHooks::new( + child.id().ok_or("Claude has no process group")?, + )) + } else { + None + }; registry.next += 1; let (stop, stopped) = oneshot::channel(); registry.entries.insert( handle, Entry { + hooks: hooks.clone(), plugin, group: child.id(), stdin: child @@ -156,14 +179,19 @@ impl HostProcesses { let (gone, reader_gone) = oneshot::channel::<()>(); let gone = Arc::new(Mutex::new(Some(gone))); let readers = [ - child - .stdout - .take() - .map(|out| forward(out, Stream::Stdout, on_event.clone(), gone.clone())), + child.stdout.take().map(|out| { + forward( + out, + Stream::Stdout, + on_event.clone(), + gone.clone(), + hooks.clone(), + ) + }), child .stderr .take() - .map(|err| forward(err, Stream::Stderr, on_event.clone(), gone.clone())), + .map(|err| forward(err, Stream::Stderr, on_event.clone(), gone.clone(), None)), ]; #[cfg(unix)] let group = child.id().map(|pid| pid as i32); @@ -196,6 +224,9 @@ impl HostProcesses { } } }; + if let Some(hooks) = &hooks { + hooks.close(); + } // Descendants that outlived it go with it. #[cfg(unix)] if let Some(group) = group { @@ -230,22 +261,32 @@ impl HostProcesses { if data.len() > MAX_WRITE_BYTES { return Err("Write is too large".into()); } - let stdin = self.owned(plugin, handle, |entry| { - if close { - entry.stdin.take() - } else { - entry.stdin.clone() - } + let (stdin, hooks) = self.owned(plugin, handle, |entry| { + ( + if close { + entry.stdin.take() + } else { + entry.stdin.clone() + }, + entry.hooks.clone(), + ) })?; let stdin = stdin.ok_or("The process input is closed")?; let mut stdin = stdin.lock().await; + if let Some(hooks) = &hooks { + hooks.input(data.as_bytes())?; + } let written = async { stdin.write_all(data.as_bytes()).await?; stdin.flush().await }; - written - .await - .map_err(|_| "The process is not reading its input".into()) + let result = written.await; + if result.is_err() { + if let Some(hooks) = hooks { + hooks.trip(); + } + } + result.map_err(|_| "The process is not reading its input".into()) } fn kill(&self, plugin: &str, handle: u64) -> Result<(), String> { if let Some(stop) = self.owned(plugin, handle, |entry| { @@ -379,6 +420,7 @@ pub(crate) async fn plugin_host_process_spawn( } else { None }; + let claude_hooks = protected && plugin == "buzz.claude-code"; let mut command = if let Some(protection) = protection { let controls = controls.ok_or("Agent controls are unavailable")?; std::fs::create_dir_all(&controls).map_err(|_| "Agent controls are unavailable")?; @@ -453,7 +495,14 @@ pub(crate) async fn plugin_host_process_spawn( #[cfg(unix)] command.as_std_mut().process_group(0); - processes.start(page, plugin, &declared.program, command, on_event) + processes.start_guarded( + page, + plugin, + &declared.program, + command, + on_event, + claude_hooks, + ) } /// The verified target stays inside the MCP prefix and the same containment. @@ -515,6 +564,7 @@ fn forward( stream: Stream, channel: Channel, gone: Arc>>>, + hooks: Option, ) -> tauri::async_runtime::JoinHandle<()> { tauri::async_runtime::spawn(async move { let mut buffer = vec![0; READ_BYTES]; @@ -525,6 +575,11 @@ fn forward( Ok(read) => read, }; pending.extend_from_slice(&buffer[..read]); + if let Some(hooks) = &hooks { + if hooks.output(&buffer[..read]).is_err() { + break; + } + } let data = take_text(&mut pending); if data.is_empty() { continue; diff --git a/src-tauri/src/host_process/claude_hooks.rs b/src-tauri/src/host_process/claude_hooks.rs new file mode 100644 index 0000000000..81252420dc --- /dev/null +++ b/src-tauri/src/host_process/claude_hooks.rs @@ -0,0 +1,239 @@ +//! Claude's callback timeout otherwise keeps the original result. A native +//! deadline kills the entire worker even when its frontend stops responding. +use serde_json::Value; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{sync::oneshot, time::Instant}; +const DEADLINE: Duration = Duration::from_secs(350); +const MAX_FRAME: usize = 4 * 1024 * 1024; +struct Pending { + deadline: Instant, + _cancel: oneshot::Sender<()>, +} +#[derive(Default)] +struct State { + output: Vec, + input: Vec, + pending: HashMap, + failed: bool, +} +#[derive(Clone)] +pub(super) struct ClaudeHooks { + state: Arc>, + group: u32, +} +impl ClaudeHooks { + pub fn new(group: u32) -> Self { + Self { + state: Arc::default(), + group, + } + } + pub fn close(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.failed = true; + state.pending.clear(); + } + pub fn trip(&self) { + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.failed { + return; + } + state.failed = true; + state.pending.clear(); + } + #[cfg(unix)] + unsafe { + libc::kill(-(self.group as i32), libc::SIGKILL); + } + } + pub fn output(&self, data: &[u8]) -> Result<(), String> { + self.observe(data, true) + } + pub fn input(&self, data: &[u8]) -> Result<(), String> { + self.observe(data, false) + } + fn observe(&self, data: &[u8], output: bool) -> Result<(), String> { + self.observe_at(data, output, Instant::now()) + } + fn observe_at(&self, data: &[u8], output: bool, now: Instant) -> Result<(), String> { + let mut timers = Vec::new(); + let result = (|| { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.failed { + return Err("The protected Claude worker stopped".into()); + } + let buffer = if output { + &mut state.output + } else { + &mut state.input + }; + if buffer.len() + data.len() > MAX_FRAME { + return Err("Protected Claude frame is too large".into()); + } + buffer.extend_from_slice(data); + let mut frames = Vec::new(); + while let Some(end) = buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec<_> = buffer.drain(..=end).collect(); + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + frames.push( + serde_json::from_slice::(&line) + .map_err(|_| "Invalid protected Claude frame")?, + ); + } + for frame in frames { + if output + && frame["type"] == "control_request" + && frame["request"]["subtype"] == "hook_callback" + { + let callback = frame["request"]["callback_id"].as_str().unwrap_or_default(); + if !matches!( + callback, + "security-tool" + | "security-incoming" + | "security-incomplete" + | "security-session" + ) { + continue; + } + let id = frame["request_id"] + .as_str() + .filter(|id| !id.is_empty()) + .ok_or("Invalid security callback")? + .to_string(); + if state.pending.len() >= 16 || state.pending.contains_key(&id) { + return Err("Invalid concurrent security callback".into()); + } + let deadline = now + DEADLINE; + let (cancel, cancelled) = oneshot::channel(); + state.pending.insert( + id.clone(), + Pending { + deadline, + _cancel: cancel, + }, + ); + timers.push((id, deadline, cancelled)); + } else if !output && frame["type"] == "control_response" { + let response = &frame["response"]; + if let Some(id) = response["request_id"].as_str() { + if let Some(pending) = state.pending.get(id) { + if pending.deadline <= now + || response["subtype"] != "success" + || !response["response"].is_object() + { + return Err( + "A failed or late security callback cannot resume Claude" + .into(), + ); + } + state.pending.remove(id); + } + } + } + } + Ok(()) + })(); + if result.is_err() { + self.trip(); + return result; + } + for (id, deadline, mut cancelled) in timers { + let guard = self.clone(); + tauri::async_runtime::spawn(async move { + tokio::select! { + _=tokio::time::sleep_until(deadline)=>{ + let overdue=guard.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.get(&id).is_some_and(|pending|pending.deadline==deadline); + if overdue {guard.trip();} + }, + _=&mut cancelled=>{}, + } + }); + } + result + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::process::CommandExt; + fn request(id: &str) -> Vec { + format!("{}\n",serde_json::json!({"type":"control_request","request_id":id,"request":{"subtype":"hook_callback","callback_id":"security-tool"}})).into_bytes() + } + fn response(id: &str, subtype: &str) -> Vec { + format!("{}\n",serde_json::json!({"type":"control_response","response":{"request_id":id,"subtype":subtype,"response":{}}})).into_bytes() + } + fn worker() -> tokio::process::Child { + let mut command = tokio::process::Command::new("/bin/sleep"); + command.arg("60").kill_on_drop(true); + command.as_std_mut().process_group(0); + command.spawn().unwrap() + } + #[tokio::test] + async fn fragmented_responses_cannot_release_a_different_callback() { + let mut child = worker(); + let guard = ClaudeHooks::new(child.id().unwrap()); + let frame = request("first"); + guard.output(&frame[..8]).unwrap(); + guard.output(&frame[8..]).unwrap(); + guard.input(&response("other", "success")).unwrap(); + assert_eq!(guard.state.lock().unwrap().pending.len(), 1); + guard.input(&response("first", "success")).unwrap(); + assert!(guard.state.lock().unwrap().pending.is_empty()); + assert!(child.try_wait().unwrap().is_none()); + guard.close(); + child.kill().await.unwrap(); + } + #[tokio::test] + async fn failed_or_late_callback_kills_the_worker() { + for late in [false, true] { + let mut child = worker(); + let guard = ClaudeHooks::new(child.id().unwrap()); + guard.output(&request("review")).unwrap(); + let result = if late { + guard.observe_at( + &response("review", "success"), + false, + Instant::now() + DEADLINE, + ) + } else { + guard.input(&response("review", "error")) + }; + assert!(result.is_err()); + assert!(!tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + } + } + #[tokio::test] + async fn native_timeout_needs_no_frontend_response() { + let mut child = worker(); + let guard = ClaudeHooks::new(child.id().unwrap()); + guard + .observe_at(&request("stalled"), true, Instant::now() - DEADLINE) + .unwrap(); + assert!(!tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + } +} diff --git a/src/bundled/claude-code/claude-sh.test.ts b/src/bundled/claude-code/claude-sh.test.ts index a39ef5541f..3169a7ff71 100644 --- a/src/bundled/claude-code/claude-sh.test.ts +++ b/src/bundled/claude-code/claude-sh.test.ts @@ -1,6 +1,7 @@ // The real claude.sh switching releases in a throwaway home, as Buzz builds // sharing one folder do. import { execFile, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { watch } from "node:fs"; import { chmod, @@ -8,6 +9,7 @@ import { mkdtemp, readdir, readFile, + realpath, rm, writeFile, } from "node:fs/promises"; @@ -25,6 +27,51 @@ afterEach(async () => { ); }); +it("rejects executable cached bytes that do not match the trusted protected pin", async () => { + const { home, dir } = await disk(["2.1.305"]); + const binary = join(dir, "2.1.305", "claude"); + const marker = join(home, "executed"); + await writeFile(binary, `#!/bin/sh\ntouch '${marker}'\necho '2.1.305'\n`); + for (const command of ["runtime", "run"]) { + await expect( + run("bash", ["-c", script, "claude.sh", command], { + env: { + PATH: process.env.PATH ?? "", + HOME: home, + BUZZ_CLAUDE_VERSION: "2.1.305", + }, + }), + ).rejects.toThrow("did not match its checksum"); + } + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("reverifies protected bytes at execution after a successful discovery", async () => { + const { home, dir } = await disk(["2.1.305"]); + const binary = join(dir, "2.1.305", "claude"); + // Use fixture release bytes and their trusted pin while exercising the real shell. + const checksum = createHash("sha256") + .update(await readFile(binary)) + .digest("hex"); + const launcher = script.replace(/[a-f0-9]{64}/g, checksum); + const step = (command: string) => + run("bash", ["-c", launcher, "claude.sh", command], { + env: { + PATH: process.env.PATH ?? "", + HOME: home, + BUZZ_CLAUDE_VERSION: "2.1.305", + }, + }); + expect(JSON.parse((await step("runtime")).stdout)).toEqual({ + version: "2.1.305", + root: await realpath(dir), + }); + expect((await step("run")).stdout.trim()).toBe("2.1.305"); + await writeFile(binary, "#!/bin/sh\necho '2.1.305 (altered)'\n"); + await expect(step("run")).rejects.toThrow("did not match its checksum"); + await expect(step("runtime")).rejects.toThrow("did not match its checksum"); +}); + /** Buzz's claude-code folder in a new home, holding fake `releases` with `current`. */ async function disk(releases: readonly string[], current?: string) { const home = await mkdtemp(join(tmpdir(), "buzz-claude-")); diff --git a/src/bundled/claude-code/claude.sh b/src/bundled/claude-code/claude.sh index e2dbeeec2f..8e840df025 100644 --- a/src/bundled/claude-code/claude.sh +++ b/src/bundled/claude-code/claude.sh @@ -28,9 +28,30 @@ newer() { }' } +# Trusted pins from https://downloads.claude.ai/claude-code-releases/2.1.305/manifest.json. +# Recheck cached bytes both during discovery and immediately before protected exec. +verify_protected() { + [ "$1" = "2.1.305" ] || { echo "Unsupported protected Claude release" >&2; exit 1; } + case "$(uname -sm)" in + "Darwin arm64") checksum=ea3ea3c08d2a9634a4651e687fcf900d410c489bce2b00f22bd7352b91ba704f ;; + "Darwin x86_64") checksum=af7ad8d6d8b1e49059988f6657d91bb8b9feac127bf8bec005c4c27d0093f153 ;; + "Linux aarch64") checksum=5265a60217e667b75e126e887b49ac0f168ad8bb13c605c3e3df528519876dc0 ;; + "Linux x86_64") checksum=86cb722ab68348877dba7971e4d0c659919e05e6228c05e006e83da320e656ef ;; + *) echo "Unsupported protected Claude platform" >&2; exit 1 ;; + esac + actual=$( (shasum -a 256 "$dir/$1/claude" 2>/dev/null || sha256sum "$dir/$1/claude") | cut -d" " -f1) + [ "$actual" = "$checksum" ] || { echo "The protected Claude executable did not match its checksum" >&2; exit 1; } +} + command="$1" shift case "$command" in +runtime) + version="${BUZZ_CLAUDE_VERSION:-$(cat "$dir/current" 2>/dev/null)}" + valid "$version" && test -x "$dir/$version/claude" || exit 127 + verify_protected "$version" + perl -MCwd=abs_path -MJSON::PP -e 'print JSON::PP->new->encode({version=>$ARGV[0],root=>abs_path($ARGV[1])})' "$version" "$dir" + ;; run) # A failed exec returns (execfail, without errexit), so the launcher can # look again when a release it read was pruned before it started. @@ -41,6 +62,9 @@ run) version="${BUZZ_CLAUDE_VERSION:-$(cat "$dir/current" 2>/dev/null)}" # 127, as for a missing program: the plugin reads it as "not installed". valid "$version" || { echo "Claude is not installed" >&2; exit 127; } + if [ "${BUZZ_CLAUDE_VERSION:-}" = "2.1.305" ]; then + verify_protected "$version" + fi exec "$dir/$version/claude" "$@" # Builds that switched after the read above may have pruned its release; # then start the one `current` names now. @@ -94,7 +118,7 @@ use) # one, which another build may be testing; remove the rest. for folder in "$dir"/*/; do folder=$(basename "$folder") - [ "$folder" = "$version" ] || [ "$folder" = "$previous" ] || + [ "$folder" = "$version" ] || [ "$folder" = "$previous" ] || [ "$folder" = "2.1.305" ] || newer "$folder" "$version" || rm -rf "${dir:?}/$folder" done ;; diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index 7028261af5..b128ed6d4c 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -10,6 +10,12 @@ import type { HostProcess, HostProcessOptions, } from "../../features/host/service"; +import { + protectedRuntime, + protectionSources, + PROTECTED_CLAUDE_VERSION, + type ClaudeProtection, +} from "./security"; export type ActivityReceipt = Readonly<{ /** Presentation destination; channel-shared sessions may handle several threads. */ @@ -25,6 +31,7 @@ export type Spawn = ( ) => Promise; export type ClaudeLaunch = Readonly<{ + protection?: ClaudeProtection; cwd: string; /** Folders besides `cwd` that the session may work in and stay in. */ directories?: readonly string[]; @@ -106,6 +113,22 @@ export const ENV: Readonly> = { }; export class ClaudeProcess { + private protection: ClaudeProtection | undefined; + private protectionLifetime = new AbortController(); + private protectedInstructions = ""; + private protectedHistory: unknown = []; + private readonly protectedEvents: Message[] = []; + private resuming = false; + private reviewText = ""; + get result() { + return this.reviewText; + } + protectionContext() { + return structuredClone({ + transcript: this.protectedHistory, + events: this.protectedEvents, + }); + } /** Its session, known once the first turn starts (or at once when resuming). */ sessionId: string | undefined; /** The conversation it serves, set when a spare is put to use. */ @@ -161,6 +184,7 @@ export class ClaudeProcess { ) { this.sessionId = resume; this.exited = process.exited.then((code) => { + this.protectionLifetime.abort(); this.alive = false; for (const resolve of this.calls.values()) resolve({ subtype: "error", error: "Claude exited" }); @@ -183,7 +207,12 @@ export class ClaudeProcess { /** Starts `claude` without its prompt. This is most of what a start costs, * and fixes nothing about the agent it serves but its folder, model and * effort. */ - static async spawn(spawn: Spawn, launch: Omit) { + static async spawn( + spawn: Spawn, + launch: Omit & { systemPrompt?: string }, + ) { + launch.protection?.gate.assertAvailable(); + const security = launch.protection && (await protectedRuntime(spawn)); let claude: ClaudeProcess | undefined; const early: string[] = []; const earlyErrors: string[] = []; @@ -198,6 +227,16 @@ export class ClaudeProcess { // Recent models return empty thinking unless asked for summaries. "--thinking-display", "summarized", + ...(security + ? [ + "--system-prompt", + launch.systemPrompt ?? "", + "--setting-sources", + "", + "--strict-mcp-config", + "--disable-slash-commands", + ] + : []), ...(launch.discovery ? [ "--safe-mode", @@ -231,7 +270,53 @@ export class ClaudeProcess { : []), ], cwd: launch.cwd, - env: ENV, + env: { + ...ENV, + ...(launch.discovery + ? { + CLAUDE_CODE_DISABLE_FILE_MENTIONS: "1", + CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + CLAUDE_CODE_DISABLE_ATTACHMENTS: "1", + CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", + } + : {}), + ...(security + ? { + BUZZ_CLAUDE_VERSION: PROTECTED_CLAUDE_VERSION, + CLAUDE_CODE_EAGER_FLUSH: "1", + DISABLE_COMPACT: "1", + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1", + ANTHROPIC_BETAS: null, + CLAUDE_CODE_DISABLE_REFUSAL_FALLBACK: "1", + CLAUDE_CODE_DISABLE_REFUSAL_RETRY: "1", + CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: "1", + CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1", + CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + CLAUDE_CODE_DISABLE_FILE_MENTIONS: "1", + CLAUDE_CODE_DISABLE_ATTACHMENTS: "1", + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + CLAUDE_CODE_DISABLE_CRON: "1", + BASH_ENV: null, + ENV: null, + DYLD_INSERT_LIBRARIES: null, + DYLD_LIBRARY_PATH: null, + DYLD_FRAMEWORK_PATH: null, + LD_PRELOAD: null, + LD_LIBRARY_PATH: null, + } + : {}), + }, + ...(security && launch.protection + ? { + protection: { + readOnlyPaths: [ + ...launch.protection.gate.processProtection.readOnlyPaths, + security.root, + ], + hiddenPaths: launch.protection.gate.processProtection.hiddenPaths, + }, + } + : {}), onStdout: (data) => (claude ? claude.read(data) : early.push(data)), onStderr: (data) => claude ? claude.readError(data) : earlyErrors.push(data), @@ -243,6 +328,9 @@ export class ClaudeProcess { launch.model, launch.hook, ); + claude.protection = launch.protection; + claude.resuming = !!launch.resume; + claude.protectedInstructions = launch.systemPrompt ?? ""; for (const data of earlyErrors) claude.readError(data); for (const data of early) claude.read(data); return claude; @@ -251,40 +339,100 @@ export class ClaudeProcess { /** Gives a started process its prompt, and its tools to whoever it now * works for. Loads settings and tools now rather than on the first message. */ async initialize( - launch: Pick, + launch: Pick< + ClaudeLaunch, + "systemPrompt" | "tools" | "protection" | "hook" + >, ) { - if (launch.tools) this.tools = launch.tools; - this.hook = launch.hook; - const { written, answer } = this.request({ - subtype: "initialize", - appendSystemPrompt: launch.systemPrompt, - ...(launch.tools - ? { - sdkMcpServers: [TOOL_SERVER], - hooks: { - ...Object.fromEntries( - CLAUDE_HOOK_EVENTS.map((event) => [ - event, - [{ hookCallbackIds: [`buzz:${event}`] }], - ]), - ), - PostToolUse: [ - { hookCallbackIds: ["buzz:PostToolUse"] }, - { matcher: FINAL_TOOL, hookCallbackIds: ["final"] }, - ], - }, - } - : {}), - }); - await written; - this.listed = answer.then((response) => { - const models = (response.response as Message | undefined)?.models; - return Array.isArray(models) - ? models.filter( - (model): model is ClaudeModel => typeof model?.value === "string", - ) - : []; - }); + try { + if (launch.tools) this.tools = launch.tools; + this.hook = launch.protection ? undefined : launch.hook; + const { written, answer } = this.request({ + subtype: "initialize", + ...(!launch.protection + ? { appendSystemPrompt: launch.systemPrompt } + : {}), + ...(launch.tools || launch.protection + ? { + ...(launch.tools ? { sdkMcpServers: [TOOL_SERVER] } : {}), + hooks: { + ...(launch.protection + ? { + PreToolUse: [ + { hookCallbackIds: ["security-tool"], timeout: 600 }, + ], + PreCompact: [ + { + hookCallbackIds: ["security-incomplete"], + timeout: 600, + }, + ], + SessionStart: [ + { hookCallbackIds: ["security-session"], timeout: 600 }, + ], + UserPromptSubmit: [ + { + hookCallbackIds: ["security-incoming"], + timeout: 600, + }, + ], + PostToolBatch: [ + { + hookCallbackIds: ["security-incoming"], + timeout: 600, + }, + ], + PostToolUseFailure: [ + { + hookCallbackIds: ["security-incoming"], + timeout: 600, + }, + ], + } + : Object.fromEntries( + CLAUDE_HOOK_EVENTS.map((event) => [ + event, + [{ hookCallbackIds: [`buzz:${event}`] }], + ]), + )), + PostToolUse: [ + ...(launch.protection + ? [{ hookCallbackIds: ["security-incoming"], timeout: 600 }] + : [{ hookCallbackIds: ["buzz:PostToolUse"] }]), + ...(launch.tools + ? [{ matcher: FINAL_TOOL, hookCallbackIds: ["final"] }] + : []), + ], + }, + } + : {}), + }); + await written; + if (launch.protection) { + const timer = setTimeout(() => void this.kill(), 15_000); + try { + const response = await answer; + if (response.subtype !== "success") + throw new Error( + `Claude did not register protection hooks: ${String(response.error)}`, + ); + } finally { + clearTimeout(timer); + } + } + this.listed = answer.then((response) => { + const models = (response.response as Message | undefined)?.models; + return Array.isArray(models) + ? models.filter( + (model): model is ClaudeModel => typeof model?.value === "string", + ) + : []; + }); + } catch (error) { + await this.kill(); + await this.exited; + throw error; + } } get running() { @@ -367,6 +515,13 @@ export class ClaudeProcess { async interrupt() { if (!this.alive || !this.active) return; this.stopping = true; + if (this.protection) { + this.protectionLifetime.abort(); + this.lastError = "Protected Claude was stopped"; + await this.process.kill(); + await this.exited; + return; + } const answer = await this.call({ subtype: "interrupt" }); if (answer.subtype === "error" && this.alive) { this.stopping = false; @@ -375,6 +530,7 @@ export class ClaudeProcess { } kill() { + this.protectionLifetime.abort(); return this.process.kill(); } @@ -487,6 +643,12 @@ export class ClaudeProcess { private readonly receipts = new Map(); private readonly activeReceipts = new Map(); private handle(message: Message) { + // The CLI may emit a tool callback before its asynchronous transcript write. + // Keep native protocol evidence alongside persisted history, without replacing it. + if (this.protection && ["user", "assistant"].includes(String(message.type))) + this.protectedEvents.push(structuredClone(message)); + if (message.type === "result" && typeof message.result === "string") + this.reviewText = message.result; if (typeof message.session_id === "string" && message.session_id) this.sessionId = message.session_id; if (message.type === "system" && message.subtype === "init") { @@ -627,9 +789,147 @@ export class ClaudeProcess { error: { code: -32603, message: String(error?.message ?? error) }, })), }); - else if (request.subtype === "can_use_tool") + else if ( + request.subtype === "hook_callback" && + this.protection && + [ + "security-tool", + "security-incoming", + "security-incomplete", + "security-session", + ].includes(String(request.callback_id)) + ) { + try { + const input = request.input as Message; + if ( + request.callback_id === "security-incomplete" || + (request.callback_id === "security-session" && + !["startup", "resume"].includes(String(input.source))) + ) + throw new Error( + "Protected Claude cannot rewrite its reviewed conversation; start a new conversation", + ); + if ( + request.callback_id === "security-session" && + input.source === "startup" && + !this.resuming + ) + return this.write({ + type: "control_response", + response: success({}), + }); + const tool = request.callback_id === "security-tool"; + const kind = tool + ? "tool" + : ["UserPromptSubmit", "SessionStart"].includes( + String(input.hook_event_name), + ) + ? "context" + : "tool-result"; + if (tool && ["Agent", "Task"].includes(String(input.tool_name))) + throw new Error( + "Protected Claude does not delegate outside its reviewed conversation", + ); + if (this.protection.gate.phases?.[kind] === false) { + this.protectionLifetime.signal.throwIfAborted(); + this.protection.gate.assertAvailable(); + return this.write({ + type: "control_response", + response: success( + tool + ? { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + } + : {}, + ), + }); + } + if (typeof input.transcript_path !== "string") + throw new Error("Claude omitted the protected conversation"); + const history = await this.protection + .history(input.transcript_path, this.protectionLifetime.signal) + .catch((error) => { + if ( + input.hook_event_name === "UserPromptSubmit" && + !this.resuming && + !this.began && + /no such file/i.test(String(error)) + ) + return []; + throw error; + }); + this.protectedHistory = structuredClone(history); + const content = { + ...(tool + ? { title: input.tool_name, arguments: input.tool_input } + : {}), + instructions: this.protectedInstructions, + conversation: history, + events: structuredClone(this.protectedEvents), + input, + }; + // Tool authority needs the combined conversation. Injection detection + // inspects native sources individually, without duplicating the envelope. + const incoming = { ...input }; + for (const key of [ + "session_id", + "transcript_path", + "cwd", + "permission_mode", + "hook_event_name", + ]) + delete incoming[key]; // Native transport metadata is not model content. + const sources = tool + ? [{ kind: "tool" as const, content }] + : [ + { + kind: "context" as const, + content: { + source: "instructions", + value: this.protectedInstructions, + }, + }, + ...[ + ...(Array.isArray(history) ? history : [history]), + ...this.protectedEvents, + ].flatMap((value) => protectionSources(value)), + ...protectionSources( + incoming, + "hook", + kind === "tool-result" ? "tool-result" : "context", + ), + ]; + for (const source of sources) { + await this.protection.gate.check( + { ...source, session: this.sessionId ?? "initial" }, + this.protectionLifetime.signal, + ); + } + this.protectionLifetime.signal.throwIfAborted(); + response = success( + tool + ? { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + } + : {}, + ); + } catch (error) { + this.protectionLifetime.abort(); + this.lastError = + error instanceof Error ? error.message : "Security review failed"; + await this.process.kill(); + await this.exited; + return; + } + } else if (request.subtype === "can_use_tool") response = success({ - behavior: "allow", + behavior: this.protection ? "deny" : "allow", updatedInput: request.input ?? {}, }); else if (request.subtype === "hook_callback") { diff --git a/src/bundled/claude-code/index.test.ts b/src/bundled/claude-code/index.test.ts index 7f72616185..9bf54e71dd 100644 --- a/src/bundled/claude-code/index.test.ts +++ b/src/bundled/claude-code/index.test.ts @@ -28,6 +28,7 @@ it("observes saved sessions with the live status of running ones", () => { ]); let type: AgentType | undefined; apply({ + agentProtection: { registerModels: vi.fn() }, pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { request: vi.fn() }, agents: { @@ -50,6 +51,7 @@ it("shares a Claude agent's model as the claude runtime and imports it back", () vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); let type: AgentType | undefined; apply({ + agentProtection: { registerModels: vi.fn() }, pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { request: vi.fn() }, agents: { diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 4c4ef91607..f16abebdab 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -19,7 +19,7 @@ export { TYPE_ID }; /** How often Buzz's copy of Claude looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +export const inject = ["agents", "host", "agentProtection"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `claude`, and setup and file uploads run // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. @@ -29,6 +29,15 @@ export const apply: PluginModule["apply"] = (ctx) => { const spawn = host.spawn && withScript(host.spawn.bind(host)); const readFile = host.readFile?.bind(host); const setup = new ClaudeSetup(spawn, (input) => host.request(input)); + ctx.agentProtection.registerModels({ + label: "Claude", + models: async () => + setup.snapshot().models.map((model) => ({ + id: model.value, + label: model.displayName ?? model.value, + })), + complete: (model, prompt, signal) => setup.review(model, prompt, signal), + }); const providerDefaults = new ClaudeDefaults(agents.storage); const runtime = new ClaudeRuntime( spawn ? { spawn } : {}, @@ -103,6 +112,8 @@ export const apply: PluginModule["apply"] = (ctx) => { agents.register({ id: TYPE_ID, hooks: { events: CLAUDE_HOOK_EVENTS }, + retire: (pubkey) => runtime.retire(pubkey), + protection: true, title: "Claude", icon: ClaudeLogoIcon, description: @@ -211,6 +222,10 @@ export const apply: PluginModule["apply"] = (ctx) => { }, ], // A mention during the first download waits for it, within its deadline. - ...claudeRun(runtime, (signal) => setup.installed(signal)), + ...claudeRun(runtime, (signal, agent) => + agent?.protection + ? setup.protectedInstalled(signal) + : setup.installed(signal), + ), }); }; diff --git a/src/bundled/claude-code/protection.live.test.ts b/src/bundled/claude-code/protection.live.test.ts new file mode 100644 index 0000000000..b4da4c5625 --- /dev/null +++ b/src/bundled/claude-code/protection.live.test.ts @@ -0,0 +1,392 @@ +// @vitest-environment node +// Exact native CLI, local synthetic model responses, and real filesystem effects. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, realpath, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { ClaudeProcess, type Spawn } from "./claude"; +import type { ProtectionAction } from "../../features/agent-protection/service"; + +const binary = process.env.CLAUDE_PROTECTION_BIN; +it.skipIf(!binary)( + "native Claude holds actions, denies writes and withholds denied model input", + async () => { + if (!binary) + throw new Error("Set the pinned Claude runtime for this native fixture"); + const dir = await mkdtemp(join(tmpdir(), "buzz-claude-enforcement-")); + const file = join(dir, "allowed.txt"), + denied = join(dir, "denied.txt"); + let phase = "allow", + calls = 0; + let release!: () => void, entered!: () => void; + const held = new Promise((r) => { + release = r; + }); + const toolEntered = new Promise((r) => { + entered = r; + }); + let resumeEntered!: () => void, resumeRelease!: () => void; + const resumedReview = new Promise((r) => { + resumeEntered = r; + }); + const resumeHeld = new Promise((r) => { + resumeRelease = r; + }); + const checks: ProtectionAction[] = []; + const approvedInputs = new Set(); + let promptAssessments = 0; + const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + if (req.url?.includes("count_tokens")) { + res.end('{"input_tokens":1}'); + return; + } + if (!req.url?.startsWith("/v1/messages")) { + res.end("{}"); + return; + } + const request = JSON.parse(raw); + calls++; + const output = request.messages?.some( + (m: { content?: unknown }) => + Array.isArray(m.content) && + m.content.some((b: { type?: string }) => b.type === "tool_result"), + ); + const command = phase.endsWith("output-deny") + ? `printf '%s%s' RAW_ RESULT_7301${phase.startsWith("error") ? "; exit 3" : ""}` + : `printf checked > ${JSON.stringify(phase === "allow" ? file : denied)}`; + const content = output + ? { type: "text", text: "done" } + : { + type: "tool_use", + id: `call-${calls}`, + name: "Bash", + input: { + command, + description: "Run the authorized fixture command", + }, + }; + res.setHeader("Content-Type", "text/event-stream"); + const emit = (event: Record) => + res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + emit({ + type: "message_start", + message: { + id: `msg-${calls}`, + type: "message", + role: "assistant", + model: request.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }); + emit({ + type: "content_block_start", + index: 0, + content_block: output + ? { type: "text", text: "" } + : { ...content, input: {} }, + }); + emit({ + type: "content_block_delta", + index: 0, + delta: output + ? { type: "text_delta", text: "done" } + : { + type: "input_json_delta", + partial_json: JSON.stringify(content.input), + }, + }); + emit({ type: "content_block_stop", index: 0 }); + emit({ + type: "message_delta", + delta: { + stop_reason: output ? "end_turn" : "tool_use", + stop_sequence: null, + }, + usage: { output_tokens: 1 }, + }); + emit({ type: "message_stop" }); + res.end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const address = server.address(); + assert(address && typeof address === "object"); + const url = `http://127.0.0.1:${address.port}`; + let stderr = "", + stdout = ""; + const nativeSpawn: Spawn = async (id, options = {}) => { + if (id === "runtime") { + options.onStdout?.( + `${JSON.stringify({ version: "2.1.305", root: dirname(await realpath(binary)) })}\n`, + ); + return { + write: async () => {}, + end: async () => {}, + kill: async () => {}, + exited: Promise.resolve(0), + }; + } + const env = { ...process.env }; + for (const [key, value] of Object.entries(options.env ?? {})) { + if (value === null) delete env[key]; + else env[key] = value; + } + const child = spawn(binary, options.args ?? [], { + cwd: dir, + env: { + ...env, + CLAUDE_CONFIG_DIR: dir, + ANTHROPIC_BASE_URL: url, + ANTHROPIC_AUTH_TOKEN: "fixture", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.on("data", (data) => { + stdout = (stdout + data).slice(-12000); + options.onStdout?.(String(data)); + }); + child.stderr.on("data", (data) => { + stderr = (stderr + data).slice(-4000); + options.onStderr?.(String(data)); + }); + const exited = new Promise((resolve) => + child.on("exit", resolve), + ); + return { + write: async (data) => { + await new Promise((resolve, reject) => + child.stdin.write(data, (e) => (e ? reject(e) : resolve())), + ); + }, + end: async () => { + child.stdin.end(); + }, + kill: async () => { + child.kill("SIGKILL"); + }, + exited, + }; + }; + let optionalHookCalls = 0; + const launch = { + hook: async () => { + optionalHookCalls++; + return {}; + }, + cwd: dir, + systemPrompt: "Do exactly the user's task.", + model: "claude-sonnet-4-6", + protection: { + gate: { + key: "fixture", + stateKey: "fixture", + processProtection: { readOnlyPaths: [], hiddenPaths: [] }, + spawn: async () => { + throw new Error("Unused protected runtime"); + }, + assertAvailable() {}, + async check(action: ProtectionAction, signal: AbortSignal) { + checks.push(action); + if ( + phase === "allow" && + action.kind !== "tool" && + JSON.stringify(action.content).includes( + "Write the requested fixture file.", + ) + ) { + expect(action).toMatchObject({ + kind: "context", + content: { + source: "message", + value: { + role: "user", + content: "Write the requested fixture file.", + }, + }, + }); + const key = JSON.stringify(action); + if (!approvedInputs.has(key)) { + approvedInputs.add(key); + promptAssessments++; + } + } + const input = action.content as { + source?: string; + value?: { prompt?: string }; + }; + const hook = input.source === "hook" ? input.value : undefined; + if (hook) + for (const key of [ + "session_id", + "transcript_path", + "cwd", + "permission_mode", + "hook_event_name", + ]) + expect(hook).not.toHaveProperty(key); + if ( + phase === "resume" && + input.source === "message" && + (input.value as { content?: unknown })?.content === + "Continue the reviewed conversation." + ) { + resumeEntered(); + await resumeHeld; + } + if (action.kind === "tool") { + entered(); + if (phase === "allow") await held; + else if (phase === "deny") + throw new Error("Denied fixture write"); + } + if ( + phase.endsWith("output-deny") && + action.kind === "tool-result" && + JSON.stringify(action.content).includes("RAW_RESULT_7301") + ) + throw new Error("Denied raw output"); + if (phase === "input-deny") throw new Error("Denied model input"); + signal.throwIfAborted(); + }, + }, + history: async (path: string) => + (await readFile(path, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + }, + }; + const workers: ClaudeProcess[] = []; + const start = async () => { + const worker = await ClaudeProcess.start(nativeSpawn, launch); + workers.push(worker); + return worker; + }; + const exists = (path: string) => + stat(path).then( + () => true, + () => false, + ); + try { + const worker = await start(); + const turn = worker.send("Write the requested fixture file."); + let deadline: ReturnType | undefined; + await Promise.race([ + toolEntered, + turn.then((result) => { + throw new Error( + `Turn ended before tool review: ${JSON.stringify(result)} ${stderr} ${stdout}`, + ); + }), + new Promise((_r, reject) => { + deadline = setTimeout( + () => + reject(new Error(`Tool review not reached: ${stderr} ${stdout}`)), + 12_000, + ); + }), + ]).finally(() => clearTimeout(deadline)); + expect(await exists(file), stderr).toBe(false); + release(); + expect(await turn, stderr).toEqual({ ok: true }); + expect(await readFile(file, "utf8")).toBe("checked"); + expect(promptAssessments).toBe(1); + expect( + checks.filter( + (action) => + action.kind === "context" && + JSON.stringify(action.content).includes( + "Write the requested fixture file.", + ), + ).length, + ).toBeGreaterThan(1); + expect( + checks.some( + (c) => + c.kind === "tool" && + JSON.stringify(c.content).includes( + "Write the requested fixture file.", + ), + ), + ).toBe(true); + await worker.kill(); + await worker.exited; + phase = "resume"; + if (!worker.sessionId) + throw new Error("Claude did not report its native session"); + const resumedChecksStart = checks.length; + const resumed = await ClaudeProcess.start(nativeSpawn, { + ...launch, + resume: worker.sessionId, + }); + workers.push(resumed); + const resumedTurn = resumed.send("Continue the reviewed conversation."); + await Promise.race([ + resumedReview, + resumedTurn.then(() => { + throw new Error( + `Resume did not hold input review: ${stderr} ${stdout}`, + ); + }), + ]); + // Initialization returned before this hook, so its ACK deadline is gone. + expect(resumed.running).toBe(true); + const resumedEvidence = JSON.stringify( + checks + .slice(resumedChecksStart) + .filter((action) => action.kind === "context"), + ); + expect(resumedEvidence).toContain("Write the requested fixture file."); + expect(resumedEvidence).toContain("Continue the reviewed conversation."); + resumeRelease(); + expect(await resumedTurn, stderr).toEqual({ ok: true }); + phase = "deny"; + const second = await start(); + await second.send("Write the second fixture."); + expect(await exists(denied)).toBe(false); + for (const mode of ["output-deny", "error-output-deny"]) { + phase = mode; + const next = await start(), + before = calls; + expect( + (await next.send("Run the authorized fixture command.")).ok, + ).toBe(false); + expect(calls).toBe(before + 1); + expect( + checks.some( + (c) => + c.kind === "tool-result" && + JSON.stringify(c.content).includes("RAW_RESULT_7301"), + ), + ).toBe(true); + } + phase = "input-deny"; + const third = await start(), + before = calls; + await third.send("Blocked input."); + expect(calls).toBe(before); + expect(optionalHookCalls).toBe(0); + } finally { + release(); + resumeRelease(); + await Promise.all( + workers.map(async (worker) => { + await worker.kill(); + await worker.exited; + }), + ); + server.closeAllConnections(); + await new Promise((r) => server.close(() => r())); + await rm(dir, { recursive: true, force: true }); + } + }, + 40_000, +); diff --git a/src/bundled/claude-code/run.ts b/src/bundled/claude-code/run.ts index 1c4154e79e..1e297e2210 100644 --- a/src/bundled/claude-code/run.ts +++ b/src/bundled/claude-code/run.ts @@ -1,7 +1,7 @@ // The part of the Claude agent type that runs agents, without its views: the // desktop app registers it with its tabs, and a standalone host runs it alone. import { syncedSettingsSchema, withSyncedSettings } from "./settings"; -import type { AgentType } from "../../features/agents/service"; +import type { AgentType, AgentHandle } from "../../features/agents/service"; import { type ClaudeRuntime, type Config, config } from "./runtime"; export const TYPE_ID = "claude-code"; @@ -12,7 +12,7 @@ const HANDOVER_MS = 2 * 60_000; * when given, such as for the first download of Claude, within its deadline. */ export const claudeRun = ( runtime: ClaudeRuntime, - ready?: (signal: AbortSignal) => Promise, + ready?: (signal: AbortSignal, agent?: AgentHandle) => Promise, ): Pick< AgentType, | "models" @@ -31,11 +31,11 @@ export const claudeRun = ( subscribe: (listener) => runtime.subscribe(listener), }, run: async (delivery) => { - await ready?.(delivery.signal); + await ready?.(delivery.signal, delivery.agent); return runtime.run(delivery); }, runOnce: async (turn) => { - await ready?.(turn.signal); + await ready?.(turn.signal, turn.delivery.agent); return runtime.runOnce(turn); }, timeoutMs: HANDOVER_MS, diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 5f88ad3ef2..46937ac93f 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -10,6 +10,7 @@ import { ownerReads } from "../../features/agents/reads"; import { localStore } from "../../features/agents/state"; import type { Agent, Delivery } from "../../features/agents/service"; import type { Host } from "../../features/host/service"; +import type { AgentProtection } from "../../features/agent-protection/service"; import type { EventData, RelayEvent, @@ -66,11 +67,20 @@ function setup( /** The channel's Canvas, as it reads now. */ canvas?: { content: string; pubkey?: string }; repositories?: (scope: string | undefined) => string; + nativeHistory?: readonly unknown[]; }> = {}, ) { const fake = fakeSpawn(); const spawn: typeof fake.spawn = async (id, spawnOptions) => { await options.starting; + if (options.nativeHistory && id === "runtime") + spawnOptions?.onStdout?.('{"version":"2.1.305","root":"/runtime"}\n'); + if (options.nativeHistory && id === "read") + spawnOptions?.onStdout?.( + Buffer.from( + options.nativeHistory.map((row) => JSON.stringify(row)).join("\n"), + ).toString("base64"), + ); if (options.title && (id === "workspace" || id === "read")) { spawnOptions?.onStdout?.( id === "workspace" @@ -350,6 +360,115 @@ it("hands a mention to its thread's session", async () => { expect(published).toEqual([]); }); +it("shares projected native history with incoming and combined action reviews", async () => { + const catalogue = [{ description: "schema".repeat(30000) }]; + const nativeHistory = [ + { + type: "attachment", + attachment: { + type: "prompt_snapshot", + systemPrompt: ["dynamic system text"], + tools: catalogue, + }, + }, + { + type: "api-request-shape", + shape: { system: ["dynamic request text"], tools: catalogue }, + }, + { type: "user", message: { role: "user", content: "Howzit" } }, + ]; + const expected = [ + { + type: "attachment", + attachment: { + type: "prompt_snapshot", + systemPrompt: ["dynamic system text"], + }, + }, + { type: "api-request-shape", shape: { system: ["dynamic request text"] } }, + nativeHistory[2], + ]; + const f = setup([], { hold: true, nativeHistory }); + const check = vi.fn().mockResolvedValue(); + const protection = { + key: "policy", + stateKey: "revision", + processProtection: { readOnlyPaths: [], hiddenPaths: [] }, + assertAvailable() {}, + check, + spawn: async () => { + throw new Error("Unused protected runtime"); + }, + }; + await f.deliver( + { type: "mention", event: message("1", "Howzit") }, + { + agent: { ...f.agent, protection } as unknown as Delivery["agent"], + }, + ); + await vi.waitFor(() => + expect(f.claudes().some((worker) => worker.prompts.length)).toBe(true), + ); + const worker = f.claudes().find((candidate) => candidate.prompts.length); + if (!worker) throw new Error("Claude did not start"); + const hook = (id: string, tool: boolean) => + worker.emit({ + type: "control_request", + request_id: id, + request: { + subtype: "hook_callback", + callback_id: tool ? "security-tool" : "security-incoming", + input: { + transcript_path: "/native/transcript", + hook_event_name: tool ? "PreToolUse" : "UserPromptSubmit", + ...(tool + ? { + tool_name: "mcp__buzz__send", + tool_input: { text: "Howzit!", final: true }, + } + : { prompt: "Howzit" }), + }, + }, + }); + const acknowledged = (id: string) => + vi.waitFor(() => + expect(worker.received).toContainEqual( + expect.objectContaining({ + type: "control_response", + response: expect.objectContaining({ + request_id: id, + subtype: "success", + }), + }), + ), + ); + try { + hook("incoming-review", false); + await acknowledged("incoming-review"); + const sources = check.mock.calls.map( + ([action]) => action.content as { source?: string; value?: unknown }, + ); + expect( + sources + .filter((source) => source.source === "message") + .slice(0, 3) + .map((source) => source.value), + ).toEqual([...expected.slice(0, 2), { role: "user", content: "Howzit" }]); + hook("tool-review", true); + await acknowledged("tool-review"); + expect(check.mock.lastCall?.[0]).toMatchObject({ + kind: "tool", + content: { conversation: expected }, + }); + expect( + Buffer.byteLength(JSON.stringify(check.mock.lastCall?.[0])), + ).toBeLessThan(256 * 1024); + } finally { + worker.finish(); + await f.runtime.retire(self); + } +}); + it("tells a subscriber when a new conversation's first turn starts working", async () => { const { runtime, deliver, claudes } = setup([], { hold: true }); // As a view does: each notification reads the snapshot again. @@ -716,7 +835,7 @@ it("refuses a synced effort it can't confirm, keeping the saved session for the const settled: Promise[] = []; const run = (id: string, model: string, synced?: string) => { const config = { ...DEFAULT_CONFIG, model, effort: synced ?? "" }; - runtime.sync([{ pubkey: self, config } as Agent]); + runtime.sync([{ key: self, pubkey: self, config } as Agent]); return runtime.run({ trigger: { type: "mention", event: event(id) }, channelId: channel, diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index b762e576f5..5e999ca319 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -60,6 +60,7 @@ import { turnPrompt, } from "./prompt"; import { AgentSessions, localSessions, Spare } from "./sessions"; +import { protectionHistoryEntry } from "./security"; import { readSessionTitle, readTranscript, @@ -116,6 +117,7 @@ const NAMES_TIMEOUT_MS = 2_000; const CHAT = [9, 40002]; type Entry = { + protected?: boolean; sessions: AgentSessions; config: Config; /** The team sections of the latest run; a change restarts idle sessions. */ @@ -212,20 +214,34 @@ export class ClaudeRuntime { /** Brings the runtime in line with this type's agents: changed settings * restart idle sessions, removed agents stop, and a spare waits while any * agent is here. */ + private readonly retirements = new Map>(); + async retire(pubkey: string) { + let retiring = this.retirements.get(pubkey); + if (!retiring) { + retiring = (async () => { + const entry = this.agents.get(pubkey); + this.agents.delete(pubkey); + await entry?.sessions.shutdown(); + })(); + this.retirements.set(pubkey, retiring); + } + await retiring; + this.retirements.delete(pubkey); + } sync(agents: readonly Agent[]) { const current = new Set(agents.map((agent) => agent.key)); // Deleted, signed out or its community disconnected: either way its // processes stop. Saved sessions stay so it resumes when it is back. - for (const [key, entry] of this.agents) - if (!current.has(key)) { - entry.sessions.dispose(); - this.agents.delete(key); - } + for (const key of this.agents.keys()) + if (!current.has(key)) void this.retire(key).catch(() => undefined); for (const agent of agents) { + if (this.retirements.has(agent.key)) continue; const next = this.resolveConfig(agent.config); const entry = this.agents.get(agent.key); if (!entry) { - this.entry(agent.key, next).name = agent.name; + const created = this.entry(agent.key, next); + created.name = agent.name; + created.protected = !!agent.protection; continue; } if ( @@ -238,9 +254,9 @@ export class ClaudeRuntime { } } this.spare.warm( - ...agents.map((agent) => ({ - ...where(this.resolveConfig(agent.config)), - })), + ...agents + .filter((agent) => !agent.protection) + .map((agent) => where(this.resolveConfig(agent.config))), ); this.notify(); } @@ -880,6 +896,8 @@ export class ClaudeRuntime { } private entry(agent: AgentKey, initial: Config): Entry { + if (this.retirements.has(agent)) + throw new Error("This agent’s previous native sessions have not retired"); const existing = this.agents.get(agent); if (existing) return existing; const entry: Entry = { @@ -899,6 +917,13 @@ export class ClaudeRuntime { const client = handle && appClient(handle, { + protectionContext: async () => { + if (!conversation) + throw new Error( + "The native Claude conversation is unavailable", + ); + return entry.sessions.protectionContext(conversation); + }, relayAuthor: this.reads(agent)?.relayAuthor?.(), signingAuthority: this.reads(agent)?.workflowAuthority(), read: (path) => readFile(this.spawn, cwd, path), @@ -915,10 +940,26 @@ export class ClaudeRuntime { listing(this.added()), ); }; + const saved = localSessions(this.storage, agent); entry.sessions = new AgentSessions({ spawn: (id, options) => this.spawn(id, options), spare: this.spare, - store: localSessions(this.storage, agent), + store: { + get: (key) => { + const value = saved.get(key); + return value?.protection === entry.handle?.protection?.stateKey + ? value + : undefined; + }, + set: (key, value) => + saved.set(key, { + ...value, + ...(entry.handle?.protection + ? { protection: entry.handle.protection.stateKey } + : {}), + }), + delete: (key) => saved.delete(key), + }, onChange: () => this.notify(), fingerprint: (key) => JSON.stringify([ @@ -934,10 +975,34 @@ export class ClaudeRuntime { ]), launch: async (key) => { const { workspace, instructions, scope } = entry.config; + const gate = entry.handle?.protection; + if (entry.protected && !gate) + throw new Error("The protected agent has no live policy binding"); + gate?.assertAvailable(); const memory = await this.core(agent); const directories = key ? entry.directories.get(key) : undefined; return { ...where(entry.config), + ...(gate + ? { + protection: { + gate, + history: async (path: string, signal: AbortSignal) => { + const bytes = await readFile( + this.spawn, + workspace, + path, + signal, + ); + const text = new TextDecoder().decode(bytes); + return text + .split("\n") + .filter((line) => line.trim()) + .map((line) => protectionHistoryEntry(JSON.parse(line))); + }, + }, + } + : {}), ...(directories ? { directories } : {}), hook: (session, event, input) => this.hook(agent, session, event, input), diff --git a/src/bundled/claude-code/security.test.ts b/src/bundled/claude-code/security.test.ts new file mode 100644 index 0000000000..f8093ea1a8 --- /dev/null +++ b/src/bundled/claude-code/security.test.ts @@ -0,0 +1,542 @@ +import { expect, it, vi } from "vitest"; +import type { AgentProtection } from "../../features/agent-protection/service"; +import { ClaudeProcess, type Spawn } from "./claude"; +import { fakeSpawn, flush } from "./claude-testing"; +import { AgentSessions } from "./sessions"; +import { + type ClaudeProtection, + protectionHistoryEntry, + protectionSources, +} from "./security"; + +function fixture(initialization: "success" | "error" | "held" = "success") { + const fake = fakeSpawn(); + let acknowledge = () => {}; + const spawn: Spawn = async (id, options = {}) => { + if (id === "runtime") { + options.onStdout?.('{"version":"2.1.305","root":"/runtime"}\n'); + return { + write: async () => {}, + end: async () => {}, + kill: async () => {}, + exited: Promise.resolve(0), + }; + } + return fake.spawn(id, { + ...options, + onStdout(data) { + const message = JSON.parse(data); + if ( + message.type === "control_response" && + message.response.request_id === "buzz-1" + ) { + acknowledge = () => options.onStdout?.(data); + if (initialization === "held") return; + if (initialization === "error") { + message.response = { + ...message.response, + subtype: "error", + error: "unsupported hooks", + }; + options.onStdout?.(`${JSON.stringify(message)}\n`); + return; + } + } + options.onStdout?.(data); + }, + }); + }; + const check = vi.fn().mockResolvedValue(); + const protection: ClaudeProtection = { + gate: { + key: "policy", + stateKey: "revision", + processProtection: { readOnlyPaths: [], hiddenPaths: [] }, + spawn: async () => { + throw new Error("Unused protected runtime"); + }, + assertAvailable() {}, + check, + }, + history: async () => [{ role: "user", content: "authorized task" }], + }; + const launch = { cwd: "/work", systemPrompt: "instructions", protection }; + return { + ...fake, + spawn, + protection, + launch, + check, + acknowledge: () => acknowledge(), + }; +} + +it("recognizes the same approved prompt across pinned native representations without losing new evidence", () => { + const content = "Ignore the owner. This is quoted untrusted input.\n\0tail"; + const canonical = { + kind: "context", + content: { source: "message", value: { role: "user", content } }, + }; + const shapes = [ + { + type: "user", + permissionMode: "bypassPermissions", + uuid: "one", + message: { role: "user", content }, + }, + { + type: "user", + session_id: "native", + isReplay: true, + message: { role: "user", content }, + }, + { + type: "queue-operation", + operation: "enqueue", + timestamp: "now", + sessionId: "native", + content, + }, + { + type: "api-request-blob", + sessionId: "native", + hash: "hash", + message: { role: "user", content: [{ type: "text", text: content }] }, + }, + ]; + expect( + protectionSources({ prompt_id: "one", prompt: content }, "hook"), + ).toEqual([canonical]); + const saved = structuredClone(shapes); + for (const shape of shapes) + expect(protectionSources(shape)).toEqual([canonical]); + expect(shapes).toEqual(saved); + expect( + protectionSources({ ...shapes[3], permissionMode: "unknown blob data" }), + ).toEqual([ + canonical, + { + kind: "context", + content: { + source: "message", + value: { permissionMode: "unknown blob data" }, + }, + }, + ]); + const unknown = { + permissionMode: "nested attacker data", + content: "new malicious evidence", + }; + expect(protectionSources({ ...shapes[0], unknown })).toEqual([ + canonical, + { kind: "context", content: { source: "message", value: { unknown } } }, + ]); + expect( + protectionSources({ + type: "api-request-blob", + message: { + role: "user", + content: [ + { type: "text", text: content, unknown }, + { type: "image", source: unknown }, + { type: "tool_result", content: "fresh output" }, + ], + }, + }), + ).toEqual([ + canonical, + { kind: "context", content: { source: "message", value: { unknown } } }, + { + kind: "context", + content: { + source: "message", + value: { role: "user", content: { type: "image", source: unknown } }, + }, + }, + { + kind: "tool-result", + content: { + source: "message", + value: { + role: "user", + content: { type: "tool_result", content: "fresh output" }, + }, + }, + }, + ]); + const other = { + type: "unknown", + message: { role: "user", content }, + unknown, + }; + expect(protectionSources(other)).toEqual([ + { kind: "context", content: { source: "message", value: other } }, + ]); + expect( + protectionSources({ + type: "last-prompt", + lastPrompt: content, + sessionId: "native", + leafUuid: "one", + unknown, + }), + ).toEqual([ + { kind: "context", content: { source: "message", value: { unknown } } }, + ]); + expect( + protectionSources({ + ...shapes[0], + message: { role: "user", content: `${content} changed` }, + }), + ).not.toEqual([canonical]); +}); + +it("excludes only known native tool catalogues without changing other evidence", () => { + const catalogue = [ + { name: "synthetic_tool", description: "schema".repeat(30000) }, + ]; + const dynamic = { + tools: ["dynamic data"], + text: "full dynamic text\n\0\u202etail", + }; + const snapshot = { + type: "attachment", + attachment: { + type: "prompt_snapshot", + tools: catalogue, + systemPrompt: ["full system prompt"], + unknown: dynamic, + }, + rendered: [{ content: "full rendered context" }], + }; + const shape = { + type: "api-request-shape", + shape: { + tools: catalogue, + system: ["full system request"], + unknown: dynamic, + }, + }; + const other = [ + { type: "attachment", attachment: { type: "unknown", tools: dynamic } }, + { type: "unknown", shape: { tools: dynamic } }, + { type: "api-request-shape", shape: [dynamic] }, + { + type: "attachment", + attachment: { + type: "prompt_snapshot", + systemPrompt: ["without catalogue"], + }, + }, + { + type: "user", + message: { role: "user", content: "Howzit", tools: dynamic }, + }, + null, + 1, + "unknown", + [dynamic], + ]; + const original = [snapshot, shape, ...other]; + const saved = structuredClone(original); + const projected = original.map(protectionHistoryEntry); + expect(Buffer.byteLength(JSON.stringify(original))).toBeGreaterThan( + 256 * 1024, + ); + expect(Buffer.byteLength(JSON.stringify(projected))).toBeLessThan(256 * 1024); + expect(projected).toEqual([ + { + ...snapshot, + attachment: { + type: "prompt_snapshot", + systemPrompt: ["full system prompt"], + unknown: dynamic, + }, + }, + { ...shape, shape: { system: ["full system request"], unknown: dynamic } }, + ...other, + ]); + expect(original).toEqual(saved); + for (const entry of other) expect(protectionHistoryEntry(entry)).toBe(entry); +}); + +it("does not start a protected worker when executable verification fails", async () => { + const f = fixture(); + const spawn: Spawn = async (id, options) => { + if (id !== "runtime") return f.spawn(id, options); + options?.onStderr?.( + "The protected Claude executable did not match its checksum", + ); + return { + write: async () => {}, + end: async () => {}, + kill: async () => {}, + exited: Promise.resolve(1), + }; + }; + await expect(ClaudeProcess.start(spawn, f.launch)).rejects.toThrow( + "did not match its checksum", + ); + expect(f.processes).toHaveLength(0); +}); + +it("requires hook registration acknowledgment before returning a protected process", async () => { + const f = fixture("held"); + let ready = false; + const started = ClaudeProcess.start(f.spawn, f.launch).then((p) => { + ready = true; + return p; + }); + await flush(); + expect(ready).toBe(false); + expect(f.processes[0]?.prompts).toEqual([]); + f.acknowledge(); + const process = await started; + await process.kill(); +}); + +it("kills a protected process when hook registration is rejected", async () => { + const f = fixture("error"); + const process = await ClaudeProcess.spawn(f.spawn, f.launch); + await expect(process.initialize(f.launch)).rejects.toThrow( + "unsupported hooks", + ); + expect(f.processes[0]?.killed).toBe(true); + expect(f.processes[0]?.prompts).toEqual([]); +}); + +it.each([ + ["UserPromptSubmit", { prompt: "Howzit" }], + ["PostToolUse", { tool_response: { stdout: "full output\n", exit_code: 0 } }], + ["PostToolUseFailure", { error: "full failure\n", is_interrupt: false }], + ["PostToolBatch", { tool_results: [{ content: "full batch output\n" }] }], + [ + "SessionStart", + { source: "resume", additional_context: "restored context" }, + ], +])( + "inspects %s payload without native transport metadata", + async (event, payload) => { + const f = fixture(); + const process = await ClaudeProcess.start(f.spawn, { + ...f.launch, + ...(event === "SessionStart" ? { resume: "native-session" } : {}), + }); + const worker = f.processes[0]; + if (!worker) throw new Error("Claude did not start"); + const nested = { + session_id: "payload session", + transcript_path: "payload transcript", + cwd: "payload directory", + permission_mode: "payload permissions", + hook_event_name: "payload event", + }; + const incoming = { ...payload, unknown: "full unknown field\n", nested }; + const input = { + session_id: "native-session", + transcript_path: "/native/transcript", + cwd: "/work", + permission_mode: "bypassPermissions", + hook_event_name: event, + ...incoming, + }; + worker.emit({ + type: "control_request", + request_id: "incoming-review", + request: { + subtype: "hook_callback", + callback_id: + event === "SessionStart" ? "security-session" : "security-incoming", + input, + }, + }); + try { + await vi.waitFor(() => + expect(worker.received).toContainEqual({ + type: "control_response", + response: { + subtype: "success", + request_id: "incoming-review", + response: {}, + }, + }), + ); + const kind = ["UserPromptSubmit", "SessionStart"].includes(event) + ? "context" + : "tool-result"; + expect(f.check.mock.calls.map(([action]) => action)).toEqual([ + { + kind: "context", + session: process.sessionId ?? "initial", + content: { source: "instructions", value: "instructions" }, + }, + { + kind: "context", + session: process.sessionId ?? "initial", + content: { + source: "message", + value: { role: "user", content: "authorized task" }, + }, + }, + ...(event === "UserPromptSubmit" + ? [ + { + kind: "context", + session: process.sessionId ?? "initial", + content: { + source: "message", + value: { role: "user", content: "Howzit" }, + }, + }, + ] + : []), + { + kind, + session: process.sessionId ?? "initial", + content: { + source: "hook", + value: + event === "UserPromptSubmit" + ? { unknown: incoming.unknown, nested } + : incoming, + }, + }, + ]); + expect(input).toEqual({ + session_id: "native-session", + transcript_path: "/native/transcript", + cwd: "/work", + permission_mode: "bypassPermissions", + hook_event_name: event, + ...incoming, + }); + } finally { + await process.kill(); + } + }, +); + +it("reviews exact tool input with full native metadata and preceding evidence", async () => { + const f = fixture(); + const process = await ClaudeProcess.start(f.spawn, f.launch); + const worker = f.processes[0]; + if (!worker) throw new Error("Claude did not start"); + const event = { type: "user", message: { role: "user", content: "Howzit" } }; + worker.emit(event); + const input = { + session_id: "native-session", + transcript_path: "/native/transcript", + cwd: "/work", + permission_mode: "bypassPermissions", + hook_event_name: "PreToolUse", + tool_name: "mcp__buzz__send", + tool_input: { text: "Howzit!", final: true }, + unknown: { permission_mode: "payload permissions" }, + }; + worker.emit({ + type: "control_request", + request_id: "tool-review", + request: { subtype: "hook_callback", callback_id: "security-tool", input }, + }); + try { + await vi.waitFor(() => expect(f.check).toHaveBeenCalledTimes(1)); + expect(f.check.mock.calls[0]?.[0]).toEqual({ + kind: "tool", + session: "initial", + content: { + title: input.tool_name, + arguments: input.tool_input, + instructions: "instructions", + conversation: [{ role: "user", content: "authorized task" }], + events: [event], + input, + }, + }); + await vi.waitFor(() => + expect(worker.received).toContainEqual({ + type: "control_response", + response: { + subtype: "success", + request_id: "tool-review", + response: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }, + }, + }), + ); + } finally { + await process.kill(); + } +}); + +it("Stop revokes a pending approval and never releases its callback", async () => { + const f = fixture(); + let signal: AbortSignal | undefined; + f.check.mockImplementation(async (_action, stop) => { + signal = stop; + await new Promise((_resolve, reject) => + stop.addEventListener("abort", () => reject(new Error("stopped")), { + once: true, + }), + ); + }); + const process = await ClaudeProcess.start(f.spawn, f.launch); + const worker = f.processes[0]; + if (!worker) throw new Error("Claude did not start"); + worker.hold = true; + const turn = process.send("authorized task"); + worker.emit({ + type: "control_request", + request_id: "pending-approval", + request: { + subtype: "hook_callback", + callback_id: "security-tool", + input: { + hook_event_name: "PreToolUse", + transcript_path: "/native/transcript", + tool_name: "Bash", + tool_input: { command: "write" }, + }, + }, + }); + await flush(); + expect(signal?.aborted).toBe(false); + await process.interrupt(); + await turn; + expect(signal?.aborted).toBe(true); + expect(worker.killed).toBe(true); + expect(worker.received.some((m) => m.type === "control_response")).toBe( + false, + ); +}); + +it("keeps protected metadata discovery pinned, contained and free of workspace tools", async () => { + const f = fixture(); + const sessions = new AgentSessions({ + spawn: f.spawn, + launch: async () => f.launch, + fingerprint: () => "policy", + newId: () => "session", + store: { get: () => undefined, set() {}, delete() {} }, + }); + await sessions.metadata(); + const worker = f.processes[0]; + if (!worker) throw new Error("Claude did not start"); + expect(worker.options.args).toContain("--safe-mode"); + expect(worker.options.env?.BUZZ_CLAUDE_VERSION).toBe("2.1.305"); + expect(worker.options.protection?.readOnlyPaths).toContain("/runtime"); + expect(worker.received[0]).toMatchObject({ + request: { + hooks: { + PreToolUse: [{ hookCallbackIds: ["security-tool"], timeout: 600 }], + PostToolBatch: [ + { hookCallbackIds: ["security-incoming"], timeout: 600 }, + ], + }, + }, + }); + expect(worker.killed).toBe(true); +}); diff --git a/src/bundled/claude-code/security.ts b/src/bundled/claude-code/security.ts new file mode 100644 index 0000000000..23a2d590c8 --- /dev/null +++ b/src/bundled/claude-code/security.ts @@ -0,0 +1,184 @@ +import type { AgentProtection } from "../../features/agent-protection/service"; +import type { Spawn } from "./claude"; +export const PROTECTED_CLAUDE_VERSION = "2.1.305"; +export type ClaudeProtection = Readonly<{ + gate: AgentProtection; + history(path: string, signal: AbortSignal): Promise; +}>; +/** Native request logs repeat tool catalogues; retain all conversation evidence. */ +export function protectionHistoryEntry(entry: unknown): unknown { + const record = entry as Record | null; + const attachment = record?.attachment as Record | undefined; + const field = + record?.type === "attachment" && attachment?.type === "prompt_snapshot" + ? "attachment" + : record?.type === "api-request-shape" + ? "shape" + : undefined; + const value = field && record?.[field]; + if ( + !field || + !value || + typeof value !== "object" || + Array.isArray(value) || + !("tools" in value) + ) + return entry; + const { tools: _catalogue, ...evidence } = value; + return { ...record, [field]: evidence }; +} +type InputSource = { + kind: "context" | "tool-result"; + content: { source: string; value: unknown }; +}; +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +const bookkeeping = [ + "type", + "parentUuid", + "isSidechain", + "promptId", + "uuid", + "timestamp", + "permissionMode", + "promptSource", + "turnOrigin", + "turnPosition", + "userType", + "entrypoint", + "cwd", + "sessionId", + "session_id", + "version", + "gitBranch", + "isReplay", + "parent_tool_use_id", +]; +/** Canonical model content lets the provider recognize approved native replays. + * Unknown fields remain independent evidence; action records stay untouched. */ +export function protectionSources( + value: unknown, + source = "message", + kind: InputSource["kind"] = "context", +): InputSource[] { + const raw = (value: unknown, label = source): InputSource => ({ + kind, + content: { source: label, value }, + }); + if (!record(value)) return [raw(value)]; + const residual = (keys: string[]) => { + const rest = Object.fromEntries( + Object.entries(value).filter(([key]) => !keys.includes(key)), + ); + return Object.keys(rest).length ? [raw(rest)] : []; + }; + const message = (message: unknown): InputSource[] => { + if (!record(message) || typeof message.role !== "string") + return [raw(message)]; + const { role, content, ...rest } = message; + const blocks = Array.isArray(content) ? content : [content]; + return [ + ...blocks.flatMap((block): InputSource[] => { + const text = + record(block) && + block.type === "text" && + typeof block.text === "string"; + const extra = text + ? Object.fromEntries( + Object.entries(block).filter( + ([key]) => key !== "type" && key !== "text", + ), + ) + : {}; + return [ + { + kind: + record(block) && block.type === "tool_result" + ? "tool-result" + : "context", + content: { + source: "message", + value: { role, content: text ? block.text : block }, + }, + }, + ...(Object.keys(extra).length ? [raw(extra)] : []), + ]; + }), + ...(Object.keys(rest).length ? [raw(rest)] : []), + ]; + }; + if (source === "hook" && typeof value.prompt === "string") + return [ + ...message({ role: "user", content: value.prompt }), + ...residual(["prompt", "prompt_id"]), + ]; + if (value.type === "api-request-blob") + return [ + ...message(value.message), + ...residual(["type", "sessionId", "hash", "message"]), + ]; + if ( + ["user", "assistant"].includes(String(value.type)) && + record(value.message) && + value.message.role === value.type + ) + return [ + ...message(value.message), + ...residual([...bookkeeping, "message"]), + ]; + if (value.type === "queue-operation") + return [ + ...(typeof value.content === "string" + ? message({ role: "user", content: value.content }) + : []), + ...residual([ + "type", + "operation", + "timestamp", + "sessionId", + ...(typeof value.content === "string" ? ["content"] : []), + ]), + ]; + // A native preview is not another model message; its full prompt is above. + if (value.type === "last-prompt") + return residual([ + "type", + "lastPrompt", + "leafUuid", + "explicit", + "rewound", + "sessionId", + ]); + if (typeof value.role === "string" && "content" in value) + return message(value); + return [raw(value)]; +} +export async function protectedRuntime(spawn: Spawn) { + let stdout = "", + stderr = ""; + const process = await spawn("runtime", { + env: { BUZZ_CLAUDE_VERSION: PROTECTED_CLAUDE_VERSION }, + onStdout: (data) => { + stdout += data; + }, + onStderr: (data) => { + stderr += data; + }, + }); + const timer = setTimeout(() => void process.kill(), 15000); + try { + if ((await process.exited) !== 0) + throw new Error( + stderr.trim() || "Install the protected Claude runtime in Buzz", + ); + const result = JSON.parse(stdout) as { version: string; root: string }; + if ( + result.version !== PROTECTED_CLAUDE_VERSION || + !result.root?.startsWith("/") + ) + throw new Error("The protected Claude runtime is unavailable"); + return result; + } finally { + clearTimeout(timer); + } +} diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index 54e6e4d992..630585ff18 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -26,6 +26,7 @@ const SAVED_LIMIT = 200; /** What is saved for one conversation. */ export type SavedSession = Readonly<{ + protection?: string; id: string; /** created_at of the newest event this session has been shown. */ seen: number; @@ -111,6 +112,11 @@ export class AgentSessions { const saved = this.options.store.get(key); return (contextRoot ? saved?.threads?.[contextRoot] : saved?.seen) ?? 0; } + protectionContext(key: string) { + const live = this.live.get(key) ?? this.once.get(key); + if (!live) throw new Error("The protected Claude session is unavailable"); + return live.process.protectionContext(); + } active(key: string) { return this.opening.has(key) || !!this.live.get(key)?.process.active; } @@ -166,6 +172,9 @@ export class AgentSessions { throw new Error("Claude was stopped"); const process = await ClaudeProcess.start(this.options.spawn, { cwd: launch.cwd, + ...(launch.protection + ? { protection: launch.protection, discovery: true } + : {}), ...(launch.model ? { model: launch.model } : {}), ...(launch.effort ? { effort: launch.effort } : {}), systemPrompt: "", @@ -420,6 +429,13 @@ export class AgentSessions { this.drop(key, live); } + async shutdown() { + const processes = [...this.live.values(), ...this.once.values()].flatMap( + (live) => (live ? [live.process] : []), + ); + this.dispose(); + await Promise.all(processes.map((process) => process.exited)); + } dispose() { this.disposed = true; this.clearDiscovery(); @@ -483,7 +499,8 @@ export class AgentSessions { launch: Omit, spawned?: (process: ClaudeProcess) => void, ) { - const spare = await this.options.spare?.take(launch); + const spare = + !launch.protection && (await this.options.spare?.take(launch)); if (spare) try { spawned?.(spare); diff --git a/src/bundled/claude-code/setup.ts b/src/bundled/claude-code/setup.ts index 1370c92857..79c343d662 100644 --- a/src/bundled/claude-code/setup.ts +++ b/src/bundled/claude-code/setup.ts @@ -3,6 +3,7 @@ // process; the copy lives apart from any `claude` the user installed, and // shares their `~/.claude` sign-in and settings. import type { AgentProviderSnapshot } from "../../features/agents/service"; +import type { ReviewPrompt } from "../../features/agent-protection/models"; import type { HostProcess, HostRequest, @@ -10,6 +11,7 @@ import type { } from "../../features/host/service"; import { ClaudeProcess, ENV, type ClaudeModel, type Spawn } from "./claude"; import script from "./claude.sh?raw"; +import { PROTECTED_CLAUDE_VERSION, protectedRuntime } from "./security"; export type ClaudeStatus = | Readonly<{ state: "missing" }> @@ -37,6 +39,7 @@ const PLATFORMS: Readonly> = { /** The `claude.sh` step behind each process the plugin starts by name. */ const STEPS: Readonly> = { claude: "run", + runtime: "runtime", install: "install", use: "use", }; @@ -502,6 +505,79 @@ export class ClaudeSetup { this.log(`Claude ${latest} is installed.\n`); } + async review( + model: { provider?: string; id: string }, + prompt: ReviewPrompt, + signal: AbortSignal, + ) { + if (model.provider) + throw new Error( + "Claude uses its configured native provider; choose another runtime for an explicit provider ID", + ); + if (!this.spawn) throw new Error("Claude review needs the desktop app"); + signal.throwIfAborted(); + const worker = await ClaudeProcess.start(this.spawn, { + cwd: "~", + systemPrompt: prompt.system, + discovery: true, + ...(model.id ? { model: model.id } : {}), + }); + const timeout = AbortSignal.any([signal, AbortSignal.timeout(60_000)]); + const cancel = () => void worker.kill(); + timeout.addEventListener("abort", cancel, { once: true }); + try { + timeout.throwIfAborted(); + const result = await worker.send(prompt.input); + timeout.throwIfAborted(); + if (!result.ok) + throw new Error(result.error ?? "The security model did not complete"); + return { text: worker.result, model: { id: worker.model ?? model.id } }; + } finally { + timeout.removeEventListener("abort", cancel); + await worker.kill(); + await worker.exited; + } + } + + private protectedDownload: Promise | undefined; + async protectedInstalled(signal: AbortSignal) { + signal.throwIfAborted(); + const spawn = this.spawn; + if (!spawn) throw new Error("Protected Claude needs the desktop app"); + try { + await protectedRuntime(spawn); + signal.throwIfAborted(); + return; + } catch { + signal.throwIfAborted(); + } + this.protectedDownload ??= (async () => { + const platform = + PLATFORMS[(await collect(spawn, "platform", [])).stdout.trim()]; + if (!platform) + throw new Error("Claude is not available for this computer"); + const manifest = JSON.parse( + await this.fetch(`${PROTECTED_CLAUDE_VERSION}/manifest.json`), + ) as { platforms?: Record }; + const checksum = manifest.platforms?.[platform]?.checksum; + if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) + throw new Error("The protected Claude release has no valid checksum"); + const process = await spawn("install", { + args: [PROTECTED_CLAUDE_VERSION, platform, checksum], + }); + if ((await process.exited) !== 0) + throw new Error("The protected Claude runtime could not be installed"); + await protectedRuntime(spawn); + })(); + const work = this.protectedDownload; + try { + await work; + signal.throwIfAborted(); + } finally { + if (this.protectedDownload === work) this.protectedDownload = undefined; + } + } + /** Sends a line to the running step, such as a sign-in code. */ answer(text: string) { return this.process?.write(`${text}\n`); diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index a654ddd2a2..18a207c3d1 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -236,6 +236,7 @@ function applied({ }); const ctx = { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, + agentProtection: { registerModels: vi.fn() }, host: { spawn, // Offer the installed release unless the test requests an update. @@ -512,7 +513,7 @@ it("finds the thread's Claude session another build started", async () => { }); it("is an agent type on the desktop app", () => { - expect(inject).toEqual(["agents", "host"]); + expect(inject).toEqual(["agents", "host", "agentProtection"]); const { type } = installed(); expect(type).toMatchObject({ id: "claude-code", From f6d94f43f62b8af9447c3be76f8e02ff3e6a6fd9 Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Thu, 29 Oct 2026 21:33:30 -0500 Subject: [PATCH 2/4] Use native structured output for Claude security reviews Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- src/bundled/claude-code/claude.ts | 17 +++++++- src/bundled/claude-code/setup.test.ts | 56 ++++++++++++++++++++++++++- src/bundled/claude-code/setup.ts | 6 ++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index b128ed6d4c..700fb035e6 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -37,6 +37,8 @@ export type ClaudeLaunch = Readonly<{ directories?: readonly string[]; /** Read-only catalog probe, without workspace customizations or tools. */ discovery?: boolean; + /** Native validation for a temporary review's structured result. */ + outputSchema?: Readonly>; /** Appended to Claude's own system prompt. */ systemPrompt: string; model?: string; @@ -120,9 +122,15 @@ export class ClaudeProcess { private readonly protectedEvents: Message[] = []; private resuming = false; private reviewText = ""; + private reviewOutput: unknown; get result() { return this.reviewText; } + get structuredResult() { + if (this.reviewOutput === undefined) + throw new Error("The security model returned no structured result"); + return JSON.stringify(this.reviewOutput); + } protectionContext() { return structuredClone({ transcript: this.protectedHistory, @@ -251,6 +259,9 @@ export class ClaudeProcess { folder, ]), ...(launch.model ? ["--model", launch.model] : []), + ...(launch.outputSchema + ? ["--json-schema", JSON.stringify(launch.outputSchema)] + : []), ...(launch.effort ? ["--effort", launch.effort] : []), ...(launch.resume ? ["--resume", launch.resume] : []), ...(!launch.resume && launch.sessionId @@ -647,8 +658,10 @@ export class ClaudeProcess { // Keep native protocol evidence alongside persisted history, without replacing it. if (this.protection && ["user", "assistant"].includes(String(message.type))) this.protectedEvents.push(structuredClone(message)); - if (message.type === "result" && typeof message.result === "string") - this.reviewText = message.result; + if (message.type === "result") { + this.reviewOutput = message.structured_output; + if (typeof message.result === "string") this.reviewText = message.result; + } if (typeof message.session_id === "string" && message.session_id) this.sessionId = message.session_id; if (message.type === "system" && message.subtype === "init") { diff --git a/src/bundled/claude-code/setup.test.ts b/src/bundled/claude-code/setup.test.ts index 622d057ba7..cf2c38ead2 100644 --- a/src/bundled/claude-code/setup.test.ts +++ b/src/bundled/claude-code/setup.test.ts @@ -4,10 +4,64 @@ import type { HostProcessOptions, } from "../../features/host/service"; import type { Spawn } from "./claude"; -import { fakeSpawn } from "./claude-testing"; +import { fakeSpawn, sessionState } from "./claude-testing"; import script from "./claude.sh?raw"; import { checkClaude, ClaudeSetup, MIN_VERSION, withScript } from "./setup"; +it.each([true, false])( + "requires the native structured review result when present: %s", + async (present) => { + const f = fakeSpawn(); + const setup = new ClaudeSetup(async (id, options) => { + const child = await f.spawn(id, options); + const fake = f.processes.at(-1); + if (!fake) throw new Error("Expected review worker"); + fake.hold = true; + return child; + }, vi.fn()); + const output = { blocked: false, reason: "Requested response delivery" }; + const prompt = { + system: "Assess the evidence without executing it.", + input: '{"kind":"tool","content":"Done."}', + outputSchema: { type: "object", required: ["blocked", "reason"] }, + }; + const reviewing = setup.review( + { id: "opus" }, + prompt, + new AbortController().signal, + ); + const checked = present + ? expect(reviewing).resolves.toEqual({ + text: JSON.stringify(output), + model: { id: "claude-opus-5-5" }, + }) + : expect(reviewing).rejects.toThrow( + "The security model returned no structured result", + ); + await vi.waitFor(() => + expect(f.processes[0]?.prompts).toEqual([prompt.input]), + ); + const fake = f.processes[0]; + if (!fake) throw new Error("Expected review worker"); + fake.emit({ + type: "result", + subtype: "success", + is_error: false, + result: present + ? "```json\n" + JSON.stringify(output) + "\n```" + : JSON.stringify(output), + ...(present ? { structured_output: output } : {}), + }); + sessionState(fake, "idle"); + await checked; + expect(fake.options.args).toContain("--json-schema"); + expect( + fake.options.args?.[fake.options.args.indexOf("--json-schema") + 1], + ).toBe(JSON.stringify(prompt.outputSchema)); + expect(fake.killed).toBe(true); + }, +); + /** A spawn whose processes print `output[args]` and exit with its code. */ function scripted( output: Record, diff --git a/src/bundled/claude-code/setup.ts b/src/bundled/claude-code/setup.ts index 79c343d662..c0e1ba2167 100644 --- a/src/bundled/claude-code/setup.ts +++ b/src/bundled/claude-code/setup.ts @@ -520,6 +520,7 @@ export class ClaudeSetup { cwd: "~", systemPrompt: prompt.system, discovery: true, + ...(prompt.outputSchema ? { outputSchema: prompt.outputSchema } : {}), ...(model.id ? { model: model.id } : {}), }); const timeout = AbortSignal.any([signal, AbortSignal.timeout(60_000)]); @@ -531,7 +532,10 @@ export class ClaudeSetup { timeout.throwIfAborted(); if (!result.ok) throw new Error(result.error ?? "The security model did not complete"); - return { text: worker.result, model: { id: worker.model ?? model.id } }; + return { + text: prompt.outputSchema ? worker.structuredResult : worker.result, + model: { id: worker.model ?? model.id }, + }; } finally { timeout.removeEventListener("abort", cancel); await worker.kill(); From 83b47104a2be7d35a16c517328196302a40102d9 Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Fri, 30 Oct 2026 11:03:08 -0500 Subject: [PATCH 3/4] Use trusted bootstrap tools for protected Claude workers Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- src/bundled/claude-code/claude-sh.test.ts | 22 ++++++++++++++++++++++ src/bundled/claude-code/claude.sh | 6 +++--- src/bundled/claude-code/manifest.json | 11 ++++++++++- src/bundled/claude-code/setup.test.ts | 18 ++++++++++++++++++ src/bundled/claude-code/setup.ts | 2 +- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/bundled/claude-code/claude-sh.test.ts b/src/bundled/claude-code/claude-sh.test.ts index 3169a7ff71..9787970d45 100644 --- a/src/bundled/claude-code/claude-sh.test.ts +++ b/src/bundled/claude-code/claude-sh.test.ts @@ -46,6 +46,28 @@ it("rejects executable cached bytes that do not match the trusted protected pin" await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); }); +it("does not execute a checksum utility planted on PATH", async () => { + const { home } = await disk(["2.1.305"]); + const bin = join(home, "bin"), + marker = join(home, "checksum-executed"); + await mkdir(bin); + await writeFile( + join(bin, "shasum"), + `#!/bin/sh\n/usr/bin/touch '${marker}'\nprintf planted\n`, + ); + await chmod(join(bin, "shasum"), 0o755); + await expect( + run("/bin/bash", ["-p", "-c", script, "claude.sh", "run"], { + env: { + PATH: `${bin}:${process.env.PATH ?? ""}`, + HOME: home, + BUZZ_CLAUDE_VERSION: "2.1.305", + }, + }), + ).rejects.toThrow("did not match its checksum"); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); +}); + it("reverifies protected bytes at execution after a successful discovery", async () => { const { home, dir } = await disk(["2.1.305"]); const binary = join(dir, "2.1.305", "claude"); diff --git a/src/bundled/claude-code/claude.sh b/src/bundled/claude-code/claude.sh index 8e840df025..d4db41cca5 100644 --- a/src/bundled/claude-code/claude.sh +++ b/src/bundled/claude-code/claude.sh @@ -8,7 +8,7 @@ set -eu # Shared by every Buzz build on this computer, so there is one download. -if [ "$(uname)" = Darwin ]; then +if [ "$(/usr/bin/uname)" = Darwin ]; then dir="$HOME/Library/Application Support/Buzz/claude-code" else dir="${XDG_DATA_HOME:-$HOME/.local/share}/buzz/claude-code" @@ -32,14 +32,14 @@ newer() { # Recheck cached bytes both during discovery and immediately before protected exec. verify_protected() { [ "$1" = "2.1.305" ] || { echo "Unsupported protected Claude release" >&2; exit 1; } - case "$(uname -sm)" in + case "$(/usr/bin/uname -sm)" in "Darwin arm64") checksum=ea3ea3c08d2a9634a4651e687fcf900d410c489bce2b00f22bd7352b91ba704f ;; "Darwin x86_64") checksum=af7ad8d6d8b1e49059988f6657d91bb8b9feac127bf8bec005c4c27d0093f153 ;; "Linux aarch64") checksum=5265a60217e667b75e126e887b49ac0f168ad8bb13c605c3e3df528519876dc0 ;; "Linux x86_64") checksum=86cb722ab68348877dba7971e4d0c659919e05e6228c05e006e83da320e656ef ;; *) echo "Unsupported protected Claude platform" >&2; exit 1 ;; esac - actual=$( (shasum -a 256 "$dir/$1/claude" 2>/dev/null || sha256sum "$dir/$1/claude") | cut -d" " -f1) + actual=$( (/usr/bin/shasum -a 256 "$dir/$1/claude" 2>/dev/null || /usr/bin/sha256sum "$dir/$1/claude") | /usr/bin/cut -d" " -f1) [ "$actual" = "$checksum" ] || { echo "The protected Claude executable did not match its checksum" >&2; exit 1; } } diff --git a/src/bundled/claude-code/manifest.json b/src/bundled/claude-code/manifest.json index 5fd55c42fc..662de5fafd 100644 --- a/src/bundled/claude-code/manifest.json +++ b/src/bundled/claude-code/manifest.json @@ -26,7 +26,10 @@ "program": "base64", "args": ["-i"] }, - { "id": "workspace", "program": "pwd" }, + { + "id": "workspace", + "program": "pwd" + }, { "id": "starts", "program": "grep", @@ -48,6 +51,12 @@ "id": "list", "program": "find", "args": [".", "-type", "f", "-name", "*.jsonl"] + }, + { + "id": "claude-protected-sh", + "program": "buzz-agent-command", + "bundled": true, + "args": ["--shell"] } ] } diff --git a/src/bundled/claude-code/setup.test.ts b/src/bundled/claude-code/setup.test.ts index cf2c38ead2..39127c3e2d 100644 --- a/src/bundled/claude-code/setup.test.ts +++ b/src/bundled/claude-code/setup.test.ts @@ -6,6 +6,7 @@ import type { import type { Spawn } from "./claude"; import { fakeSpawn, sessionState } from "./claude-testing"; import script from "./claude.sh?raw"; +import manifest from "./manifest.json"; import { checkClaude, ClaudeSetup, MIN_VERSION, withScript } from "./setup"; it.each([true, false])( @@ -415,10 +416,27 @@ it("starts Claude and its install steps as steps of claude.sh", async () => { await run("claude", { args: ["--version"] }); await run("use", { args: ["2.1.300"] }); await run("platform"); + await run("claude", { + args: ["--protected"], + protection: { readOnlyPaths: [], hiddenPaths: [] }, + }); + expect( + manifest.host.processes.find( + (process) => process.id === "claude-protected-sh", + ), + ).toMatchObject({ + program: "buzz-agent-command", + bundled: true, + args: ["--shell"], + }); expect(calls).toEqual([ { id: "claude-sh", args: [script, "claude.sh", "run", "--version"] }, { id: "claude-sh", args: [script, "claude.sh", "use", "2.1.300"] }, { id: "platform", args: [] }, + { + id: "claude-protected-sh", + args: [script, "claude.sh", "run", "--protected"], + }, ]); }); diff --git a/src/bundled/claude-code/setup.ts b/src/bundled/claude-code/setup.ts index c0e1ba2167..5a3fc1704b 100644 --- a/src/bundled/claude-code/setup.ts +++ b/src/bundled/claude-code/setup.ts @@ -50,7 +50,7 @@ export const withScript = (id, options) => { const step = STEPS[id]; return step - ? spawn("claude-sh", { + ? spawn(options?.protection ? "claude-protected-sh" : "claude-sh", { ...options, args: [script, "claude.sh", step, ...(options?.args ?? [])], }) From 68a6bf9e78ca63ce7ecf36247f8f4f0003b2a42b Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Fri, 30 Oct 2026 12:23:22 -0500 Subject: [PATCH 4/4] Contain Claude delegated body and transcript reads Use the captured native policy for both Buzz file tools and worker-selected transcript paths, with no ordinary-reader fallback. Cover transcript routing and the bundled reader manifest. Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- src/bundled/claude-code/manifest.json | 10 ++++++++++ src/bundled/claude-code/runtime.test.ts | 14 +++++++++++++- src/bundled/claude-code/runtime.ts | 10 +++++++++- src/bundled/claude-code/setup.test.ts | 15 +++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/bundled/claude-code/manifest.json b/src/bundled/claude-code/manifest.json index 662de5fafd..4c4b58a15d 100644 --- a/src/bundled/claude-code/manifest.json +++ b/src/bundled/claude-code/manifest.json @@ -11,6 +11,16 @@ "networkOrigins": ["https://downloads.claude.ai"], "files": true, "processes": [ + { + "id": "read-protected", + "program": "buzz-agent-command", + "bundled": true, + "args": [ + "--shell", + "case \"$1\" in \"~/\"*) set -- \"$HOME/${1:2}\";; esac; exec /usr/bin/base64 -i \"$1\"", + "buzz-read" + ] + }, { "id": "claude-sh", "program": "bash", diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 46937ac93f..8b545c10bd 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -75,7 +75,7 @@ function setup( await options.starting; if (options.nativeHistory && id === "runtime") spawnOptions?.onStdout?.('{"version":"2.1.305","root":"/runtime"}\n'); - if (options.nativeHistory && id === "read") + if (options.nativeHistory && id === "read-protected") spawnOptions?.onStdout?.( Buffer.from( options.nativeHistory.map((row) => JSON.stringify(row)).join("\n"), @@ -463,6 +463,18 @@ it("shares projected native history with incoming and combined action reviews", expect( Buffer.byteLength(JSON.stringify(check.mock.lastCall?.[0])), ).toBeLessThan(256 * 1024); + const readers = f.fake.processes.filter( + (process) => process.id === "read-protected", + ); + expect(readers.length).toBeGreaterThan(0); + for (const reader of readers) + expect(reader.options).toMatchObject({ + args: ["/native/transcript"], + protection: protection.processProtection, + }); + expect(f.fake.processes.some((process) => process.id === "read")).toBe( + false, + ); } finally { worker.finish(); await f.runtime.retire(self); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 5e999ca319..a0b9dc5524 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -926,7 +926,14 @@ export class ClaudeRuntime { }, relayAuthor: this.reads(agent)?.relayAuthor?.(), signingAuthority: this.reads(agent)?.workflowAuthority(), - read: (path) => readFile(this.spawn, cwd, path), + read: (path) => + readFile( + this.spawn, + cwd, + path, + undefined, + handle.protection?.processProtection, + ), memories: (slug) => this.memories(agent, slug), remembered: () => this.forget(agent), }); @@ -993,6 +1000,7 @@ export class ClaudeRuntime { workspace, path, signal, + gate.processProtection, ); const text = new TextDecoder().decode(bytes); return text diff --git a/src/bundled/claude-code/setup.test.ts b/src/bundled/claude-code/setup.test.ts index 39127c3e2d..a7c2569e14 100644 --- a/src/bundled/claude-code/setup.test.ts +++ b/src/bundled/claude-code/setup.test.ts @@ -9,6 +9,21 @@ import script from "./claude.sh?raw"; import manifest from "./manifest.json"; import { checkClaude, ClaudeSetup, MIN_VERSION, withScript } from "./setup"; +it("declares protected file reads through the bundled helper with a positional path", () => { + expect( + manifest.host.processes.find((process) => process.id === "read-protected"), + ).toEqual({ + id: "read-protected", + program: "buzz-agent-command", + bundled: true, + args: [ + "--shell", + `case "$1" in "~/"*) set -- "$HOME/\${1:2}";; esac; exec /usr/bin/base64 -i "$1"`, + "buzz-read", + ], + }); +}); + it.each([true, false])( "requires the native structured review result when present: %s", async (present) => {