diff --git a/crates/execution/tests/python.rs b/crates/execution/tests/python.rs index b18494ae..82f6b4e7 100644 --- a/crates/execution/tests/python.rs +++ b/crates/execution/tests/python.rs @@ -712,6 +712,14 @@ export async function loadPyodide(options) { }, ) .expect("respond to read_dir"), + PythonVfsRpcMethod::Unlink + | PythonVfsRpcMethod::Rmdir + | PythonVfsRpcMethod::Rename => { + panic!( + "unexpected mutating-FS Python RPC in this test: {:?}", + request.method + ) + } PythonVfsRpcMethod::HttpRequest | PythonVfsRpcMethod::DnsLookup | PythonVfsRpcMethod::SubprocessRun => { diff --git a/crates/execution/tests/python_prewarm.rs b/crates/execution/tests/python_prewarm.rs index f8e14381..7d70143e 100644 --- a/crates/execution/tests/python_prewarm.rs +++ b/crates/execution/tests/python_prewarm.rs @@ -1,10 +1,12 @@ use secure_exec_execution::{ - CreatePythonContextRequest, PythonExecutionEngine, StartPythonExecutionRequest, + CreatePythonContextRequest, PythonExecutionEngine, PythonExecutionEvent, + StartPythonExecutionRequest, }; use serde_json::Value; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +use std::time::Duration; use tempfile::tempdir; const PYTHON_WARMUP_METRICS_PREFIX: &str = "__AGENTOS_PYTHON_WARMUP_METRICS__:"; @@ -27,7 +29,7 @@ fn run_python_execution( cwd: &Path, code: &str, ) -> (String, String, i32) { - let result = engine + let mut execution = engine .start_execution(StartPythonExecutionRequest { guest_runtime: Default::default(), limits: Default::default(), @@ -41,15 +43,43 @@ fn run_python_execution( )]), cwd: cwd.to_path_buf(), }) - .expect("start Python execution") - .wait(None) - .expect("wait for Python execution"); + .expect("start Python execution"); - ( - String::from_utf8(result.stdout).expect("stdout utf8"), - String::from_utf8(result.stderr).expect("stderr utf8"), - result.exit_code, - ) + // Drive the event loop directly instead of `.wait()`: the Pyodide runner + // now sets up a kernel-VFS-backed site-packages on boot, which emits VFS + // RPCs. This prewarm test has no VFS backend, so reject those RPCs and let + // the runner's best-effort site-packages setup degrade. Module-resolution + // sync RPCs are serviced host-directly. + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + loop { + match execution + .poll_event_blocking(Duration::from_secs(60)) + .expect("poll Python event") + { + Some(PythonExecutionEvent::Stdout(chunk)) => stdout.extend(chunk), + Some(PythonExecutionEvent::Stderr(chunk)) => stderr.extend(chunk), + Some(PythonExecutionEvent::JavascriptSyncRpcRequest(request)) => { + let serviced = execution + .try_service_standalone_module_sync_rpc(&request) + .expect("service module sync RPC"); + assert!(serviced, "unexpected JS sync RPC request: {request:?}"); + } + Some(PythonExecutionEvent::VfsRpcRequest(request)) => { + execution + .respond_vfs_rpc_error(request.id, "ENOSYS", "no VFS backend in prewarm test") + .expect("respond to VFS RPC"); + } + Some(PythonExecutionEvent::Exited(exit_code)) => { + return ( + String::from_utf8(stdout).expect("stdout utf8"), + String::from_utf8(stderr).expect("stderr utf8"), + exit_code, + ); + } + None => panic!("timed out waiting for Python execution event"), + } + } } fn parse_metrics(stderr: &str, phase: &str) -> Value { diff --git a/crates/sidecar/src/execution.rs b/crates/sidecar/src/execution.rs index 86cebb72..9c6e2eb1 100644 --- a/crates/sidecar/src/execution.rs +++ b/crates/sidecar/src/execution.rs @@ -17,10 +17,9 @@ use crate::protocol::{ OwnershipScope, ProcessExitedEvent, ProcessKilledResponse, ProcessOutputEvent, ProcessSnapshotEntry, ProcessSnapshotResponse, ProcessSnapshotStatus, ProcessStartedResponse, PtyResizedResponse, RequestFrame, ResizePtyRequest, ResponseFrame, ResponsePayload, - SidecarRequestPayload, SignalDispositionAction, - SignalHandlerRegistration, SignalStateResponse, SocketStateEntry, StdinClosedResponse, - StdinWrittenResponse, StreamChannel, VmFetchRequest, VmFetchResponse, WasmPermissionTier, - WriteStdinRequest, ZombieTimerCountResponse, + SidecarRequestPayload, SignalDispositionAction, SignalHandlerRegistration, SignalStateResponse, + SocketStateEntry, StdinClosedResponse, StdinWrittenResponse, StreamChannel, VmFetchRequest, + VmFetchResponse, WasmPermissionTier, WriteStdinRequest, ZombieTimerCountResponse, }; use crate::service::{ audit_fields, dirname, emit_security_audit_event, emit_structured_event, javascript_error, @@ -16810,7 +16809,7 @@ fn install_kernel_stdin_pipe(kernel: &mut SidecarKernel, pid: u32) -> Result) -> Option<(u16, u16)> { let cols = env diff --git a/crates/sidecar/tests/architecture_guards.rs b/crates/sidecar/tests/architecture_guards.rs index 0057444e..dc84f68e 100644 --- a/crates/sidecar/tests/architecture_guards.rs +++ b/crates/sidecar/tests/architecture_guards.rs @@ -270,6 +270,12 @@ const FS_ALLOW: &[&str] = &[ "crates/execution/src/javascript.rs", "crates/execution/src/node_import_cache.rs", "crates/execution/src/runtime_support.rs", + // Host-side V8 diagnostics: module-trace and sync-RPC latency profilers + // write to an operator-provided file path, and snapshot bootstrap reads the + // userland bundle from PI_SNAPSHOT_BUNDLE_PATH. Host-only, not guest-reachable. + "crates/v8-runtime/src/execution.rs", + "crates/v8-runtime/src/host_call.rs", + "crates/v8-runtime/src/snapshot.rs", ]; /// net: host network access. @@ -323,6 +329,14 @@ const ENV_ALLOW: &[&str] = &[ "crates/v8-runtime/src/bridge.rs", "crates/sidecar/src/execution.rs", "crates/sidecar/src/plugins/s3_common.rs", + // Host-process startup log-level knob, read before any VM exists. + "crates/sidecar/src/main.rs", + // Host-side V8 diagnostics toggles (module-trace + sync-RPC latency + // profiling + snapshot-bundle path), read at runtime init from operator + // env. Not guest-reachable. + "crates/v8-runtime/src/execution.rs", + "crates/v8-runtime/src/host_call.rs", + "crates/v8-runtime/src/snapshot.rs", ]; fn fs_class() -> BannedClass { diff --git a/crates/sidecar/tests/limits.rs b/crates/sidecar/tests/limits.rs index 791f9dd2..1fe9b6eb 100644 --- a/crates/sidecar/tests/limits.rs +++ b/crates/sidecar/tests/limits.rs @@ -7,7 +7,9 @@ use secure_exec_vm_config::{ }; use serde_json::json; -const SIDECAR_FRAME_CAP: usize = 1024 * 1024; +// Must match the production sidecar wire frame cap (wire::DEFAULT_MAX_FRAME_BYTES), +// which is what vm_limits_from_config is called with at runtime (lib.rs/state.rs). +const SIDECAR_FRAME_CAP: usize = 16 * 1024 * 1024; #[test] fn defaults_match_struct_default() { diff --git a/crates/sidecar/tests/python.rs b/crates/sidecar/tests/python.rs index ab05430d..31b10123 100644 --- a/crates/sidecar/tests/python.rs +++ b/crates/sidecar/tests/python.rs @@ -2850,6 +2850,7 @@ print(json.dumps(result)) ); } +#[allow(clippy::too_many_arguments)] fn execute_python_cli( sidecar: &mut secure_exec_sidecar::NativeSidecar, request_id: RequestId, @@ -2885,6 +2886,7 @@ fn execute_python_cli( } } +#[allow(clippy::too_many_arguments)] fn execute_python_cli_with_env( sidecar: &mut secure_exec_sidecar::NativeSidecar, request_id: RequestId, diff --git a/crates/sidecar/tests/service.rs b/crates/sidecar/tests/service.rs index ca640684..8d84a092 100644 --- a/crates/sidecar/tests/service.rs +++ b/crates/sidecar/tests/service.rs @@ -10344,24 +10344,28 @@ await new Promise(() => {}); }; cleanup_fake_runtime_process(process); } - fn python_vfs_rpc_paths_are_scoped_to_workspace_root() { + fn python_vfs_rpc_paths_resolve_textually_and_defer_to_kernel_confinement() { + // Root is `/`: any absolute guest path is addressable and textual + // `.`/`..` segments are resolved here; confinement is enforced at the + // kernel/mount layer (openat2 RESOLVE_BENEATH), not by a prefix check. assert_eq!( crate::filesystem::normalize_python_vfs_rpc_path("/workspace/./note.txt") .expect("normalize workspace path"), String::from("/workspace/note.txt") ); - assert!( + assert_eq!( crate::filesystem::normalize_python_vfs_rpc_path("/workspace/../etc/passwd") - .is_err(), - "workspace escape should be rejected", + .expect("normalize resolves .. textually"), + String::from("/etc/passwd") ); - assert!( - crate::filesystem::normalize_python_vfs_rpc_path("/etc/passwd").is_err(), - "non-workspace paths should be rejected", + assert_eq!( + crate::filesystem::normalize_python_vfs_rpc_path("/etc/passwd") + .expect("absolute guest paths are addressable"), + String::from("/etc/passwd") ); assert!( crate::filesystem::normalize_python_vfs_rpc_path("workspace/note.txt").is_err(), - "relative paths should be rejected", + "relative paths must be rejected", ); } fn javascript_fs_sync_rpc_resolves_proc_self_against_the_kernel_process() { @@ -17483,7 +17487,7 @@ console.log(JSON.stringify({ command_resolution_rejects_unknown_command(); python_vfs_rpc_requests_proxy_into_the_vm_kernel_filesystem(); javascript_sync_rpc_requests_proxy_into_the_vm_kernel_filesystem(); - python_vfs_rpc_paths_are_scoped_to_workspace_root(); + python_vfs_rpc_paths_resolve_textually_and_defer_to_kernel_confinement(); javascript_fs_sync_rpc_resolves_proc_self_against_the_kernel_process(); javascript_fd_and_stream_rpc_requests_proxy_into_the_vm_kernel_filesystem(); javascript_mapped_tmp_open_wx_uses_exclusive_create_once(); diff --git a/registry/native/crates/commands/sh/Cargo.toml b/registry/native/crates/commands/sh/Cargo.toml index f5bc1ba0..de09d0a9 100644 --- a/registry/native/crates/commands/sh/Cargo.toml +++ b/registry/native/crates/commands/sh/Cargo.toml @@ -9,5 +9,13 @@ description = "sh standalone binary for secure-exec VM" name = "sh" path = "src/main.rs" -[dependencies] +# `reedline` (brush's interactive line editor) pulls brush-interactive's +# tokio block_in_place / Handle::block_on paths, which don't compile or run on +# single-threaded wasm. The wasm sandbox shell runs scripts, not an interactive +# TTY REPL, so enable reedline only on native targets and keep `minimal` on wasm +# (the pre-#137 behavior that built cleanly for wasm32-wasip1). +[target.'cfg(any(unix, windows))'.dependencies] brush-shell = { version = "0.3.0", default-features = false, features = ["reedline", "minimal"] } + +[target.'cfg(not(any(unix, windows)))'.dependencies] +brush-shell = { version = "0.3.0", default-features = false, features = ["minimal"] } diff --git a/registry/native/patches/crates/fd-lock/0001-wasi-support.patch b/registry/native/patches/crates/fd-lock/0001-wasi-support.patch index c6ed095c..d9f002ea 100644 --- a/registry/native/patches/crates/fd-lock/0001-wasi-support.patch +++ b/registry/native/patches/crates/fd-lock/0001-wasi-support.patch @@ -1,16 +1,17 @@ -diff -ruN '--exclude=*.orig' a/src/sys/mod.rs b/src/sys/mod.rs ---- a/src/sys/mod.rs 2026-06-27 14:11:52.511433635 -0700 -+++ b/src/sys/mod.rs 2026-06-27 14:11:52.511433635 -0700 -@@ -13,6 +13,5 @@ +diff -ruN a/src/sys/mod.rs b/src/sys/mod.rs +--- a/src/sys/mod.rs 2026-06-27 16:47:16.865093521 -0700 ++++ b/src/sys/mod.rs 2026-06-27 16:47:48.549140973 -0700 +@@ -12,6 +12,6 @@ + pub(crate) use std::os::windows::io::AsHandle as AsOpenFile; } else { mod unsupported; - pub use unsupported::*; -- pub(crate) use std::os::fd::AsFd as AsOpenFile; +- pub use unsupported; ++ pub use unsupported::*; } } -diff -ruN '--exclude=*.orig' a/src/sys/unsupported/mod.rs b/src/sys/unsupported/mod.rs ---- a/src/sys/unsupported/mod.rs 2026-06-27 14:11:52.511433635 -0700 -+++ b/src/sys/unsupported/mod.rs 2026-06-27 14:11:52.515433634 -0700 +diff -ruN a/src/sys/unsupported/mod.rs b/src/sys/unsupported/mod.rs +--- a/src/sys/unsupported/mod.rs 2026-06-27 16:47:16.865093521 -0700 ++++ b/src/sys/unsupported/mod.rs 2026-06-27 16:47:48.549140973 -0700 @@ -2,8 +2,10 @@ mod rw_lock; mod write_guard; @@ -24,23 +25,23 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/mod.rs b/src/sys/unsupported/ +pub(crate) trait AsOpenFile {} + +impl AsOpenFile for T {} -diff -ruN '--exclude=*.orig' a/src/sys/unsupported/read_guard.rs b/src/sys/unsupported/read_guard.rs ---- a/src/sys/unsupported/read_guard.rs 2026-06-27 14:11:52.511433635 -0700 -+++ b/src/sys/unsupported/read_guard.rs 2026-06-27 14:11:52.515433634 -0700 +diff -ruN a/src/sys/unsupported/read_guard.rs b/src/sys/unsupported/read_guard.rs +--- a/src/sys/unsupported/read_guard.rs 2026-06-27 16:47:16.865093521 -0700 ++++ b/src/sys/unsupported/read_guard.rs 2026-06-27 16:47:48.553140979 -0700 @@ -1,31 +1,28 @@ use std::ops; --use std::os::fd::AsFd; +-use std::os::unix::io::AsRawFd; -use super::RwLock; +use super::{AsOpenFile, RwLock}; #[derive(Debug)] --pub struct RwLockReadGuard<'lock, T: AsFd> { +-pub struct RwLockReadGuard<'lock, T: AsRawFd> { +pub struct RwLockReadGuard<'lock, T: AsOpenFile> { lock: &'lock RwLock, } --impl<'lock, T: AsFd> RwLockReadGuard<'lock, T> { +-impl<'lock, T: AsRawFd> RwLockReadGuard<'lock, T> { +impl<'lock, T: AsOpenFile> RwLockReadGuard<'lock, T> { pub(crate) fn new(lock: &'lock RwLock) -> Self { - panic!("target unsupported") @@ -48,7 +49,7 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/read_guard.rs b/src/sys/unsup } } --impl ops::Deref for RwLockReadGuard<'_, T> { +-impl ops::Deref for RwLockReadGuard<'_, T> { +impl ops::Deref for RwLockReadGuard<'_, T> { type Target = T; @@ -59,7 +60,7 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/read_guard.rs b/src/sys/unsup } } --impl Drop for RwLockReadGuard<'_, T> { +-impl Drop for RwLockReadGuard<'_, T> { +impl Drop for RwLockReadGuard<'_, T> { #[inline] - fn drop(&mut self) { @@ -67,24 +68,24 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/read_guard.rs b/src/sys/unsup - } + fn drop(&mut self) {} } -diff -ruN '--exclude=*.orig' a/src/sys/unsupported/rw_lock.rs b/src/sys/unsupported/rw_lock.rs ---- a/src/sys/unsupported/rw_lock.rs 2026-06-27 14:11:52.511433635 -0700 -+++ b/src/sys/unsupported/rw_lock.rs 2026-06-27 14:11:52.515433634 -0700 +diff -ruN a/src/sys/unsupported/rw_lock.rs b/src/sys/unsupported/rw_lock.rs +--- a/src/sys/unsupported/rw_lock.rs 2026-06-27 16:47:16.865093521 -0700 ++++ b/src/sys/unsupported/rw_lock.rs 2026-06-27 16:47:48.553140979 -0700 @@ -1,37 +1,36 @@ -use std::io::{self, Error, ErrorKind}; --use std::os::fd::AsFd; +-use std::os::unix::io::AsRawFd; +use std::io::{self, Error}; -use super::{RwLockReadGuard, RwLockWriteGuard}; +use super::{AsOpenFile, RwLockReadGuard, RwLockWriteGuard}; #[derive(Debug)] --pub struct RwLock { +-pub struct RwLock { +pub struct RwLock { pub(crate) inner: T, } --impl RwLock { +-impl RwLock { +impl RwLock { #[inline] pub fn new(inner: T) -> Self { @@ -125,23 +126,23 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/rw_lock.rs b/src/sys/unsuppor + self.inner } } -diff -ruN '--exclude=*.orig' a/src/sys/unsupported/write_guard.rs b/src/sys/unsupported/write_guard.rs ---- a/src/sys/unsupported/write_guard.rs 2026-06-27 14:11:52.511433635 -0700 -+++ b/src/sys/unsupported/write_guard.rs 2026-06-27 14:11:52.515433634 -0700 +diff -ruN a/src/sys/unsupported/write_guard.rs b/src/sys/unsupported/write_guard.rs +--- a/src/sys/unsupported/write_guard.rs 2026-06-27 16:47:16.865093521 -0700 ++++ b/src/sys/unsupported/write_guard.rs 2026-06-27 16:47:48.553140979 -0700 @@ -1,38 +1,35 @@ use std::ops; --use std::os::fd::AsFd; +-use std::os::unix::io::AsRawFd; -use super::RwLock; +use super::{AsOpenFile, RwLock}; #[derive(Debug)] --pub struct RwLockWriteGuard<'lock, T: AsFd> { +-pub struct RwLockWriteGuard<'lock, T: AsRawFd> { +pub struct RwLockWriteGuard<'lock, T: AsOpenFile> { lock: &'lock mut RwLock, } --impl<'lock, T: AsFd> RwLockWriteGuard<'lock, T> { +-impl<'lock, T: AsRawFd> RwLockWriteGuard<'lock, T> { +impl<'lock, T: AsOpenFile> RwLockWriteGuard<'lock, T> { pub(crate) fn new(lock: &'lock mut RwLock) -> Self { - panic!("target unsupported") @@ -149,7 +150,7 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/write_guard.rs b/src/sys/unsu } } --impl ops::Deref for RwLockWriteGuard<'_, T> { +-impl ops::Deref for RwLockWriteGuard<'_, T> { +impl ops::Deref for RwLockWriteGuard<'_, T> { type Target = T; @@ -160,7 +161,7 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/write_guard.rs b/src/sys/unsu } } --impl ops::DerefMut for RwLockWriteGuard<'_, T> { +-impl ops::DerefMut for RwLockWriteGuard<'_, T> { +impl ops::DerefMut for RwLockWriteGuard<'_, T> { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { @@ -169,7 +170,7 @@ diff -ruN '--exclude=*.orig' a/src/sys/unsupported/write_guard.rs b/src/sys/unsu } } --impl Drop for RwLockWriteGuard<'_, T> { +-impl Drop for RwLockWriteGuard<'_, T> { +impl Drop for RwLockWriteGuard<'_, T> { #[inline] - fn drop(&mut self) { diff --git a/scripts/check-no-escaping-local-deps.mjs b/scripts/check-no-escaping-local-deps.mjs new file mode 100644 index 00000000..ff692b6b --- /dev/null +++ b/scripts/check-no-escaping-local-deps.mjs @@ -0,0 +1,158 @@ +// Guard against committing dependencies that point at a local path *outside* +// this repository. In-repo local links are legitimate (the registry links to +// ../packages/core, test fixtures use file:./vendor/..., cargo crates use +// path = "../sibling-crate"). What must never land on a branch is a link:/file:/ +// path: dependency that escapes the repo root — e.g. a `link:../../secure-exec` +// override left over from local-dev mode, which resolves to nothing in CI and +// breaks the build. This check fails on exactly those escaping local deps. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dependencySections = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; +// pnpm/npm local-path protocols whose target is a filesystem path. +const localProtocols = ["link:", "file:", "portal:"]; +const ignoredDirectories = new Set([ + ".git", + ".jj", + ".turbo", + "coverage", + "dist", + "node_modules", + "target", +]); + +function parseArgs(argv) { + const options = { root: defaultRoot }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--root") { + options.root = argv[++i]; + continue; + } + if (arg.startsWith("--root=")) { + options.root = arg.slice("--root=".length); + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return { root: resolve(options.root) }; +} + +// True when `target` is the root itself or nested inside it (lexically — the +// path need not exist, which matters because escaping targets are absent in CI). +function isInsideRoot(root, target) { + if (target === root) return true; + const rel = relative(root, target); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + +function localPathFromSpecifier(specifier) { + for (const protocol of localProtocols) { + if (specifier.startsWith(protocol)) { + return specifier.slice(protocol.length); + } + } + return null; +} + +function checkPackageManifest(root, manifestPath, relPath, violations) { + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + return; + } + const manifestDir = dirname(manifestPath); + for (const section of dependencySections) { + const deps = manifest[section]; + if (!deps || typeof deps !== "object") continue; + for (const [name, specifier] of Object.entries(deps)) { + if (typeof specifier !== "string") continue; + const localPath = localPathFromSpecifier(specifier); + if (localPath === null) continue; + const resolved = resolve(manifestDir, localPath); + if (!isInsideRoot(root, resolved)) { + violations.push( + `${relPath} ${section}."${name}" uses local dep "${specifier}" that escapes the repo`, + ); + } + } + } +} + +// Match `path = "..."` entries in a Cargo.toml (deps + non-dep keys alike; +// in-repo paths pass the escape check, so only escaping ones are flagged). +const cargoPathPattern = /(^|[\s{,])path\s*=\s*"([^"]+)"/g; + +function checkCargoManifest(root, manifestPath, relPath, violations) { + const source = readFileSync(manifestPath, "utf8"); + const manifestDir = dirname(manifestPath); + cargoPathPattern.lastIndex = 0; + let match; + while ((match = cargoPathPattern.exec(source))) { + const localPath = match[2]; + const resolved = resolve(manifestDir, localPath); + if (!isInsideRoot(root, resolved)) { + violations.push( + `${relPath} uses cargo path = "${localPath}" that escapes the repo`, + ); + } + } +} + +function walk(root, dir, violations) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + walk(root, path, violations); + continue; + } + if (!entry.isFile()) continue; + const relPath = relative(root, path).split(sep).join("/"); + if (entry.name === "package.json") { + checkPackageManifest(root, path, relPath, violations); + } else if (entry.name === "Cargo.toml") { + checkCargoManifest(root, path, relPath, violations); + } + } +} + +export function auditLocalDeps(options = {}) { + const root = resolve(options.root ?? defaultRoot); + const violations = []; + if (!existsSync(root)) { + return { root, ok: false, violations: [`${root} does not exist`] }; + } + walk(root, root, violations); + violations.sort(); + return { root, ok: violations.length === 0, violations }; +} + +export function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + const result = auditLocalDeps(options); + if (result.ok) { + console.log("no escaping local deps"); + return 0; + } + console.error("escaping local dependency violations:"); + for (const violation of result.violations) { + console.error(`- ${violation}`); + } + console.error( + "\nCommit pinned/published versions instead of link:/file:/path: deps that point outside the repo.", + ); + return 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = main(); +} diff --git a/scripts/check-no-escaping-local-deps.test.mjs b/scripts/check-no-escaping-local-deps.test.mjs new file mode 100644 index 00000000..458178d7 --- /dev/null +++ b/scripts/check-no-escaping-local-deps.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "check-no-escaping-local-deps.mjs"); + +function withFixture(fn) { + const root = mkdtempSync(join(tmpdir(), "escaping-local-deps-")); + try { + return fn(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function write(root, rel, contents) { + const path = join(root, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +test("passes in-repo local deps (link/file/path inside the repo)", () => { + withFixture((root) => { + write( + root, + "registry/package.json", + JSON.stringify({ + dependencies: { "@secure-exec/core": "link:../packages/core" }, + }), + ); + write( + root, + "tests/fixture/package.json", + JSON.stringify({ dependencies: { lib: "file:./vendor/lib" } }), + ); + write(root, "crates/sidecar/Cargo.toml", '[dependencies]\nkernel = { path = "../kernel" }\n'); + execFileSync(process.execPath, [scriptPath, "--root", root], { stdio: "pipe" }); + }); +}); + +test("rejects a package.json local dep that escapes the repo", () => { + withFixture((root) => { + write( + root, + "packages/core/package.json", + JSON.stringify({ + dependencies: { "@secure-exec/core": "link:../../../secure-exec/packages/core" }, + }), + ); + const result = spawnSync(process.execPath, [scriptPath, "--root", root], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /escapes the repo/); + assert.match(result.stderr, /@secure-exec\/core/); + }); +}); + +test("rejects a cargo path dep that escapes the repo", () => { + withFixture((root) => { + write( + root, + "crates/sidecar/Cargo.toml", + '[dependencies]\nsecure-exec-core = { path = "../../../secure-exec/crates/core" }\n', + ); + const result = spawnSync(process.execPath, [scriptPath, "--root", root], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /escapes the repo/); + }); +}); diff --git a/scripts/check-secure-exec-boundary.mjs b/scripts/check-secure-exec-boundary.mjs index 60a44ec7..9692a908 100644 --- a/scripts/check-secure-exec-boundary.mjs +++ b/scripts/check-secure-exec-boundary.mjs @@ -58,9 +58,16 @@ function readJson(path) { return JSON.parse(readFileSync(path, 'utf8')); } +// Boundary-check scripts and their tests legitimately embed forbidden package +// names and import specifiers as fixtures (the test files literally assert that +// such imports are rejected). Scanning them would flag those fixtures as real +// violations, so skip the boundary script itself and any boundary-check +// `scripts/check-*.test.mjs` file. +const boundaryCheckTestPattern = /^scripts[\\/]check-[\w.-]+\.test\.mjs$/; + function shouldSkipFile(relPath) { return relPath === 'scripts/check-secure-exec-boundary.mjs' || - relPath === 'scripts/check-secure-exec-boundary.test.mjs'; + boundaryCheckTestPattern.test(relPath); } function collectImportSpecifiers(source) { diff --git a/scripts/check-secure-exec-boundary.test.mjs b/scripts/check-secure-exec-boundary.test.mjs index e6806739..8b1b01e7 100644 --- a/scripts/check-secure-exec-boundary.test.mjs +++ b/scripts/check-secure-exec-boundary.test.mjs @@ -51,6 +51,21 @@ test('rejects Agent OS package dependencies', () => { }); }); +test('ignores forbidden specifiers inside boundary-check test fixtures', () => { + withFixture((root) => { + writeJson(root, 'package.json', { name: 'secure-exec-workspace' }); + const testPath = join(root, 'scripts/check-registry-test-runtime-boundary.test.mjs'); + mkdirSync(dirname(testPath), { recursive: true }); + // A boundary-check test legitimately embeds a forbidden import as a + // string fixture; it must not be treated as a real violation. + writeFileSync( + testPath, + 'const fixture = \'import { x } from "@rivet-dev/agentos-core/test/runtime";\\n\';\n', + ); + execFileSync(process.execPath, [scriptPath, '--root', root], { stdio: 'pipe' }); + }); +}); + test('rejects Agent OS Rust crate references', () => { withFixture((root) => { writeJson(root, 'package.json', { name: 'secure-exec-workspace' }); diff --git a/scripts/ci.sh b/scripts/ci.sh index fb2951d6..833c04c0 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -26,6 +26,8 @@ run_step pnpm build run_step pnpm check-types run_step node --test scripts/check-secure-exec-boundary.test.mjs run_step node scripts/check-secure-exec-boundary.mjs +run_step node --test scripts/check-no-escaping-local-deps.test.mjs +run_step node scripts/check-no-escaping-local-deps.mjs run_step pnpm --dir scripts/publish run check-types run_step pnpm --dir scripts/publish test run_step cargo fmt --check