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,447 @@
|
||||
//! E2E enforcement tests for kernel-enforced profile `deny` paths.
|
||||
//!
|
||||
//! Drives the GENERIC path-deny primitive via a custom sandbox profile whose
|
||||
//! `deny` list names concrete files. `SandboxManager::apply` is process-wide and
|
||||
//! irreversible, so kernel enforcement is verified in an isolated subprocess.
|
||||
//!
|
||||
//! On Linux, read-deny requires bwrap bind-over; the subprocess re-execs inside
|
||||
//! bwrap when `bwrap` is available. macOS uses Seatbelt platform rules directly
|
||||
//! via `SandboxManager::apply`.
|
||||
|
||||
#![cfg(all(unix, feature = "enforce"))]
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const SCENARIO_ENV: &str = "SANDBOX_E2E_SCENARIO";
|
||||
const WORKSPACE_ENV: &str = "SANDBOX_E2E_WORKSPACE";
|
||||
/// Custom profile name, comma-joined deny targets, and comma-joined control
|
||||
/// files, passed to the subprocess so one entry point drives every deny case
|
||||
/// (exact paths and globs alike).
|
||||
const PROFILE_ENV: &str = "SANDBOX_E2E_PROFILE";
|
||||
const TARGETS_ENV: &str = "SANDBOX_E2E_TARGETS";
|
||||
const CONTROLS_ENV: &str = "SANDBOX_E2E_CONTROLS";
|
||||
/// Paths NOT present at apply time that match a deny glob; the macOS runtime
|
||||
/// regex must deny creating them post-launch (the differentiator vs exact paths).
|
||||
const POSTLAUNCH_ENV: &str = "SANDBOX_E2E_POSTLAUNCH";
|
||||
const MARKER: &str = "deny-paths-e2e-marker-9f3c1a";
|
||||
|
||||
/// Re-invoke this test binary as a subprocess driving `profile` over `targets`
|
||||
/// (denied) and `controls` (must stay readable). `postlaunch` paths are created
|
||||
/// AFTER apply to exercise the macOS runtime-regex (post-launch) coverage.
|
||||
fn run_scenario(
|
||||
workspace: &Path,
|
||||
profile: &str,
|
||||
targets: &[&str],
|
||||
controls: &[&str],
|
||||
postlaunch: &[&str],
|
||||
) -> (std::process::ExitStatus, String) {
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let output = Command::new(exe)
|
||||
.env(SCENARIO_ENV, "block_deny")
|
||||
.env(WORKSPACE_ENV, workspace.as_os_str())
|
||||
.env(PROFILE_ENV, profile)
|
||||
.env(TARGETS_ENV, targets.join(","))
|
||||
.env(CONTROLS_ENV, controls.join(","))
|
||||
.env(POSTLAUNCH_ENV, postlaunch.join(","))
|
||||
.arg("--ignored")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.arg("subprocess_entry")
|
||||
.output()
|
||||
.expect("failed to spawn subprocess");
|
||||
// All assertions read stderr; the subprocess prints only diagnostics there.
|
||||
(
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode a comma-joined env list (empty/missing -> empty vec).
|
||||
fn list_from_env(key: &str) -> Vec<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// EROFS too: a root writer on Linux bypasses the mode-000 DAC check via
|
||||
// CAP_DAC_OVERRIDE and hits the read-only bind-mount instead — still a denial.
|
||||
fn is_permission_denied(e: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
e.raw_os_error(),
|
||||
Some(libc::EACCES) | Some(libc::EPERM) | Some(libc::EROFS)
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a child command and `exit(1)` if its stdout exposes the secret MARKER.
|
||||
/// Asserts marker-absence rather than a non-zero exit: a root reader of the
|
||||
/// mode-000 placeholder gets empty output, which still means the path is shadowed.
|
||||
fn assert_child_cannot_read(label: &str, program: &str, args: &[&str]) {
|
||||
let out = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("failed to spawn {program}: {e}"));
|
||||
if String::from_utf8_lossy(&out.stdout).contains(MARKER) {
|
||||
eprintln!("FAIL: {label} exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert a denied file's bytes are unreadable via an in-process read, a `cat`
|
||||
/// child (the `bash`/`grep` tools), and a nested `sh -c "cat"` child (the shell a
|
||||
/// subagent shells out through). The property is MARKER-absence (EACCES/EPERM, or
|
||||
/// empty output under root, all satisfy it).
|
||||
fn assert_read_blocked(label: &str, path: &Path) {
|
||||
if let Ok(content) = fs::read_to_string(path)
|
||||
&& content.contains(MARKER)
|
||||
{
|
||||
eprintln!("FAIL: {label} in-process read exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let s = path.display().to_string();
|
||||
assert_child_cannot_read(label, "cat", &[s.as_str()]);
|
||||
let sh_cmd = format!("cat '{s}'");
|
||||
assert_child_cannot_read(label, "sh", &["-c", sh_cmd.as_str()]);
|
||||
eprintln!("OK: {label} read blocked");
|
||||
}
|
||||
|
||||
/// Assert a denied file cannot be overwritten (write must EACCES/EPERM, not
|
||||
/// succeed — a permitted write would enable the relocation bypass below).
|
||||
fn assert_write_denied(label: &str, path: &Path) {
|
||||
match fs::write(path, "overwrite-attempt") {
|
||||
Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} write denied"),
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: unexpected {label} write error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(()) => {
|
||||
eprintln!("FAIL: {label} write was permitted (relocation bypass possible)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert the `mv x y && cat y` relocation bypass does not expose the bytes:
|
||||
/// the rename must fail (unlink of the source is denied) so the moved copy never
|
||||
/// materializes with the secret.
|
||||
fn assert_rename_bypass_blocked(label: &str, path: &Path, workspace: &Path) {
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
let moved = workspace.join(format!("exfil-{name}"));
|
||||
let _ = fs::rename(path, &moved); // expected to fail; bytes must not leak
|
||||
match fs::read_to_string(&moved) {
|
||||
Ok(c) if c.contains(MARKER) => {
|
||||
eprintln!("FAIL: {label} rename bypass exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => eprintln!("OK: {label} rename bypass blocked"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_available() -> bool {
|
||||
// `--version` only checks the binary exists; remote CI may have bwrap but
|
||||
// deny user namespace creation ("Creating new namespace failed: Operation not permitted").
|
||||
Command::new("bwrap")
|
||||
.args(["--bind", "/", "/", "--", "true"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The custom profile under test, read from the env the parent set.
|
||||
fn profile_from_env() -> kigi_sandbox::ProfileName {
|
||||
kigi_sandbox::ProfileName::Custom(std::env::var(PROFILE_ENV).expect(PROFILE_ENV))
|
||||
}
|
||||
|
||||
// ── Subprocess entry point ──────────────────────────────────────────────
|
||||
|
||||
/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn subprocess_entry() {
|
||||
let scenario = match std::env::var(SCENARIO_ENV) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let workspace = std::env::var(WORKSPACE_ENV).expect(WORKSPACE_ENV);
|
||||
let workspace = dunce::canonicalize(&workspace).expect("canonicalize workspace");
|
||||
let workspace = workspace.as_path();
|
||||
let targets = list_from_env(TARGETS_ENV);
|
||||
let controls = list_from_env(CONTROLS_ENV);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !kigi_sandbox::is_inside_bwrap() {
|
||||
// Drive the REAL routing the shell uses at startup — computing the
|
||||
// custom profile's deny set (exact paths AND launch-time glob
|
||||
// expansion), building placeholders, and failing closed on a partial
|
||||
// bind — rather than hand-rolling a single-path `bwrap_reexec_command`.
|
||||
match kigi_sandbox::bwrap_reexec_for_profile(&profile_from_env(), workspace) {
|
||||
Some(mut cmd) => {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = cmd.exec(); // returns only if exec failed
|
||||
eprintln!("bwrap re-exec failed: {err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Outside bwrap with no command means the read-deny set could not
|
||||
// be secured. The shell fails closed here; mirror that.
|
||||
None => {
|
||||
eprintln!("FAIL: bwrap_reexec_for_profile returned None outside bwrap");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match scenario.as_str() {
|
||||
"block_deny" => {
|
||||
let mut sandbox = kigi_sandbox::SandboxManager::new(profile_from_env(), workspace);
|
||||
if let Err(e) = sandbox.apply(workspace) {
|
||||
eprintln!("sandbox apply failed: {e}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
if !sandbox.is_applied() {
|
||||
eprintln!("sandbox was not applied (unsupported platform?)");
|
||||
std::process::exit(4);
|
||||
}
|
||||
|
||||
// Each denied target must be read-, write-, and rename-denied — via the
|
||||
// read_file tool (in-process), `bash`/`grep` (cat child), and the shell
|
||||
// a subagent uses (sh -c child). Targets exercise nested glob matches
|
||||
// (`sub/dir/key.pem`) and the denied-directory (subpath) path alike.
|
||||
for rel in &targets {
|
||||
let path = workspace.join(rel);
|
||||
assert_read_blocked(rel, &path);
|
||||
assert_write_denied(rel, &path);
|
||||
assert_rename_bypass_blocked(rel, &path, workspace);
|
||||
}
|
||||
|
||||
// Non-denied control files (incl. a sibling of a glob match) stay readable.
|
||||
for rel in &controls {
|
||||
match fs::read_to_string(workspace.join(rel)) {
|
||||
Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"),
|
||||
Ok(_) => {
|
||||
eprintln!("FAIL: control {rel} readable but missing marker");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: control {rel} should stay readable: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// macOS-only: the runtime regex denies paths that match a glob even
|
||||
// when created AFTER apply — the differentiator vs the exact-path flow
|
||||
// (and the macOS-airtight half of the documented asymmetry). On Linux
|
||||
// post-launch matches are best-effort and NOT covered, so skip there.
|
||||
#[cfg(target_os = "macos")]
|
||||
for rel in list_from_env(POSTLAUNCH_ENV) {
|
||||
match fs::write(workspace.join(&rel), MARKER) {
|
||||
Err(e) if is_permission_denied(&e) => {
|
||||
eprintln!("OK: {rel} post-launch write denied")
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: unexpected {rel} post-launch write error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(()) => {
|
||||
eprintln!("FAIL: {rel} post-launch matching path was writable");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A NON-matching post-launch path must still be writable — proves the
|
||||
// denial above is the glob, not a blanket workspace write-deny.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !list_from_env(POSTLAUNCH_ENV).is_empty() {
|
||||
match fs::write(workspace.join("late-control.txt"), "hello") {
|
||||
Ok(()) => eprintln!("OK: post-launch control writable"),
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: non-matching post-launch path should be writable: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown scenario: {other}");
|
||||
std::process::exit(99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parent test cases ───────────────────────────────────────────────────
|
||||
|
||||
/// Drive one deny case end-to-end: define a custom profile whose `deny` list is
|
||||
/// `deny_entries` (exact paths and/or globs), create each `target` (with the
|
||||
/// MARKER) and each `control` (readable), then assert in an isolated subprocess
|
||||
/// that every target is read/write/rename-denied and every control stays
|
||||
/// readable. Shared by the exact-path and glob cases.
|
||||
fn run_deny_case(
|
||||
tag: &str,
|
||||
profile: &str,
|
||||
deny_entries: &[&str],
|
||||
targets: &[&str],
|
||||
controls: &[&str],
|
||||
postlaunch: &[&str],
|
||||
) {
|
||||
// When set, missing prerequisites must FAIL loudly instead of skipping, so a
|
||||
// CI lane can guarantee the deny enforcement is actually exercised.
|
||||
let require = std::env::var("SANDBOX_E2E_REQUIRE_ENFORCEMENT").is_ok();
|
||||
|
||||
let support = kigi_sandbox::SandboxManager::support_info();
|
||||
if !support.is_supported {
|
||||
if require {
|
||||
panic!(
|
||||
"SANDBOX_E2E_REQUIRE_ENFORCEMENT set but sandbox unsupported: {}",
|
||||
support.details
|
||||
);
|
||||
}
|
||||
eprintln!("skipping: sandbox not supported ({})", support.details);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if !bwrap_available() {
|
||||
if require {
|
||||
panic!(
|
||||
"SANDBOX_E2E_REQUIRE_ENFORCEMENT set but bwrap unavailable (required for Linux read-deny)"
|
||||
);
|
||||
}
|
||||
eprintln!("skipping: bwrap not installed (required for Linux read-deny)");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"grok-sandbox-e2e-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&tmp).expect("create temp workspace");
|
||||
let tmp = dunce::canonicalize(&tmp).expect("canonicalize temp workspace");
|
||||
let _cleanup = TempDirGuard(tmp.clone());
|
||||
|
||||
// Define the custom profile whose `deny` list holds the entries under test.
|
||||
let deny_list = deny_entries
|
||||
.iter()
|
||||
.map(|p| format!("\"{p}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
fs::create_dir_all(tmp.join(".kigi")).expect("mkdir .kigi");
|
||||
fs::write(
|
||||
tmp.join(".kigi").join("sandbox.toml"),
|
||||
format!("[profiles.{profile}]\nextends = \"workspace\"\ndeny = [{deny_list}]\n"),
|
||||
)
|
||||
.expect("write sandbox.toml");
|
||||
|
||||
// Create each denied target with the MARKER (parents created as needed, e.g.
|
||||
// `sub/dir/` for a nested glob match, `secretdir/` for a denied directory)
|
||||
// plus each readable control.
|
||||
for rel in targets {
|
||||
let path = tmp.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("mkdir denied parent");
|
||||
}
|
||||
fs::write(&path, format!("SECRET={MARKER}")).expect("write denied file");
|
||||
}
|
||||
for rel in controls {
|
||||
let path = tmp.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("mkdir control parent");
|
||||
}
|
||||
fs::write(&path, "hello workspace").expect("write control");
|
||||
}
|
||||
|
||||
let (status, stderr) = run_scenario(&tmp, profile, targets, controls, postlaunch);
|
||||
assert!(
|
||||
status.success(),
|
||||
"[{tag}] custom-profile deny should block read/write/rename\nstderr: {stderr}"
|
||||
);
|
||||
for rel in targets {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} read blocked")),
|
||||
"[{tag}] expected '{rel}' read block confirmation\nstderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} write denied")),
|
||||
"[{tag}] expected '{rel}' write to be denied\nstderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} rename bypass blocked")),
|
||||
"[{tag}] expected '{rel}' rename bypass to be blocked\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
for rel in controls {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} control readable")),
|
||||
"[{tag}] expected non-denied control '{rel}' to stay readable\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
// The post-launch (runtime-regex) coverage is macOS-only; Linux best-effort
|
||||
// expansion does not cover files created after launch.
|
||||
#[cfg(target_os = "macos")]
|
||||
for rel in postlaunch {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} post-launch write denied")),
|
||||
"[{tag}] expected post-launch matching '{rel}' to be write-denied\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if !postlaunch.is_empty() {
|
||||
assert!(
|
||||
stderr.contains("OK: post-launch control writable"),
|
||||
"[{tag}] expected non-matching post-launch path to stay writable\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_exact_paths_block_read_write_rename() {
|
||||
// Exact-path entries: two files plus a directory (exercised via a file inside
|
||||
// it), covering the literal-file and the subpath / Linux dir-placeholder paths.
|
||||
run_deny_case(
|
||||
"exact",
|
||||
"denytest",
|
||||
&[".env", "src/server.pem", "secretdir"],
|
||||
&[".env", "src/server.pem", "secretdir/inner.pem"],
|
||||
&["readable.txt"],
|
||||
&[], // exact paths have no runtime/post-launch coverage to assert
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_globs_block_read_write_rename() {
|
||||
// Glob entries exercising: nested `*.pem`, a `.env` at root AND nested, and a
|
||||
// trailing-`**` prefix dir. The control inside a matched directory
|
||||
// (`sub/dir/keep.txt`) proves the glob denies only matches, not the whole tree.
|
||||
// `postlaunch` (`late.pem`) pins the macOS runtime-regex post-launch coverage.
|
||||
run_deny_case(
|
||||
"glob",
|
||||
"denyglob",
|
||||
&["**/*.pem", "**/.env", "secrets/**"],
|
||||
&["sub/dir/key.pem", ".env", "sub/.env", "secrets/inner.key"],
|
||||
&["readable.txt", "sub/dir/keep.txt"],
|
||||
&["late.pem"],
|
||||
);
|
||||
}
|
||||
|
||||
struct TempDirGuard(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Integration tests for kigi-sandbox.
|
||||
//!
|
||||
//! Note: `Sandbox::apply()` is irreversible and process-wide, so we cannot
|
||||
//! test actual kernel enforcement in standard `#[test]` functions (they share
|
||||
//! a process). Use the `sandbox_smoke_test` example for enforcement testing.
|
||||
//! These tests verify the API contracts, config loading, and support detection.
|
||||
|
||||
// `support_info` is only available with the `enforce` feature (it returns a
|
||||
// nono type), so gate this test the same way.
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn test_support_info() {
|
||||
// Verify that nono can report platform support status without applying
|
||||
let support = kigi_sandbox::SandboxManager::support_info();
|
||||
// On macOS and Linux 5.13+, this should be supported
|
||||
// On other platforms, it gracefully reports unsupported
|
||||
println!(
|
||||
"Sandbox support: supported={}, details={}",
|
||||
support.is_supported, support.details
|
||||
);
|
||||
// We don't assert is_supported because CI may run on any platform
|
||||
}
|
||||
|
||||
// `to_capability_set` is only available with the `enforce` feature.
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn test_profile_capability_set_construction() {
|
||||
use kigi_sandbox::ProfileName;
|
||||
|
||||
// Use CWD as workspace — guaranteed to exist
|
||||
let workspace = std::env::current_dir().expect("cwd");
|
||||
|
||||
// All profiles should produce valid CapabilitySets without panicking
|
||||
for profile in [
|
||||
ProfileName::Workspace,
|
||||
ProfileName::ReadOnly,
|
||||
ProfileName::Strict,
|
||||
ProfileName::Off,
|
||||
] {
|
||||
let result = profile.to_capability_set(&workspace);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Profile {:?} failed to build CapabilitySet: {:?}",
|
||||
profile,
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_manager_lifecycle() {
|
||||
use kigi_sandbox::{ProfileName, SandboxManager};
|
||||
|
||||
let workspace = std::env::current_dir().expect("cwd");
|
||||
|
||||
// Off profile: apply should succeed without actually sandboxing
|
||||
let mut manager = SandboxManager::new(ProfileName::Off, &workspace);
|
||||
assert!(!manager.is_applied());
|
||||
assert!(!manager.restrict_child_network());
|
||||
|
||||
let result = manager.apply(&workspace);
|
||||
assert!(result.is_ok());
|
||||
// Off profile doesn't actually apply
|
||||
assert!(!manager.is_applied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_logger() {
|
||||
use kigi_sandbox::{SandboxEvent, SandboxLogger};
|
||||
|
||||
let logger = SandboxLogger::new();
|
||||
|
||||
// Log some events (use violation events — profile_applied requires a resolved profile)
|
||||
logger.log(SandboxEvent::fs_violation("workspace", "/tmp/test", "read"));
|
||||
logger.log(SandboxEvent::fs_violation(
|
||||
"workspace",
|
||||
"/etc/shadow",
|
||||
"write",
|
||||
));
|
||||
logger.log(SandboxEvent::net_violation("strict", "evil.com:443"));
|
||||
|
||||
// Check metrics
|
||||
assert_eq!(logger.metrics().fs_violation_count(), 2);
|
||||
assert_eq!(logger.metrics().net_violation_count(), 1);
|
||||
|
||||
// Take events drains the buffer
|
||||
let events = logger.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
// Buffer is now empty
|
||||
let events2 = logger.take_events();
|
||||
assert!(events2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_restrict_child_network_default() {
|
||||
// Before any sandbox is applied, child network should not be restricted
|
||||
// Note: this test may interfere with other tests if they set the global.
|
||||
// In practice, the global is set once at process startup and never unset.
|
||||
// For testing, we just verify the default state.
|
||||
//
|
||||
// We can't meaningfully test the "set" path without applying a sandbox
|
||||
// (which is irreversible), so we verify the default is false.
|
||||
assert!(!kigi_sandbox::should_restrict_child_network());
|
||||
}
|
||||
Reference in New Issue
Block a user