fix(sandbox): strict profile no longer lets a child rewrite ~/.kigi
Port of upstream grok-build 1.0.14 ("--sandbox strict now restricts
writes to ~/.grok/sessions only"), shaped for Kigi.
The strict profile granted the whole ~/.kigi read-write, so a sandboxed
command could plant a global SessionStart hook or flip
`[sandbox].profile` in config.toml for the next launch. Landlock cannot
carve a child out of a write grant, so the home is now read-only
except an explicit allowlist of state subtrees (run, sessions,
worktrees, worktree_pool, tasks, memory, logs, debug, crash, memtrace,
trace-exports); config.toml, auth.json, sandbox.toml, hooks/, bin/ and
vendor/ stay read-only. A symlinked state dir under the home is
followed only to the default ~/.kigi/sessions.
What the process itself writes there after enforcement moves or is
minted first:
- kigi-shell `apply_sandbox` mints the device id, extracts the bundled
rg and the bundled skills BEFORE enforcement for every profile that
restricts home writes (`restricts_home_writes`, custom profiles
extending strict included).
- The sandbox audit log lives at ~/.kigi/sessions/sandbox-events.jsonl.
- Leader lock/socket default to ~/.kigi/run/, the leader stderr log to
~/.kigi/logs/leader.log; discovery scans run/ and the legacy root,
and the default target prefers the current socket.
Root-level caches (version.json, agent_id, slash-mru.json, pager.toml,
claude_import_state.json) degrade best-effort under strict; documented.
Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
1 parent
cadc46f5dc
commit
97c934a647
13 files changed
+300
-25
No files matched your search
Generated
+1
@@ -6186,6 +6186,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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))]
|
||||
|
||||
@@ -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<SandboxConfig> {
|
||||
#[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<PathBuf> {
|
||||
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<CapabilitySet> {
|
||||
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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<File, LockError> {
|
||||
if let Some(parent) = self.lock_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
|
||||
@@ -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<LeaderDescriptor> {
|
||||
entries
|
||||
}
|
||||
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
|
||||
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<LeaderDescriptor>,
|
||||
current_default_socket: &Path,
|
||||
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
||||
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<LeaderTargetSelection, LeaderTargetError> {
|
||||
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<u32, ConnectionError> {
|
||||
}
|
||||
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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user