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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user