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:
雷电芽衣 committed 2026-09-13 02:08:42 -04:00
1 parent cadc46f5dc
commit 97c934a647
13 files changed
+300 -25

No files matched your search

@@ -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")]
+13 -4
View File
@@ -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)
+68 -5
View File
@@ -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()],
&current_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()],
&current_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");