M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
#![allow(
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_mut,
|
||||
unreachable_code,
|
||||
dead_code
|
||||
)]
|
||||
//! OS-level sandboxing for Grok Build 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 = "__GROK_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 grok
|
||||
/// 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!("grok-{tag}-{}-{nanos}", std::process::id()));
|
||||
let grok = ws.join(".kigi");
|
||||
std::fs::create_dir_all(&grok).unwrap();
|
||||
std::fs::write(grok.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!("grok-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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user