Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions scripts/install-protected-claude-test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
85 changes: 70 additions & 15 deletions src-tauri/src/host_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -66,6 +68,7 @@ pub(crate) enum ProcessEvent {
}

struct Entry {
hooks: Option<ClaudeHooks>,
plugin: String,
/// Its process group, which is its own pid.
#[cfg_attr(not(unix), allow(dead_code))]
Expand Down Expand Up @@ -110,13 +113,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<ProcessEvent>,
) -> Result<u64, String> {
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<ProcessEvent>,
claude_hooks: bool,
) -> Result<u64, String> {
let state = self.clone();
let mut registry = self.registry();
Expand All @@ -139,11 +154,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
Expand All @@ -158,14 +181,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);
Expand Down Expand Up @@ -198,6 +226,9 @@ impl HostProcesses {
}
}
};
if let Some(hooks) = &hooks {
hooks.close();
}
// Descendants that outlived it go with it.
#[cfg(unix)]
if let Some(group) = group {
Expand Down Expand Up @@ -232,22 +263,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| {
Expand Down Expand Up @@ -381,6 +422,7 @@ pub(crate) async fn plugin_host_process_spawn<R: tauri::Runtime>(
} 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")?;
Expand Down Expand Up @@ -455,7 +497,14 @@ pub(crate) async fn plugin_host_process_spawn<R: tauri::Runtime>(
#[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.
Expand Down Expand Up @@ -517,6 +566,7 @@ fn forward(
stream: Stream,
channel: Channel<ProcessEvent>,
gone: Arc<Mutex<Option<oneshot::Sender<()>>>>,
hooks: Option<ClaudeHooks>,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let mut buffer = vec![0; READ_BYTES];
Expand All @@ -527,6 +577,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;
Expand Down
Loading
Loading