diff --git a/Cargo.lock b/Cargo.lock index 0aa1ef2..2014aa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6186,6 +6186,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "tempfile", "toml", "tracing", ] diff --git a/crates/codegen/kigi-sandbox/Cargo.toml b/crates/codegen/kigi-sandbox/Cargo.toml index cb9ebb6..db855e9 100644 --- a/crates/codegen/kigi-sandbox/Cargo.toml +++ b/crates/codegen/kigi-sandbox/Cargo.toml @@ -51,6 +51,7 @@ enforce = [] [dev-dependencies] serial_test = { workspace = true } +tempfile = { workspace = true } # Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches # EXACTLY the set globset (the Linux backend) matches — the cross-platform parity # guard for the glob dialect. diff --git a/crates/codegen/kigi-sandbox/src/lib.rs b/crates/codegen/kigi-sandbox/src/lib.rs index 9213752..826cd89 100644 --- a/crates/codegen/kigi-sandbox/src/lib.rs +++ b/crates/codegen/kigi-sandbox/src/lib.rs @@ -360,6 +360,23 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool { pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool { false } +/// Whether the resolved profile leaves `~/.kigi` read-only outside its +/// state dirs (strict and every profile extending it), so files first use +/// would write there must be minted before enforcement. +#[cfg(all(feature = "enforce", unix))] +pub fn restricts_home_writes(profile: &ProfileName, workspace: &Path) -> bool { + if *profile == ProfileName::Off { + return false; + } + let config = profiles::load_sandbox_config(workspace); + profile + .resolve_profile(workspace, &config) + .is_ok_and(|resolved| !resolved.read_write.contains(&paths::kigi_home())) +} +#[cfg(not(all(feature = "enforce", unix)))] +pub fn restricts_home_writes(_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 @@ -717,3 +734,31 @@ mod tests { let _ = std::fs::remove_dir_all(&ws_ws); } } + +#[cfg(all(test, feature = "enforce", unix))] +mod home_write_tests { + use super::{ProfileName, restricts_home_writes}; + + #[test] + fn strict_restricts_home_writes_and_workspace_does_not() { + let workspace = std::env::temp_dir(); + assert!(restricts_home_writes(&ProfileName::Strict, &workspace)); + assert!(!restricts_home_writes(&ProfileName::Workspace, &workspace)); + assert!(!restricts_home_writes(&ProfileName::ReadOnly, &workspace)); + assert!(!restricts_home_writes(&ProfileName::Off, &workspace)); + } + + #[test] + fn custom_profile_extending_strict_inherits_the_restriction() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join(".kigi"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("sandbox.toml"), + "[profiles.audit-home-write-test]\nextends = \"strict\"\n", + ) + .unwrap(); + let custom = ProfileName::Custom("audit-home-write-test".to_string()); + assert!(restricts_home_writes(&custom, tmp.path())); + } +} diff --git a/crates/codegen/kigi-sandbox/src/logging.rs b/crates/codegen/kigi-sandbox/src/logging.rs index 6da10e6..bbd3816 100644 --- a/crates/codegen/kigi-sandbox/src/logging.rs +++ b/crates/codegen/kigi-sandbox/src/logging.rs @@ -1,7 +1,7 @@ //! Sandbox event logger. //! //! Events (profile applied, violations, bypasses) are buffered in memory and -//! flushed as JSONL to `~/.kigi/sandbox-events.jsonl`. +//! flushed as JSONL to `~/.kigi/sessions/sandbox-events.jsonl`. use std::path::PathBuf; use std::sync::Mutex; @@ -87,8 +87,12 @@ impl SandboxLogger { Ok(()) } + /// Under `sessions/`: the only home subtree a strict sandbox leaves + /// writable, and this flush runs after enforcement. fn log_file_path() -> PathBuf { - kigi_config::kigi_home().join("sandbox-events.jsonl") + kigi_config::kigi_home() + .join("sessions") + .join("sandbox-events.jsonl") } } diff --git a/crates/codegen/kigi-sandbox/src/paths.rs b/crates/codegen/kigi-sandbox/src/paths.rs index 5968463..1beece3 100644 --- a/crates/codegen/kigi-sandbox/src/paths.rs +++ b/crates/codegen/kigi-sandbox/src/paths.rs @@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec { paths } +/// Home subtrees the process keeps writing after enforcement: session and +/// worktree state, memory, diagnostics. Landlock cannot carve a child out of +/// a write grant, so the rest of `~/.kigi` (`config.toml`, `auth.json`, +/// `sandbox.toml`, `hooks/`, `bin/`, `vendor/`, `plugin-data/`) stays +/// read-only and a sandboxed child cannot arm the next launch. +#[cfg(all(feature = "enforce", unix))] +const HOME_STATE_DIRS: &[&str] = &[ + "run", + "sessions", + "worktrees", + "worktree_pool", + "tasks", + "memory", + "logs", + "debug", + "crash", + "memtrace", + "trace-exports", +]; + +/// Writable paths for the strict profile: the workspace, the home state +/// subtrees, and temp. +#[cfg(all(feature = "enforce", unix))] +pub(crate) fn essential_writable_paths_strict(workspace: &Path) -> Vec { + let home = kigi_home(); + let mut paths = vec![workspace.to_path_buf()]; + paths.extend(HOME_STATE_DIRS.iter().map(|dir| home.join(dir))); + paths.extend(temp_writable_paths()); + paths +} + /// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp). /// Device files are handled separately via `allow_file` in `to_capability_set_with_config`. #[cfg(all(feature = "enforce", unix))] diff --git a/crates/codegen/kigi-sandbox/src/profiles.rs b/crates/codegen/kigi-sandbox/src/profiles.rs index 8e9b379..d1e8563 100644 --- a/crates/codegen/kigi-sandbox/src/profiles.rs +++ b/crates/codegen/kigi-sandbox/src/profiles.rs @@ -18,6 +18,7 @@ use crate::paths::kigi_home; #[cfg(all(feature = "enforce", unix))] use crate::paths::{ DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal, + essential_writable_paths_strict, }; /// A resolved sandbox profile ready to be converted to a `CapabilitySet`. @@ -186,6 +187,32 @@ fn load_config_file(path: &Path) -> Option { #[cfg(all(feature = "enforce", unix))] impl ProfileName { /// Convert this profile into a nono `CapabilitySet` for the given workspace. + /// The directory a `read_write` entry really grants. A symlink under + /// `~/.kigi` is followed only to the default `~/.kigi/sessions` (the + /// `KIGI_SHARE_DIR` override case); any other target would widen the + /// grant to wherever the link points. Missing paths are created. + fn read_write_grant_path(path: &Path, home: &Path) -> Option { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + if path == home || !path.starts_with(home) { + return Some(path.to_path_buf()); + } + let real = dunce::canonicalize(path).ok()?; + let default_sessions = kigi_config::default_kigi_home().join("sessions"); + (real.is_dir() + && path.file_name() == Some(std::ffi::OsStr::new("sessions")) + && real == default_sessions) + .then_some(real) + } + Ok(_) => Some(path.to_path_buf()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir_all(path).ok()?; + Some(path.to_path_buf()) + } + Err(_) => None, + } + } + pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result { let config = load_sandbox_config(workspace); self.to_capability_set_with_config(workspace, &config) @@ -231,13 +258,14 @@ impl ProfileName { // apply time (it opens an O_PATH fd), but new files within it can // be created freely after the sandbox is applied. Pre-create // directories like ~/.kigi/ that may not exist on first run. + let home = kigi_home(); for path in &profile.read_write { - if !path.exists() && std::fs::create_dir_all(path).is_err() { - tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping"); + let Some(grant) = Self::read_write_grant_path(path, &home) else { + tracing::warn!(path = ?path, "read_write grant skipped"); continue; - } - let Some(path_str) = path.to_str() else { - tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path"); + }; + let Some(path_str) = grant.to_str() else { + tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path"); continue; }; caps = caps.allow_path(path_str, AccessMode::ReadWrite)?; @@ -380,12 +408,15 @@ impl ProfileName { .chain(std::iter::once(home.join("Library"))) .filter(|p| p.exists()) .chain(std::iter::once(workspace.to_path_buf())) + // Read-only: config, auth and global hooks are read after + // enforcement; only sessions/ below stays writable. + .chain(std::iter::once(kigi_home())) .collect(); Ok(SandboxProfile { name: "strict".to_string(), read_only: system_read, - read_write: essential_writable_paths(workspace), + read_write: essential_writable_paths_strict(workspace), deny: vec![], default_read: false, restrict_network: true, @@ -544,6 +575,82 @@ mod tests { } } + #[test] + #[cfg(all(feature = "enforce", unix))] + fn strict_read_write_is_workspace_home_state_and_temp() { + let workspace = std::env::temp_dir(); + let profile = ProfileName::Strict + .resolve_profile(&workspace, &SandboxConfig::default()) + .expect("strict resolves"); + let home = kigi_home(); + assert_eq!(profile.read_write[0], workspace); + for dir in ["sessions", "worktrees", "memory", "logs", "debug"] { + assert!( + profile.read_write.contains(&home.join(dir)), + "{dir} must stay writable: {:?}", + profile.read_write + ); + } + for denied in [ + home.clone(), + home.join("hooks"), + home.join("vendor"), + home.join("bin"), + ] { + assert!( + !profile.read_write.contains(&denied), + "strict must not grant {}: {:?}", + denied.display(), + profile.read_write + ); + } + assert!( + profile.read_only.contains(&home), + "config/auth/hooks are read after enforcement: {:?}", + profile.read_only + ); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn read_write_grant_follows_real_dirs_and_creates_missing_ones() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + std::fs::create_dir_all(home.join("sessions")).unwrap(); + assert_eq!( + ProfileName::read_write_grant_path(&home.join("sessions"), &home), + Some(home.join("sessions")) + ); + let missing = home.join("fresh"); + assert_eq!( + ProfileName::read_write_grant_path(&missing, &home), + Some(missing.clone()) + ); + assert!(missing.is_dir()); + } + + #[test] + #[cfg(all(feature = "enforce", unix))] + fn read_write_grant_refuses_a_home_symlink_pointing_elsewhere() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, home.join("sessions")).unwrap(); + assert_eq!( + ProfileName::read_write_grant_path(&home.join("sessions"), &home), + None + ); + // A symlinked workspace outside the home is the user's own choice. + let ws_link = tmp.path().join("ws"); + std::os::unix::fs::symlink(&outside, &ws_link).unwrap(); + assert_eq!( + ProfileName::read_write_grant_path(&ws_link, &home), + Some(ws_link.clone()) + ); + } + #[test] #[cfg(all(feature = "enforce", unix))] fn base_profile_capability_set_builds() { diff --git a/crates/codegen/kigi-shell/README.md b/crates/codegen/kigi-shell/README.md index 7f8a225..bdb4bf0 100644 --- a/crates/codegen/kigi-shell/README.md +++ b/crates/codegen/kigi-shell/README.md @@ -2101,7 +2101,7 @@ kigi --sandbox strict | `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox | | `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development | | `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review | -| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` | Blocked | Untrusted code | +| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` state dirs | Blocked | Untrusted code | Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always write-protected regardless of profile. @@ -2160,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime. ### Event Logging -Sandbox events (profile applied, violations) are logged to `~/.kigi/sandbox-events.jsonl` +Sandbox events (profile applied, violations) are logged to `~/.kigi/sessions/sandbox-events.jsonl` for telemetry and debugging. --- diff --git a/crates/codegen/kigi-shell/src/config/mod.rs b/crates/codegen/kigi-shell/src/config/mod.rs index a9993c6..7f3c703 100644 --- a/crates/codegen/kigi-shell/src/config/mod.rs +++ b/crates/codegen/kigi-shell/src/config/mod.rs @@ -1034,6 +1034,15 @@ pub fn apply_sandbox( .and_then(|p| dunce::canonicalize(p).ok()) .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| std::path::PathBuf::from(".")); + if kigi_sandbox::restricts_home_writes(&sandbox_profile, &workspace) { + // The profile leaves ~/.kigi read-only outside its state dirs: mint + // what first use would otherwise write after enforcement. + if let Err(e) = crate::auth::device::device_id() { + tracing::warn!(error = %e, "sandbox: device id not created before enforcement"); + } + kigi_tools::implementations::kigi::grep::ripgrep::rg_path(); + crate::builtin::extract_bundled_files(&crate::util::kigi_home::kigi_home()); + } #[cfg(target_os = "linux")] let requires_read_deny = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace); #[cfg(target_os = "linux")] diff --git a/crates/codegen/kigi-shell/src/leader/lock.rs b/crates/codegen/kigi-shell/src/leader/lock.rs index 2bb2ed8..20a68cd 100644 --- a/crates/codegen/kigi-shell/src/leader/lock.rs +++ b/crates/codegen/kigi-shell/src/leader/lock.rs @@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf { root.join("leader.lock") } +/// Runtime files live under `~/.kigi/run`: the one home subtree a strict +/// sandbox leaves writable for them. +pub(super) fn runtime_root() -> PathBuf { + kigi_home().join("run") +} + /// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling -/// `.lock` when set, else the default under kigi home. +/// `.lock` when set, else the default under `~/.kigi/run`. pub fn default_lock_path() -> PathBuf { - resolve_lock_path(leader_socket_override(), &kigi_home()) + resolve_lock_path(leader_socket_override(), &runtime_root()) } pub fn default_socket_path_in(root: &Path) -> PathBuf { @@ -63,9 +69,9 @@ pub fn default_socket_path_in(root: &Path) -> PathBuf { } /// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set, -/// else the default under kigi home. +/// else the default under `~/.kigi/run`. pub fn default_socket_path() -> PathBuf { - resolve_socket_path(leader_socket_override(), &kigi_home()) + resolve_socket_path(leader_socket_override(), &runtime_root()) } /// The instance suffix encoded in a lock/socket file-name pair @@ -145,6 +151,9 @@ impl LeaderLock { } fn open_lock_file(&self) -> Result { + if let Some(parent) = self.lock_path.parent() { + std::fs::create_dir_all(parent)?; + } Ok(OpenOptions::new() .read(true) .write(true) diff --git a/crates/codegen/kigi-shell/src/leader/mod.rs b/crates/codegen/kigi-shell/src/leader/mod.rs index 61362d0..1e017e5 100644 --- a/crates/codegen/kigi-shell/src/leader/mod.rs +++ b/crates/codegen/kigi-shell/src/leader/mod.rs @@ -23,7 +23,7 @@ //! │ │ - Tracks session ownership for routing ││ //! │ └────────────────────────┬────────────────────────────────┘│ //! └───────────────────────────┼──────────────────────────────────┘ -//! │ IPC (Unix socket at ~/.kigi/leader.sock) +//! │ IPC (Unix socket at ~/.kigi/run/leader.sock) //! ┌───────────────────┼───────────────────┐ //! ▼ ▼ ▼ //! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ @@ -481,7 +481,10 @@ async fn discover_leaders_in(root: &Path) -> Vec { entries } pub async fn discover_leaders() -> Vec { - discover_leaders_in(&crate::util::kigi_home::kigi_home()).await + let mut entries = discover_leaders_in(&lock::runtime_root()).await; + // Pre-0.1.15 leaders and socket overrides keep their files at the home root. + entries.extend(discover_leaders_in(&crate::util::kigi_home::kigi_home()).await); + entries } /// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a /// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled). @@ -610,13 +613,28 @@ fn resolve_matching_descriptors( fn resolve_target_from_descriptors( target: LeaderTarget, leaders: Vec, + current_default_socket: &Path, ) -> Result { match target { LeaderTarget::Default => { - let matching: Vec<_> = leaders + let defaults: Vec<_> = leaders .into_iter() .filter(|descriptor| descriptor.is_default) .collect(); + // An upgrade can leave a pre-0.1.15 default at the home root; the + // current default socket wins while it exists. + let current: Vec<_> = defaults + .iter() + .filter(|descriptor| { + descriptor.socket_path.as_deref() == Some(current_default_socket) + }) + .cloned() + .collect(); + let matching = if current.is_empty() { + defaults + } else { + current + }; resolve_matching_descriptors("default", matching) } LeaderTarget::Socket(socket_path) => { @@ -685,7 +703,7 @@ pub async fn resolve_leader_target( target: LeaderTarget, ) -> Result { let leaders = discover_leaders().await; - resolve_target_from_descriptors(target, leaders) + resolve_target_from_descriptors(target, leaders, &lock::default_socket_path()) } #[derive(Debug, thiserror::Error)] pub enum ConnectionError { @@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result { } cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()); - let log_path = crate::util::kigi_home::kigi_home().join("leader.log"); + let log_path = crate::util::kigi_home::kigi_home() + .join("logs") + .join("leader.log"); + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } match std::fs::File::create(&log_path) { Ok(log_file) => { info!("Leader stderr → log file"); @@ -1444,6 +1467,46 @@ mod tests { vec![(222, "0.2.52".to_string())] ); } + fn reachable_default(socket: &str) -> LeaderDescriptor { + let socket_path = PathBuf::from(socket); + LeaderDescriptor { + pid_from_lock: Some(1), + lock_path: Some(socket_path.with_extension("lock")), + socket_path: Some(socket_path.clone()), + socket_suffix: String::new(), + classification: LeaderDiscoveryState::Reachable, + is_default: true, + live_info: Some(LiveLeaderInfo { + pid: 1, + socket_path, + lock_path: PathBuf::new(), + socket_suffix: String::new(), + leader_protocol_version: 1, + leader_binary_version: "0.1.15".to_string(), + }), + target_error: None, + } + } + #[test] + fn default_target_prefers_the_current_socket_over_a_legacy_default() { + let legacy = reachable_default("/h/.kigi/leader.sock"); + let current = reachable_default("/h/.kigi/run/leader.sock"); + let current_socket = PathBuf::from("/h/.kigi/run/leader.sock"); + let picked = resolve_target_from_descriptors( + LeaderTarget::Default, + vec![current.clone(), legacy.clone()], + ¤t_socket, + ) + .expect("current default resolves"); + assert_eq!(picked.descriptor.socket_path, current.socket_path); + let fallback = resolve_target_from_descriptors( + LeaderTarget::Default, + vec![legacy.clone()], + ¤t_socket, + ) + .expect("legacy default still resolves alone"); + assert_eq!(fallback.descriptor.socket_path, legacy.socket_path); + } #[test] fn leader_is_older_than_directional() { assert!(leader_is_older_than("0.1.0", "0.2.0")); diff --git a/crates/codegen/kigi-shell/src/leader/server.rs b/crates/codegen/kigi-shell/src/leader/server.rs index 3450ec5..762965d 100644 --- a/crates/codegen/kigi-shell/src/leader/server.rs +++ b/crates/codegen/kigi-shell/src/leader/server.rs @@ -1146,6 +1146,9 @@ pub async fn run_leader_server( control_state: LeaderServerControlState, ) -> Result<(), ServerError> { let _ = std::fs::remove_file(&socket_path); + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } let shutdown_reason_rx = shutdown_tx.subscribe(); let listener = LeaderListener::bind(&socket_path)?; info!("Leader server listening"); diff --git a/crates/codegen/kigi-test-support/src/leader.rs b/crates/codegen/kigi-test-support/src/leader.rs index 4b2a729..280730a 100644 --- a/crates/codegen/kigi-test-support/src/leader.rs +++ b/crates/codegen/kigi-test-support/src/leader.rs @@ -144,7 +144,7 @@ impl LeaderStdioClient { .env("KIGI_TRACE_UPLOAD", "false") .env("KIGI_INSTRUMENTATION", "disabled") // Inherited by the spawned leader, whose stderr goes to - // ~/.kigi/leader.log — keep it chatty for diagnosis. + // ~/.kigi/logs/leader.log — keep it chatty for diagnosis. .env("RUST_LOG", "kigi_shell=debug"); let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd); @@ -345,5 +345,5 @@ pub async fn wait_for_replay_notifications( } pub fn leader_log(home: &Path) -> String { - std::fs::read_to_string(home.join(".kigi").join("leader.log")).unwrap_or_default() + std::fs::read_to_string(home.join(".kigi").join("logs").join("leader.log")).unwrap_or_default() } diff --git a/crates/codegen/kigi-tui/docs/user-guide/18-sandbox.md b/crates/codegen/kigi-tui/docs/user-guide/18-sandbox.md index 8eb3baf..5d24bed 100644 --- a/crates/codegen/kigi-tui/docs/user-guide/18-sandbox.md +++ b/crates/codegen/kigi-tui/docs/user-guide/18-sandbox.md @@ -15,7 +15,7 @@ kigi --sandbox workspace # Read-only mode (read everywhere, write only to ~/.kigi/ + temp dirs) kigi --sandbox read-only -# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/, no child network) +# Most restrictive profile (read CWD + system paths, write CWD + temp dirs + ~/.kigi/ state dirs, no child network) kigi --sandbox strict ``` @@ -29,10 +29,12 @@ kigi --sandbox strict | `workspace` | Everywhere | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Allowed | Normal development | | `devbox` | Everywhere | All top-level dirs except `/data` | Allowed | Disposable dev VMs | | `read-only` | Everywhere | `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Exploration, code review | -| `strict` | CWD + system paths | CWD + `~/.kigi/` + `/tmp` + `/var/tmp` | Blocked¹ | Untrusted code | +| `strict` | CWD + system paths | CWD + `/tmp` + `/var/tmp` + `~/.kigi/` state dirs² | Blocked¹ | Untrusted code | ¹ Child-network blocking is enforced on **Linux only** (via seccomp). On macOS it is a no-op — these profiles do not restrict child-process network there. +² `run`, `sessions`, `worktrees`, `worktree_pool`, `tasks`, `memory`, `logs`, `debug`, `crash`, `memtrace`, `trace-exports`. The rest of `~/.kigi/` (`config.toml`, `auth.json`, `sandbox.toml`, `hooks/`, `bin/`, `vendor/`) is read-only, so a sandboxed command cannot change the next launch. Root-level caches (`version.json`, `slash-mru.json`, `pager.toml` settings) are not written under `strict`. + To block specific files (e.g. `.env` or credential paths) on top of a profile, define a [custom profile](#custom-profiles) with a `deny` list — it is kernel-enforced (read + write/rename) and supports glob patterns like `**/*.pem`. ### Profile Details @@ -43,7 +45,7 @@ To block specific files (e.g. `.env` or credential paths) on top of a profile, d **read-only** -- Use when you want the agent to analyze code without modifying your project files. The agent can read everything but can only write to `~/.kigi/` (needed for session persistence) and temp directories. Child-process network access is blocked on Linux (no-op on macOS). -**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory and essential system paths. Writes are limited to CWD, `~/.kigi/`, and temp directories. Child-process network access is blocked on Linux (no-op on macOS). +**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory, essential system paths, and `~/.kigi/`. Writes are limited to CWD, the `~/.kigi/` state directories (sessions, worktrees, memory, logs), and temp directories; `config.toml`, `auth.json`, `hooks/` and `bin/` stay read-only. Child-process network access is blocked on Linux (no-op on macOS). --- @@ -193,7 +195,7 @@ In practice, on Linux this means: ## Event Logging -Sandbox events are logged to `~/.kigi/sandbox-events.jsonl` for debugging. Events include: +Sandbox events are logged to `~/.kigi/sessions/sandbox-events.jsonl` for debugging. Events include: - Profile applied (which profile, timestamp) - Violations (attempted access to denied paths)