From 8409f1d81cfeea39a0bedd62b70c1ba79cdcce39 Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Wed, 28 Oct 2026 21:08:03 -0500 Subject: [PATCH] Add verified plugin processes and native containment Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- .github/workflows/ci.yml | 36 +++++- crates/plugin-manager/src/lib.rs | 74 ++++++++++++ src-tauri/src/host_process.rs | 147 +++++++++++++++++++++-- src-tauri/src/host_process/protection.rs | 130 ++++++++++++++++++++ src-tauri/src/host_process/tests.rs | 38 ++++++ src/features/host/service.test.ts | 1 + src/features/host/service.ts | 7 ++ src/plugins/api.ts | 7 ++ tests/integration/browser-ci.test.mjs | 3 +- 9 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/host_process/protection.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 140b0bc655..20634de1aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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' }} @@ -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' }} @@ -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' }} @@ -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: @@ -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 diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cdcb27a3a6..f432243a42 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -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, +} +#[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, +} +#[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() @@ -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()); } @@ -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!("")), diff --git a/src-tauri/src/host_process.rs b/src-tauri/src/host_process.rs index ab3fa6a0a1..fe371a82f7 100644 --- a/src-tauri/src/host_process.rs +++ b/src-tauri/src/host_process.rs @@ -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; @@ -302,16 +309,19 @@ pub(crate) async fn plugin_host_process_spawn( env: Option>>, on_event: Channel, mcp: Option, + protection: Option, ) -> Result { 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')) { @@ -320,19 +330,71 @@ pub(crate) async fn plugin_host_process_spawn( 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 _ = ®istry; + 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) @@ -461,6 +523,9 @@ fn program_path( resources: impl FnOnce() -> Option, path: &OsStr, ) -> Result { + if let Some(artifact) = &declared.artifact { + return verify_artifact(artifact); + } if !declared.bundled { return Ok(resolve_program(&declared.program, path)); } @@ -468,6 +533,74 @@ fn program_path( 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 { + 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("~/") diff --git a/src-tauri/src/host_process/protection.rs b/src-tauri/src/host_process/protection.rs new file mode 100644 index 0000000000..5a1a0f76ca --- /dev/null +++ b/src-tauri/src/host_process/protection.rs @@ -0,0 +1,130 @@ +//! Read-only host controls and private approval state for a protected worker. +//! Ordinary workspace and runtime-state writes retain the user's access. +use serde::Deserialize; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProcessProtection { + pub read_only_paths: Vec, + pub hidden_paths: Vec, +} +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +impl ProcessProtection { + pub(crate) fn profile(&self, controls: &[PathBuf]) -> Result { + let paths = self + .read_only_paths + .iter() + .chain(&self.hidden_paths) + .chain(controls); + let mut roots = BTreeSet::new(); + let mut ancestors = BTreeSet::new(); + if self.read_only_paths.len() + self.hidden_paths.len() > 128 { + return Err("Too many protection paths".into()); + } + for path in paths { + if !path.is_absolute() || path.as_os_str().len() > 4096 { + return Err("Protection paths must be absolute".into()); + } + let original = path.clone(); + let path = path + .canonicalize() + .map_err(|_| "Protection path is unavailable")?; + for parent in path.ancestors().skip(1).filter(|p| *p != Path::new("/")) { + ancestors.insert(parent.to_path_buf()); + } + for parent in original + .ancestors() + .skip(1) + .filter(|p| *p != Path::new("/")) + { + ancestors.insert(parent.to_path_buf()); + } + roots.insert(original); + roots.insert(path); + } + let quote = |p: &Path| { + serde_json::to_string(p.to_str().ok_or("Invalid protection path")?) + .map_err(|_| "Invalid protection path") + }; + let mut writes = Vec::new(); + for path in roots { + writes.push(format!("(subpath {})", quote(&path)?)); + } + for path in ancestors { + writes.push(format!("(literal {})", quote(&path)?)); + } + let mut reads = Vec::new(); + for path in &self.hidden_paths { + reads.push(format!( + "(subpath {})", + quote( + &path + .canonicalize() + .map_err(|_| "Private storage is unavailable")? + )? + )); + } + Ok(format!("(version 1)\n(allow default)\n(deny appleevent-send)\n(deny mach-lookup (global-name \"com.apple.coreservices.appleevents\"))\n(deny file-write* {})\n{}", writes.join(" "), + if reads.is_empty() { String::new() } else { format!("(deny file-read* {})\n", reads.join(" ")) })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_relative_missing_and_excessive_paths() { + let mut protection = ProcessProtection { + read_only_paths: vec![PathBuf::from("relative")], + hidden_paths: vec![], + }; + assert!(protection.profile(&[]).is_err()); + protection.read_only_paths = vec![PathBuf::from("/path-that-does-not-exist-buzz-security")]; + assert!(protection.profile(&[]).is_err()); + protection.read_only_paths = vec![std::env::temp_dir(); 129]; + assert!(protection.profile(&[]).is_err()); + } + #[test] + #[cfg(target_os = "macos")] + fn kernel_denies_control_writes_and_private_reads_while_workspace_remains_writable() { + let dir = tempfile::tempdir().unwrap(); + let controls = dir.path().join("controls"); + let private = dir.path().join("private"); + let workspace = dir.path().join("workspace"); + for path in [&controls, &private, &workspace] { + std::fs::create_dir(path).unwrap(); + } + let secret = private.join("decision"); + std::fs::write(&secret, "private").unwrap(); + let profile = ProcessProtection { + read_only_paths: vec![], + hidden_paths: vec![private], + } + .profile(std::slice::from_ref(&controls)) + .unwrap(); + let run = |script: &str, path: &Path| { + std::process::Command::new("/usr/bin/sandbox-exec") + .args(["-p", &profile, "/bin/sh", "-c", script, "sandbox-test"]) + .arg(path) + .output() + .unwrap() + }; + assert!(run("printf allowed > \"$1\"", &workspace.join("note")) + .status + .success()); + assert!(!run("printf denied > \"$1\"", &controls.join("policy")) + .status + .success()); + let denied = run("cat \"$1\"", &secret); + assert!(!denied.status.success()); + assert!(denied.stdout.is_empty()); + assert_eq!(std::fs::read_to_string(secret).unwrap(), "private"); + assert_eq!( + std::fs::read_to_string(workspace.join("note")).unwrap(), + "allowed" + ); + } +} diff --git a/src-tauri/src/host_process/tests.rs b/src-tauri/src/host_process/tests.rs index 827e417624..66ffde9c08 100644 --- a/src-tauri/src/host_process/tests.rs +++ b/src-tauri/src/host_process/tests.rs @@ -313,6 +313,44 @@ fn a_bundled_program_runs_from_the_app_bin_never_path() { assert!(program_path(&process(false), || unreachable!(), path).is_ok()); } +#[test] +#[cfg(unix)] +fn artifact_program_requires_exact_executable_and_sibling_bytes() { + use sha2::{Digest, Sha256}; + use std::os::unix::fs::{symlink, PermissionsExt}; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let executable = root.join("agent"); + let sibling = root.join("runtime.js"); + std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(&sibling, "original").unwrap(); + let hash = |bytes: &[u8]| format!("{:x}", Sha256::digest(bytes)); + let process: HostProcess = serde_json::from_value(serde_json::json!({ + "id": "agent", "program": "agent", "artifact": { + "path": executable.to_str().unwrap(), + "sha256": hash(b"#!/bin/sh\nexit 0\n"), + "siblings": [{"name": "runtime.js", "sha256": hash(b"original")}] + } + })) + .unwrap(); + assert_eq!( + program_path(&process, || unreachable!(), OsStr::new("")), + Ok(executable.clone()) + ); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(program_path(&process, || unreachable!(), OsStr::new("")).is_err()); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(&sibling, "changed").unwrap(); + assert!(program_path(&process, || unreachable!(), OsStr::new("")).is_err()); + std::fs::write(&sibling, "original").unwrap(); + std::fs::remove_file(&executable).unwrap(); + let target = root.join("actual"); + std::fs::write(&target, "#!/bin/sh\nexit 0\n").unwrap(); + symlink(&target, &executable).unwrap(); + assert!(program_path(&process, || unreachable!(), OsStr::new("")).is_err()); +} + #[test] fn process_mcp_connection_has_an_explicit_wire_shape() { let connection = serde_json::json!({ diff --git a/src/features/host/service.test.ts b/src/features/host/service.test.ts index ab750263b8..f5b3126c42 100644 --- a/src/features/host/service.test.ts +++ b/src/features/host/service.test.ts @@ -312,6 +312,7 @@ it("starts a declared process as the calling plugin and streams it", async () => args: ["--print"], cwd: "~/.buzz", env: { CLAUDECODE: null }, + protection: null, onEvent: spawn.onEvent, mcp: { url: "http://127.0.0.1:1234/mcp", diff --git a/src/features/host/service.ts b/src/features/host/service.ts index 5bcac2cb16..f3e4b3f879 100644 --- a/src/features/host/service.ts +++ b/src/features/host/service.ts @@ -39,6 +39,10 @@ export type NipOaAuthorization = readonly [ conditions: string, signature: string, ]; +export type ProcessProtection = Readonly<{ + readOnlyPaths: readonly string[]; + hiddenPaths: readonly string[]; +}>; export type HostProcessOptions = Readonly<{ /** Follow the arguments the manifest declares. */ args?: readonly string[]; @@ -48,6 +52,8 @@ export type HostProcessOptions = Readonly<{ env?: Readonly>; /** Run with one app-held MCP session and an agent-scoped `buzz` on PATH. */ mcp?: Pick; + /** Native macOS containment for a protected worker and its descendants. */ + protection?: ProcessProtection; onStdout?(data: string): void; onStderr?(data: string): void; }>; @@ -243,6 +249,7 @@ export class HostService extends Service implements Host { cwd: options.cwd ?? null, env: options.env ?? null, mcp: options.mcp ?? null, + protection: options.protection ?? null, onEvent, }); // The host takes at most 1 MiB a write, so a longer one goes in pieces, diff --git a/src/plugins/api.ts b/src/plugins/api.ts index d7a0d782c0..cdbe979134 100644 --- a/src/plugins/api.ts +++ b/src/plugins/api.ts @@ -37,6 +37,13 @@ export type PluginManifest = Readonly<{ args?: readonly string[]; /** Run the copy the app ships, never one on PATH. */ bundled?: boolean; + /** Run an installed executable only when its bytes and declared sibling + * files match this descriptor. Mutually exclusive with `bundled`. */ + artifact?: Readonly<{ + path: string; + sha256: string; + siblings?: readonly Readonly<{ name: string; sha256: string }>[]; + }>; }>[]; /** The plugin may serve MCP with `host.serveMcp`, including tools for * commands bound to an Attention source run. */ diff --git a/tests/integration/browser-ci.test.mjs b/tests/integration/browser-ci.test.mjs index 22812f6eb2..a4e8162aa8 100644 --- a/tests/integration/browser-ci.test.mjs +++ b/tests/integration/browser-ci.test.mjs @@ -242,7 +242,7 @@ test("required gate executes its real shell and rejects every unsuccessful lane" assert.doesNotMatch(required, /^ {8}if:/m); assert.match( required, - /^ {4}needs: \[javascript, native, measurements, browser_fixture, browser, media_recorder\]$/m, + /^ {4}needs: \[javascript, native, measurements, browser_fixture, browser, media_recorder, agent_protection\]$/m, ); assert.doesNotMatch(required, /continue-on-error/); const lanes = [ @@ -252,6 +252,7 @@ test("required gate executes its real shell and rejects every unsuccessful lane" "FIXTURE", "BROWSER", "MEDIA_RECORDER", + "AGENT_PROTECTION", ]; for (const lane of lanes) assert.ok(