The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
734 lines
28 KiB
Rust
734 lines
28 KiB
Rust
#![allow(
|
|
unused_imports,
|
|
unused_variables,
|
|
unused_mut,
|
|
unreachable_code,
|
|
dead_code
|
|
)]
|
|
//! OS-level sandboxing for Kigi via [nono](https://crates.io/crates/nono).
|
|
//!
|
|
//! Applied once at process startup. Covers in-process `tokio::fs` calls
|
|
//! and child processes. Network is left open at the process level (agent
|
|
//! needs LLM API); child network is blocked per-subprocess via seccomp.
|
|
//!
|
|
//! The `enforce` feature (on by default) pulls in `nono` for
|
|
//! kernel-enforced sandboxing (Landlock/Seatbelt). When disabled, the
|
|
//! crate still provides lightweight helpers (`log_violation`,
|
|
//! `should_restrict_child_network`, `child_net`) that compile on all
|
|
//! targets including musl.
|
|
//!
|
|
//! ```rust,no_run
|
|
//! use kigi_sandbox::{SandboxManager, ProfileName};
|
|
//! use std::path::Path;
|
|
//!
|
|
//! let workspace = Path::new("/home/user/project");
|
|
//! let mut sandbox = SandboxManager::new(ProfileName::Workspace, workspace);
|
|
//! sandbox.apply(workspace).expect("sandbox apply failed");
|
|
//! sandbox.install();
|
|
//! ```
|
|
pub mod child_net;
|
|
mod deny;
|
|
mod logging;
|
|
mod paths;
|
|
mod profiles;
|
|
mod types;
|
|
pub use logging::SandboxLogger;
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
use nono::Sandbox;
|
|
pub use profiles::{
|
|
ProfileName, SandboxConfig, SandboxProfile, load_sandbox_config, sandbox_profile_conflicts,
|
|
};
|
|
use std::path::Path;
|
|
#[cfg(any(target_os = "linux", all(feature = "enforce", test)))]
|
|
use std::path::PathBuf;
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics};
|
|
static SANDBOX: OnceLock<GlobalSandboxState> = OnceLock::new();
|
|
static CONFIGURED_PROFILE: OnceLock<String> = OnceLock::new();
|
|
static RESTRICT_CHILD_NETWORK: AtomicBool = AtomicBool::new(false);
|
|
static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false);
|
|
const BWRAP_ENV_VAR: &str = "__KIGI_INSIDE_BWRAP";
|
|
pub fn is_inside_bwrap() -> bool {
|
|
std::env::var(BWRAP_ENV_VAR).is_ok()
|
|
}
|
|
pub fn trust_bwrap_marker_for_devbox() -> bool {
|
|
false
|
|
}
|
|
struct GlobalSandboxState {
|
|
profile: String,
|
|
logger: SandboxLogger,
|
|
applied: bool,
|
|
}
|
|
/// Whether child subprocesses should have network blocked via seccomp.
|
|
pub fn should_restrict_child_network() -> bool {
|
|
RESTRICT_CHILD_NETWORK.load(Ordering::Relaxed)
|
|
}
|
|
/// Whether bash commands should be auto-approved when the sandbox is active.
|
|
pub fn should_auto_allow_bash() -> bool {
|
|
AUTO_ALLOW_BASH.load(Ordering::Relaxed) && is_active()
|
|
}
|
|
pub fn set_auto_allow_bash(enabled: bool) {
|
|
AUTO_ALLOW_BASH.store(enabled, Ordering::Relaxed);
|
|
}
|
|
/// Record the resolved sandbox profile at process startup (including `"off"`).
|
|
pub fn set_configured_profile(name: impl Into<String>) {
|
|
let _ = CONFIGURED_PROFILE.set(name.into());
|
|
}
|
|
/// Resolved sandbox profile from startup, or `None` if `set_configured_profile` was never called.
|
|
pub fn configured_profile_name() -> Option<&'static str> {
|
|
CONFIGURED_PROFILE.get().map(|s| s.as_str())
|
|
}
|
|
/// Whether the sandbox was successfully applied to this process.
|
|
pub fn is_active() -> bool {
|
|
SANDBOX.get().is_some_and(|s| s.applied)
|
|
}
|
|
/// The active sandbox profile name, or `None` if sandbox is not applied.
|
|
pub fn profile_name() -> Option<&'static str> {
|
|
SANDBOX
|
|
.get()
|
|
.filter(|s| s.applied)
|
|
.map(|s| s.profile.as_str())
|
|
}
|
|
/// Log a sandbox violation. Immediately flushed to disk.
|
|
/// No-op if sandbox is not active.
|
|
pub fn log_violation(target: &str, operation: &str) {
|
|
if let Some(state) = SANDBOX.get() {
|
|
state.logger.log(SandboxEvent::fs_violation(
|
|
&state.profile,
|
|
target,
|
|
operation,
|
|
));
|
|
let _ = state.logger.flush_to_disk();
|
|
}
|
|
}
|
|
/// Flush sandbox events to disk. No-op if not initialized.
|
|
pub fn flush() {
|
|
if let Some(state) = SANDBOX.get()
|
|
&& let Err(e) = state.logger.flush_to_disk()
|
|
{
|
|
tracing::warn!(error = % e, "Failed to flush sandbox events to disk");
|
|
}
|
|
}
|
|
/// Violation metrics, or `None` if sandbox is not active.
|
|
pub fn metrics() -> Option<&'static SandboxMetrics> {
|
|
SANDBOX.get().map(|s| s.logger.metrics())
|
|
}
|
|
/// Manages the OS-level sandbox. Call `apply()` then `install()`.
|
|
pub struct SandboxManager {
|
|
profile: ProfileName,
|
|
logger: SandboxLogger,
|
|
net_restricted: bool,
|
|
applied: bool,
|
|
}
|
|
impl SandboxManager {
|
|
/// Create a sandbox manager. Does not apply until `apply()` is called.
|
|
pub fn new(profile: ProfileName, _workspace: &Path) -> Self {
|
|
let net_restricted = profile.restricts_network();
|
|
Self {
|
|
profile,
|
|
logger: SandboxLogger::new(),
|
|
net_restricted,
|
|
applied: false,
|
|
}
|
|
}
|
|
/// Apply the sandbox to the current process. **Irreversible.**
|
|
/// Degrades gracefully if the platform doesn't support it.
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
pub fn apply(&mut self, workspace: &Path) -> anyhow::Result<()> {
|
|
if self.profile == ProfileName::Off {
|
|
tracing::info!("Sandbox disabled (profile: off)");
|
|
return Ok(());
|
|
}
|
|
let support = Sandbox::support_info();
|
|
if !support.is_supported {
|
|
tracing::warn!(
|
|
details = % support.details,
|
|
"Sandbox not supported on this platform, continuing without sandbox"
|
|
);
|
|
self.logger.log(SandboxEvent::apply_failed(
|
|
&self.profile.to_string(),
|
|
workspace,
|
|
&support.details,
|
|
));
|
|
return Ok(());
|
|
}
|
|
let config = profiles::load_sandbox_config(workspace);
|
|
let caps = self
|
|
.profile
|
|
.to_capability_set_with_config(workspace, &config)?;
|
|
let mut resolved = self.profile.resolve_profile(workspace, &config)?;
|
|
resolved.deny = deny::effective_deny_paths(workspace, &resolved.deny);
|
|
self.net_restricted = self.profile.restricts_network_resolved(&config);
|
|
match Sandbox::apply(&caps) {
|
|
Ok(_) => {
|
|
self.applied = true;
|
|
if self.net_restricted {
|
|
RESTRICT_CHILD_NETWORK.store(true, Ordering::Relaxed);
|
|
}
|
|
self.logger.log(SandboxEvent::profile_applied(
|
|
&self.profile.to_string(),
|
|
workspace,
|
|
&resolved,
|
|
));
|
|
tracing::info!(
|
|
profile = % self.profile, workspace = % workspace.display(),
|
|
restrict_network = self.net_restricted,
|
|
"Sandbox applied (kernel-enforced, irreversible)"
|
|
);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
profile = % self.profile, error = % e,
|
|
"Sandbox could not be applied, continuing without sandbox"
|
|
);
|
|
self.logger.log(SandboxEvent::apply_failed(
|
|
&self.profile.to_string(),
|
|
workspace,
|
|
&e,
|
|
));
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
/// Stub when `enforce` feature is disabled — sandbox is not applied.
|
|
#[cfg(not(all(feature = "enforce", unix)))]
|
|
pub fn apply(&mut self, _workspace: &Path) -> anyhow::Result<()> {
|
|
tracing::info!(
|
|
profile = % self.profile,
|
|
"Sandbox enforcement unavailable (built without 'enforce' feature)"
|
|
);
|
|
Ok(())
|
|
}
|
|
/// Store globally for session-lifetime violation logging.
|
|
pub fn install(self) {
|
|
let _ = self.logger.flush_to_disk();
|
|
let _ = SANDBOX.set(GlobalSandboxState {
|
|
profile: self.profile.to_string(),
|
|
logger: self.logger,
|
|
applied: self.applied,
|
|
});
|
|
}
|
|
/// Check whether the current platform supports sandboxing.
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
pub fn support_info() -> nono::SupportInfo {
|
|
Sandbox::support_info()
|
|
}
|
|
/// Whether the sandbox was successfully applied.
|
|
pub fn is_applied(&self) -> bool {
|
|
self.applied
|
|
}
|
|
/// Whether child subprocesses should have network blocked.
|
|
pub fn restrict_child_network(&self) -> bool {
|
|
self.applied && self.net_restricted
|
|
}
|
|
/// The active profile name.
|
|
pub fn profile(&self) -> &ProfileName {
|
|
&self.profile
|
|
}
|
|
/// Access the sandbox event logger (before `install()`).
|
|
pub fn logger(&self) -> &SandboxLogger {
|
|
&self.logger
|
|
}
|
|
}
|
|
/// Build a bwrap command that re-execs the current process with
|
|
/// `deny_write` paths mounted read-only and `deny_read` paths bound
|
|
/// over with an unreadable placeholder (EPERM on read).
|
|
///
|
|
/// Returns `None` if already inside bwrap. Caller should `cmd.exec()` the result.
|
|
pub fn bwrap_reexec_command(
|
|
deny_write: &[&str],
|
|
deny_read: &[&str],
|
|
) -> Option<std::process::Command> {
|
|
if is_inside_bwrap() {
|
|
return None;
|
|
}
|
|
let self_exe = std::env::current_exe().ok()?;
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let mut cmd = std::process::Command::new("bwrap");
|
|
cmd.arg("--bind").arg("/").arg("/");
|
|
for path in deny_write {
|
|
if Path::new(path).exists() {
|
|
cmd.arg("--ro-bind").arg(path).arg(path);
|
|
}
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
if !deny_read.is_empty() {
|
|
for path in deny_read {
|
|
let Some(blocked) = bwrap_blocked_source_for_path(Path::new(path)) else {
|
|
eprintln!(
|
|
"error: could not create bwrap placeholder for read-deny path {path}; \
|
|
refusing to start with a partial sandbox"
|
|
);
|
|
return None;
|
|
};
|
|
cmd.arg("--ro-bind").arg(&blocked).arg(path);
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
let _ = deny_read;
|
|
cmd.arg("--dev-bind").arg("/dev").arg("/dev");
|
|
cmd.arg("--proc").arg("/proc");
|
|
cmd.env(BWRAP_ENV_VAR, "1");
|
|
cmd.arg("--").arg(self_exe).args(args);
|
|
Some(cmd)
|
|
}
|
|
/// Choose file vs directory placeholder for a deny path (existing dirs need a dir bind).
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_blocked_source_for_path(path: &Path) -> Option<PathBuf> {
|
|
if deny::deny_path_is_dir(path) {
|
|
bwrap_blocked_placeholder("sandbox-blocked-dir", true)
|
|
} else {
|
|
bwrap_blocked_placeholder("sandbox-blocked", false)
|
|
}
|
|
}
|
|
/// Without kernel enforcement there are no read-deny placeholders to bind over.
|
|
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
|
fn bwrap_blocked_source_for_path(_path: &Path) -> Option<PathBuf> {
|
|
None
|
|
}
|
|
/// chmod a placeholder to mode 000 so a bwrap bind-over yields EPERM on read.
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn chmod_000(path: &Path) -> Option<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(path).ok()?.permissions();
|
|
perms.set_mode(0o000);
|
|
std::fs::set_permissions(path, perms).ok()?;
|
|
Some(())
|
|
}
|
|
/// Zero-permission placeholder (file or dir) under `kigi_home` used by bwrap bind-over.
|
|
///
|
|
/// The placeholder name is suffixed with the current PID so concurrent kigi
|
|
/// processes don't race each other's create/remove/chmod on a shared path (which
|
|
/// could yield `None` and the silent dropped-bind fail-open this avoids).
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_blocked_placeholder(name: &str, want_dir: bool) -> Option<PathBuf> {
|
|
use std::fs::OpenOptions;
|
|
let path = paths::kigi_home().join(format!("{name}.{}", std::process::id()));
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).ok()?;
|
|
}
|
|
if path.exists() {
|
|
if path.is_dir() == want_dir {
|
|
chmod_000(&path)?;
|
|
return Some(path);
|
|
}
|
|
if path.is_dir() {
|
|
std::fs::remove_dir_all(&path).ok()?;
|
|
} else {
|
|
std::fs::remove_file(&path).ok()?;
|
|
}
|
|
}
|
|
if want_dir {
|
|
std::fs::create_dir(&path).ok()?;
|
|
} else {
|
|
OpenOptions::new()
|
|
.create(true)
|
|
.truncate(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.ok()?;
|
|
}
|
|
chmod_000(&path)?;
|
|
Some(path)
|
|
}
|
|
/// Whether a profile write-denies `/data` via the devbox bwrap bind (built-in
|
|
/// `devbox` or a custom profile that `extends = "devbox"`). This is a pure mount,
|
|
/// so it applies even WITHOUT the `enforce` feature.
|
|
#[cfg(target_os = "linux")]
|
|
fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
|
|
match profile {
|
|
ProfileName::Devbox => true,
|
|
ProfileName::Custom(name) => {
|
|
config.profiles.get(name).and_then(|p| p.extends.as_deref()) == Some("devbox")
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
/// Whether kernel read-deny enforcement is required. The single source of truth
|
|
/// for this classification so callers (e.g. the shell's fail-closed startup path)
|
|
/// cannot drift and silently fail open.
|
|
///
|
|
/// Decided directly from the profile config (a `Custom` profile with a non-empty
|
|
/// `deny`) — NOT from the resolved/expanded deny set, which returns empty on
|
|
/// failure. Keying "requires" on that empty-on-error result would silently
|
|
/// downgrade to fail-open (Linux) when resolution hiccups; this intrinsic check
|
|
/// stays fail-closed.
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
|
match profile {
|
|
ProfileName::Custom(name) => {
|
|
let config = profiles::load_sandbox_config(workspace);
|
|
config
|
|
.profiles
|
|
.get(name)
|
|
.is_some_and(|p| !p.deny.is_empty())
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
/// Stub when `enforce` is unavailable — nothing is kernel-enforced.
|
|
#[cfg(not(all(feature = "enforce", unix)))]
|
|
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
|
|
false
|
|
}
|
|
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
|
|
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
|
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
|
|
/// current matches — globs are best-effort on Linux).
|
|
#[cfg(target_os = "linux")]
|
|
struct BwrapDenyPlan {
|
|
deny_write: Vec<String>,
|
|
deny_read: Vec<String>,
|
|
has_globs: bool,
|
|
}
|
|
/// Resolve a profile's full [`BwrapDenyPlan`] in ONE config read: the `/data`
|
|
/// write-deny (devbox and devbox-extending customs), the exact read-deny paths,
|
|
/// and the launch-time glob expansion. Returns `None` (fail closed) if a deny
|
|
/// glob blows past the expansion caps or is invalid, so
|
|
/// [`bwrap_reexec_for_profile`] refuses to start.
|
|
///
|
|
/// Best-effort on Linux: a mount namespace can't glob at runtime, so globs are
|
|
/// expanded once here at launch — files matching them that are created LATER are
|
|
/// NOT covered (macOS Seatbelt enforces the same globs as runtime regexes).
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
|
let config = profiles::load_sandbox_config(workspace);
|
|
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
|
vec!["/data".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let entries = if *profile == ProfileName::Off {
|
|
Vec::new()
|
|
} else {
|
|
profile
|
|
.resolve_profile(workspace, &config)
|
|
.map(|r| r.deny)
|
|
.unwrap_or_default()
|
|
};
|
|
let (exact, globs) = deny::partition_deny_entries(&entries);
|
|
let mut deny_read = deny::exact_deny_path_strings(workspace, &exact);
|
|
let has_globs = !globs.is_empty();
|
|
if has_globs {
|
|
tracing::warn!(
|
|
count = globs.len(),
|
|
"sandbox deny globs are enforced best-effort on Linux (expanded at launch); \
|
|
files matching them that are created later are NOT covered"
|
|
);
|
|
deny_read.extend(deny::expand_deny_globs(
|
|
workspace,
|
|
&globs,
|
|
deny::DENY_GLOB_MAX_DEPTH,
|
|
deny::DENY_GLOB_MAX_MATCHES,
|
|
deny::DENY_GLOB_MAX_ENTRIES,
|
|
)?);
|
|
}
|
|
Some(BwrapDenyPlan {
|
|
deny_write,
|
|
deny_read,
|
|
has_globs,
|
|
})
|
|
}
|
|
/// Stub when `enforce` is unavailable on Linux: read-deny needs nono, so there is
|
|
/// none — but the devbox `/data` write-deny is a plain bwrap mount and MUST still
|
|
/// apply (devbox `/data` is always sandboxed), so it is preserved here.
|
|
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
|
let config = profiles::load_sandbox_config(workspace);
|
|
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
|
vec!["/data".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Some(BwrapDenyPlan {
|
|
deny_write,
|
|
deny_read: Vec::new(),
|
|
has_globs: false,
|
|
})
|
|
}
|
|
/// Build the bwrap re-exec command needed on Linux, or `None` if no mount-namespace
|
|
/// enforcement is needed (or we are already inside bwrap). Canonical routing:
|
|
/// devbox — and a custom profile that `extends = "devbox"` — gets write-deny on
|
|
/// `/data`; any profile gets read-deny on its own `deny` set. These compose, so a
|
|
/// devbox-based custom profile with a `deny` list write-denies `/data` AND
|
|
/// read-denies its deny paths in one re-exec.
|
|
///
|
|
/// Glob deny entries are expanded to concrete existing matches at launch and
|
|
/// bound over too (best-effort; post-launch matches are not covered on Linux).
|
|
/// Returns `None` (fail closed) if a glob blows past the expansion caps, so the
|
|
/// shell's startup refuses to run with a broad glob under-enforced.
|
|
#[cfg(target_os = "linux")]
|
|
pub fn bwrap_reexec_for_profile(
|
|
profile: &ProfileName,
|
|
workspace: &Path,
|
|
) -> Option<std::process::Command> {
|
|
let BwrapDenyPlan {
|
|
deny_write,
|
|
deny_read,
|
|
has_globs,
|
|
} = bwrap_deny_plan(profile, workspace)?;
|
|
if deny_write.is_empty() && deny_read.is_empty() && !has_globs {
|
|
return None;
|
|
}
|
|
let write_refs: Vec<&str> = deny_write.iter().map(String::as_str).collect();
|
|
let read_refs: Vec<&str> = deny_read.iter().map(String::as_str).collect();
|
|
bwrap_reexec_command(&write_refs, &read_refs)
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serial_test::serial;
|
|
/// Save, set/remove, and auto-restore an env var on drop.
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
prev: Option<String>,
|
|
}
|
|
impl EnvGuard {
|
|
fn set(key: &'static str, val: &str) -> Self {
|
|
let prev = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, val) };
|
|
Self { key, prev }
|
|
}
|
|
fn remove(key: &'static str) -> Self {
|
|
let prev = std::env::var(key).ok();
|
|
unsafe { std::env::remove_var(key) };
|
|
Self { key, prev }
|
|
}
|
|
}
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match &self.prev {
|
|
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
|
None => unsafe { std::env::remove_var(self.key) },
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn bwrap_reexec_returns_none_inside_bwrap() {
|
|
let _g = EnvGuard::set(BWRAP_ENV_VAR, "1");
|
|
let result = bwrap_reexec_command(&["/data"], &[]);
|
|
assert!(
|
|
result.is_none(),
|
|
"should return None when already inside bwrap"
|
|
);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn bwrap_reexec_returns_some_outside_bwrap() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
|
assert!(result.is_some(), "should return Some when not inside bwrap");
|
|
let cmd = result.unwrap();
|
|
assert_eq!(cmd.get_program(), "bwrap", "program should be bwrap");
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn trust_bwrap_marker_for_devbox_tracks_env_when_feature_on() {
|
|
let _g = EnvGuard::set(BWRAP_ENV_VAR, "1");
|
|
assert!(
|
|
!trust_bwrap_marker_for_devbox(),
|
|
"without bwrap-marker the hatch must stay closed"
|
|
);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn trust_bwrap_marker_for_devbox_false_outside_bwrap() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
assert!(!trust_bwrap_marker_for_devbox());
|
|
assert!(!is_inside_bwrap());
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn bwrap_reexec_skips_nonexistent_paths() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let result = bwrap_reexec_command(&["/nonexistent-test-path-xyz-12345"], &[]);
|
|
let cmd = result.unwrap();
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
assert!(
|
|
!args.iter().any(|a| a == "/nonexistent-test-path-xyz-12345"),
|
|
"should skip non-existent deny_write paths, got args: {args:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_reexec_binds_nonexistent_deny_read_paths() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let missing = "/nonexistent-deny-read-path-xyz-12345";
|
|
let result = bwrap_reexec_command(&[], &[missing]);
|
|
let cmd = result.unwrap();
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
let has_bind = args
|
|
.windows(3)
|
|
.any(|w| w[0] == "--ro-bind" && w[2] == missing);
|
|
assert!(
|
|
has_bind,
|
|
"should bind-over non-existent deny_read paths, got args: {args:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn bwrap_reexec_mounts_existing_paths_read_only() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
|
let cmd = result.unwrap();
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
let has_ro_bind = args.windows(3).any(|w| w == ["--ro-bind", "/tmp", "/tmp"]);
|
|
assert!(
|
|
has_ro_bind,
|
|
"should mount existing paths as --ro-bind, got args: {args:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
fn bwrap_reexec_uses_dev_bind() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let result = bwrap_reexec_command(&[], &[]);
|
|
let cmd = result.unwrap();
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
let has_dev_bind = args.windows(3).any(|w| w == ["--dev-bind", "/dev", "/dev"]);
|
|
assert!(
|
|
has_dev_bind,
|
|
"should use --dev-bind for /dev passthrough, got args: {args:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn configured_profile_is_recorded() {
|
|
set_configured_profile("read-only");
|
|
assert_eq!(configured_profile_name(), Some("read-only"));
|
|
}
|
|
/// Create a temp workspace whose `.kigi/sandbox.toml` contains `toml_body`.
|
|
/// Returns the workspace path (caller removes it).
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
fn temp_workspace_with_sandbox_toml(tag: &str, toml_body: &str) -> PathBuf {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let ws = std::env::temp_dir().join(format!("kigi-{tag}-{}-{nanos}", std::process::id()));
|
|
let kigi = ws.join(".kigi");
|
|
std::fs::create_dir_all(&kigi).unwrap();
|
|
std::fs::write(kigi.join("sandbox.toml"), toml_body).unwrap();
|
|
ws
|
|
}
|
|
/// Create a temp workspace defining a `denytest` profile (extends `workspace`)
|
|
/// with the given `deny` list. `deny_toml` is the raw TOML array body
|
|
/// (e.g. `"\".env\""`).
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
fn temp_workspace_with_deny(tag: &str, deny_toml: &str) -> PathBuf {
|
|
temp_workspace_with_sandbox_toml(
|
|
tag,
|
|
&format!("[profiles.denytest]\nextends = \"workspace\"\ndeny = [{deny_toml}]\n"),
|
|
)
|
|
}
|
|
#[test]
|
|
#[cfg(all(feature = "enforce", unix))]
|
|
fn requires_read_deny_only_for_custom_profile_with_deny() {
|
|
let ws = temp_workspace_with_deny("requires-deny", "\".env\"");
|
|
assert!(requires_read_deny(
|
|
&ProfileName::Custom("denytest".to_string()),
|
|
&ws
|
|
));
|
|
assert!(!requires_read_deny(
|
|
&ProfileName::Custom("undefined".to_string()),
|
|
&ws
|
|
));
|
|
assert!(!requires_read_deny(&ProfileName::Workspace, &ws));
|
|
assert!(!requires_read_deny(&ProfileName::Strict, &ws));
|
|
assert!(!requires_read_deny(&ProfileName::Devbox, &ws));
|
|
assert!(!requires_read_deny(&ProfileName::Off, &ws));
|
|
let _ = std::fs::remove_dir_all(&ws);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_reexec_uses_dir_placeholder_for_directories() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let dir = std::env::temp_dir().join(format!("kigi-deny-dir-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let dir_str = dir.to_string_lossy().to_string();
|
|
let result = bwrap_reexec_command(&[], &[&dir_str]);
|
|
let cmd = result.unwrap();
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
let blocked_dir = paths::kigi_home()
|
|
.join(format!("sandbox-blocked-dir.{}", std::process::id()))
|
|
.to_string_lossy()
|
|
.to_string();
|
|
let has_dir_bind = args
|
|
.windows(3)
|
|
.any(|w| w[0] == "--ro-bind" && w[1] == blocked_dir && w[2] == dir_str);
|
|
assert!(
|
|
has_dir_bind,
|
|
"existing directories should bind over sandbox-blocked-dir, got args: {args:?}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
#[test]
|
|
#[serial(bwrap_env)]
|
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
|
fn bwrap_reexec_for_profile_devbox_extends_composes_data_and_read_deny() {
|
|
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
|
let ws = temp_workspace_with_sandbox_toml(
|
|
"devbox-compose",
|
|
"[profiles.devcustom]\nextends = \"devbox\"\ndeny = [\"secret.pem\"]\n",
|
|
);
|
|
let cmd = bwrap_reexec_for_profile(&ProfileName::Custom("devcustom".to_string()), &ws)
|
|
.expect("devbox-extending custom with deny should build a re-exec command");
|
|
let args: Vec<String> = cmd
|
|
.get_args()
|
|
.map(|a| a.to_string_lossy().to_string())
|
|
.collect();
|
|
let deny_path = ws.join("secret.pem").to_string_lossy().to_string();
|
|
assert!(
|
|
args.windows(3)
|
|
.any(|w| w[0] == "--ro-bind" && w[2] == deny_path),
|
|
"expected read-deny bind for {deny_path}, got args: {args:?}"
|
|
);
|
|
if Path::new("/data").exists() {
|
|
assert!(
|
|
args.windows(3)
|
|
.any(|w| w == ["--ro-bind", "/data", "/data"]),
|
|
"expected /data write-deny ro-bind, got args: {args:?}"
|
|
);
|
|
}
|
|
let _ = std::fs::remove_dir_all(&ws);
|
|
let ws_empty = temp_workspace_with_sandbox_toml(
|
|
"devbox-empty",
|
|
"[profiles.devempty]\nextends = \"devbox\"\n",
|
|
);
|
|
assert!(
|
|
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
|
.is_some(),
|
|
"devbox-extending custom must compose the /data write-deny re-exec"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&ws_empty);
|
|
let ws_ws = temp_workspace_with_sandbox_toml(
|
|
"ws-empty",
|
|
"[profiles.wsempty]\nextends = \"workspace\"\n",
|
|
);
|
|
assert!(
|
|
bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_none(),
|
|
"non-devbox custom with no deny needs no re-exec"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&ws_ws);
|
|
}
|
|
}
|