Skip to content
Open
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
36 changes: 31 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,31 @@ jobs:
path: test-results/ci
retention-days: 7

agent_protection:
if: github.event_name != 'workflow_dispatch'
name: Protected agents (macOS)
runs-on: macos-15
# Native pinned runtime builds add cold compilation to the kernel fixtures.
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: ./.github/actions/setup
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: agent-protection
save-if: ${{ github.event_name == 'push' }}
- name: Native approval-state containment
run: ./bin/cargo test --manifest-path src-tauri/Cargo.toml host_process --lib
- name: Protection test evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: agent-protection
path: test-results/protection
retention-days: 7

native:
if: github.event_name != 'workflow_dispatch'
name: Rust and tool integration
Expand All @@ -69,7 +94,7 @@ jobs:
sudo sed -i '\|^http://azure\.archive\.ubuntu\.com/ubuntu/|d' /etc/apt/apt-mirrors.txt
sudo apt-get update
sudo apt-get install --no-install-recommends -y dbus-daemon libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev patchelf
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native
save-if: ${{ github.event_name == 'push' }}
Expand Down Expand Up @@ -107,7 +132,7 @@ jobs:
rustup toolchain install $version --profile minimal --component clippy
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
"RUSTUP_TOOLCHAIN=$version" >> $env:GITHUB_ENV
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-native
save-if: ${{ github.ref == 'refs/heads/main' }}
Expand Down Expand Up @@ -181,7 +206,7 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: browser-fixture
save-if: ${{ github.event_name == 'push' }}
Expand Down Expand Up @@ -419,7 +444,7 @@ jobs:
required:
name: ${{ github.event_name == 'workflow_dispatch' && 'Automatic CI (not requested)' || 'CI required' }}
if: always() && github.event_name != 'workflow_dispatch'
needs: [javascript, native, measurements, browser_fixture, browser, media_recorder]
needs: [javascript, native, measurements, browser_fixture, browser, media_recorder, agent_protection]
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
Expand All @@ -431,7 +456,8 @@ jobs:
FIXTURE: ${{ needs.browser_fixture.result }}
BROWSER: ${{ needs.browser.result }}
MEDIA_RECORDER: ${{ needs.media_recorder.result }}
AGENT_PROTECTION: ${{ needs.agent_protection.result }}
run: |
for result in "$JAVASCRIPT" "$NATIVE" "$MEASUREMENTS" "$FIXTURE" "$BROWSER" "$MEDIA_RECORDER"; do
for result in "$JAVASCRIPT" "$NATIVE" "$MEASUREMENTS" "$FIXTURE" "$BROWSER" "$MEDIA_RECORDER" "$AGENT_PROTECTION"; do
test "$result" = success || exit 1
done
74 changes: 74 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,52 @@ pub struct HostProcess {
/// Run the copy the app ships in its `bin` resources, never one on PATH.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub bundled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact: Option<ProcessArtifact>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ProcessArtifact {
pub path: String,
pub sha256: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub siblings: Vec<ProcessArtifactSibling>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ProcessArtifactSibling {
pub name: String,
pub sha256: String,
}
fn valid_sha256(hash: &str) -> bool {
hash.len() == 64
&& hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn valid_artifact(artifact: &ProcessArtifact) -> bool {
let path = Path::new(&artifact.path);
if !path.is_absolute()
|| artifact.path.len() > 4096
|| artifact.path.contains('\0')
|| !valid_sha256(&artifact.sha256)
|| artifact.siblings.len() > 16
{
return false;
}
let mut names = std::collections::HashSet::new();
artifact.siblings.iter().all(|sibling| {
let name = &sibling.name;
!name.is_empty()
&& name.len() <= 80
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
&& valid_sha256(&sibling.sha256)
&& names.insert(name)
})
}
fn valid_program(program: &str, args: &[String]) -> bool {
!program.is_empty()
Expand Down Expand Up @@ -192,6 +238,11 @@ impl Manifest {
valid_id(&process.id)?;
if !process_ids.insert(&process.id)
|| !valid_program(&process.program, &process.args)
|| (process.bundled && process.artifact.is_some())
|| process
.artifact
.as_ref()
.is_some_and(|artifact| !valid_artifact(artifact))
{
return Err("Invalid host process declaration".into());
}
Expand Down Expand Up @@ -1920,6 +1971,29 @@ mod tests {
bundled["host"]["processes"][0]["bundled"] = serde_json::json!(true);
let parsed: Manifest = serde_json::from_value(bundled).unwrap();
assert!(parsed.host.as_ref().unwrap().processes[0].bundled);
let hash = "a".repeat(64);
let mut artifact = processes.clone();
artifact["host"]["processes"][0]["artifact"] = serde_json::json!({
"path": "/opt/agents/example-cli", "sha256": hash,
"siblings": [{"name": "runtime.js", "sha256": "b".repeat(64)}]
});
assert!(artifact_from_text(&artifact.to_string(), "export const x = 1".into()).is_ok());
for (field, value) in [
("path", serde_json::json!("relative/agent")),
("sha256", serde_json::json!("A".repeat(64))),
("sha256", serde_json::json!("a".repeat(63))),
(
"siblings",
serde_json::json!([{"name": "../other", "sha256": "b".repeat(64)}]),
),
] {
let mut invalid = artifact.clone();
invalid["host"]["processes"][0]["artifact"][field] = value;
assert!(artifact_from_text(&invalid.to_string(), "export const x = 1".into()).is_err());
}
let mut invalid = artifact.clone();
invalid["host"]["processes"][0]["bundled"] = serde_json::json!(true);
assert!(artifact_from_text(&invalid.to_string(), "export const x = 1".into()).is_err());
for (field, value) in [
("program", serde_json::json!("/bin/sh")),
("program", serde_json::json!("")),
Expand Down
147 changes: 140 additions & 7 deletions src-tauri/src/host_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
//! arrive. A process belongs to the plugin that started it and to the page that
//! asked; it is killed when the plugin asks, when the page reloads, or when the
//! app exits. A page that reloaded hears nothing of it after. It runs with the
//! user's own access: there is no sandbox.
//! user's own access, with optional native containment for protected workers.
mod protection;
use protection::ProcessProtection;

use crate::host_command::{effective_path, load_login_shell_path, resolve_program};
use crate::{with_manager, PluginManager};
use buzzodz_plugins::HostProcess;
use buzzodz_plugins::{HostProcess, ProcessArtifact};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
use std::path::PathBuf;
Expand Down Expand Up @@ -302,16 +309,19 @@ pub(crate) async fn plugin_host_process_spawn<R: tauri::Runtime>(
env: Option<HashMap<String, Option<String>>>,
on_event: Channel<ProcessEvent>,
mcp: Option<ProcessMcpConnection>,
protection: Option<ProcessProtection>,
) -> Result<u64, String> {
let page = processes.page();
let plugin = id.clone();
let declared = with_manager(manager, move |manager| {
let (declared, registry) = with_manager(manager, move |manager| {
let registry = manager.storage_root().to_path_buf();
manager
.host_grants(&id, &revision)?
.processes
.into_iter()
.find(|process| process.id == process_id)
.ok_or_else(|| "Process is not declared".into())
.map(|declared| (declared, registry))
})
.await?;
if args.len() > 256 || args.iter().any(|arg| arg.contains('\0')) {
Expand All @@ -320,19 +330,71 @@ pub(crate) async fn plugin_host_process_spawn<R: tauri::Runtime>(
load_login_shell_path().await;
let path = effective_path();
let program = program_path(&declared, || app.path().resource_dir().ok(), &path)?;
let mut command = if mcp.is_some() {
#[cfg(target_os = "macos")]
let artifact_parent = declared.artifact.as_ref().map(|_| {
program
.parent()
.expect("absolute artifact path has a parent")
.to_path_buf()
});
let (program, prefix) = if mcp.is_some() {
let resources = app.path().resource_dir().map_err(|_| "No app resources")?;
let helper = if cfg!(windows) {
"buzz-agent-command.exe"
} else {
"buzz-agent-command"
};
let mut command = Command::new(resources.join("bin").join(helper));
command.arg("--launch").arg(&program);
command
(
resources.join("bin").join(helper),
vec![
std::ffi::OsString::from("--launch"),
program.into_os_string(),
],
)
} else {
(program, Vec::new())
};
#[cfg(not(target_os = "macos"))]
let _ = &registry;
let mut command = if let Some(protection) = protection {
#[cfg(not(target_os = "macos"))]
{
let _ = protection;
return Err("Protected workers require macOS".into());
}
#[cfg(target_os = "macos")]
{
// A dev frontend can be served from another checkout. Its actual
// source is not native-owned, so HMR could replace the gate.
if tauri::is_dev() {
return Err("Use a packaged Buzz build for protected workers; development frontends are mutable".into());
}
let controls = app
.path()
.app_data_dir()
.map_err(|_| "Application data is unavailable")?
.join("agents");
std::fs::create_dir_all(&controls).map_err(|_| "Agent controls are unavailable")?;
let executable =
std::env::current_exe().map_err(|_| "Application executable is unavailable")?;
let bundle = executable
.ancestors()
.find(|p| p.extension().is_some_and(|extension| extension == "app"))
.map(std::path::Path::to_path_buf)
.unwrap_or(executable);
let mut protected_roots = vec![controls, bundle, registry];
if let Some(parent) = artifact_parent {
protected_roots.push(parent);
}
let profile = protection.profile(&protected_roots)?;
let mut contained = Command::new("/usr/bin/sandbox-exec");
contained.args(["-p", &profile]).arg(program);
contained
}
} else {
Command::new(program)
};
command.args(prefix);
command
.args(&declared.args)
.args(&args)
Expand Down Expand Up @@ -461,13 +523,84 @@ fn program_path(
resources: impl FnOnce() -> Option<PathBuf>,
path: &OsStr,
) -> Result<PathBuf, String> {
if let Some(artifact) = &declared.artifact {
return verify_artifact(artifact);
}
if !declared.bundled {
return Ok(resolve_program(&declared.program, path));
}
let resources = resources().ok_or_else(|| format!("Could not start {}", declared.program))?;
Ok(resources.join("bin").join(&declared.program))
}

/// A descriptor names exact local bytes. No component may resolve through a
/// symlink, including the containing directory and each declared sibling.
fn verify_artifact(artifact: &ProcessArtifact) -> Result<PathBuf, String> {
let executable = PathBuf::from(&artifact.path);
if !executable.is_absolute() {
return Err("Artifact path must be absolute".into());
}
verify_artifact_file(&executable, &artifact.sha256, true)?;
let parent = executable
.parent()
.ok_or("Artifact has no parent directory")?;
for sibling in &artifact.siblings {
verify_artifact_file(&parent.join(&sibling.name), &sibling.sha256, false)?;
}
Ok(executable)
}

fn verify_artifact_file(
path: &std::path::Path,
expected: &str,
executable: bool,
) -> Result<(), String> {
#[cfg(not(unix))]
let _ = executable;
if !path.is_absolute()
|| expected.len() != 64
|| !expected
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err("Invalid artifact descriptor".into());
}
for component in path.ancestors() {
let metadata =
std::fs::symlink_metadata(component).map_err(|_| "Artifact is unavailable")?;
if metadata.file_type().is_symlink() {
return Err("Artifact path contains a symlink".into());
}
}
if path.canonicalize().map_err(|_| "Artifact is unavailable")? != path {
return Err("Artifact path is not canonical".into());
}
let metadata = std::fs::metadata(path).map_err(|_| "Artifact is unavailable")?;
if !metadata.is_file() {
return Err("Artifact is not a regular file".into());
}
#[cfg(unix)]
if executable && metadata.permissions().mode() & 0o111 == 0 {
return Err("Artifact is not executable".into());
}
let mut file = std::fs::File::open(path).map_err(|_| "Artifact is unreadable")?;
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file
.read(&mut buffer)
.map_err(|_| "Artifact is unreadable")?;
if count == 0 {
break;
}
hash.update(&buffer[..count]);
}
if format!("{:x}", hash.finalize()) != expected {
return Err("Artifact checksum does not match".into());
}
Ok(())
}

/// The rest of a path written `~/…`, or on Windows also `~\…`.
pub(crate) fn under_home(path: &str) -> Option<&str> {
path.strip_prefix("~/")
Expand Down
Loading
Loading