Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -1,10 +1,145 @@
|
|||
//! Per-child seccomp network filter. No-op on non-Linux.
|
||||
//! Seccomp: child network filter (pre_exec) and process-wide namespace lockdown.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ns_lockdown {
|
||||
use libc::sock_filter;
|
||||
|
||||
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||
pub(super) const EPERM_VAL: u32 = 1;
|
||||
/// ENOSYS: libc treats clone3 as unavailable and falls back to legacy clone.
|
||||
pub(super) const ENOSYS_VAL: u32 = libc::ENOSYS as u32;
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
|
||||
|
||||
pub(super) const OFF_NR: u32 = 0;
|
||||
pub(super) const OFF_ARCH: u32 = 4;
|
||||
pub(super) const OFF_ARGS0_LO: u32 = 16; // LE low half of args[0]
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
|
||||
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
|
||||
pub(super) const EXPECTED_ARCH: u32 = 0;
|
||||
|
||||
pub(super) const CLONE_NAMESPACE_BITS: u32 = (libc::CLONE_NEWNS as u32)
|
||||
| (libc::CLONE_NEWCGROUP as u32)
|
||||
| (libc::CLONE_NEWUTS as u32)
|
||||
| (libc::CLONE_NEWIPC as u32)
|
||||
| (libc::CLONE_NEWUSER as u32)
|
||||
| (libc::CLONE_NEWPID as u32)
|
||||
| (libc::CLONE_NEWNET as u32)
|
||||
| (libc::CLONE_NEWTIME as u32);
|
||||
|
||||
/// Linux `clone3` (arch-portable number; not always exported by libc).
|
||||
pub(super) const SYS_CLONE3: u32 = 435;
|
||||
|
||||
fn stmt(code: u32, k: u32) -> sock_filter {
|
||||
sock_filter {
|
||||
code: code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
|
||||
sock_filter {
|
||||
code: code as u16,
|
||||
jt,
|
||||
jf,
|
||||
k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classic BPF namespace lockdown.
|
||||
///
|
||||
/// - `unshare` / `setns` / legacy `clone(CLONE_NEW*)` → EPERM
|
||||
/// - `clone3` → ENOSYS (flags live in a pointed-to struct classic BPF cannot
|
||||
/// inspect; ENOSYS makes libc fall back to legacy clone for ordinary
|
||||
/// spawn, while direct malicious clone3 cannot create namespaces)
|
||||
pub fn build_namespace_lockdown_filter() -> Vec<sock_filter> {
|
||||
use libc::{
|
||||
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W, SYS_clone,
|
||||
SYS_setns, SYS_unshare,
|
||||
};
|
||||
|
||||
let mut f = Vec::with_capacity(22);
|
||||
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
f.push(jump(BPF_JMP | BPF_JSET | BPF_K, X32_SYSCALL_BIT, 0, 1));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
}
|
||||
for sys in [SYS_unshare as u32, SYS_setns as u32] {
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, 0, 1));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
}
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, SYS_CLONE3, 0, 1));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS_VAL));
|
||||
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, SYS_clone as u32, 0, 3));
|
||||
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARGS0_LO));
|
||||
f.push(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NAMESPACE_BITS, 0, 1));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn filter_jeq_immediates(filter: &[sock_filter]) -> Vec<u32> {
|
||||
use libc::{BPF_JEQ, BPF_JMP, BPF_K};
|
||||
let jeq = (BPF_JMP | BPF_JEQ | BPF_K) as u16;
|
||||
filter
|
||||
.iter()
|
||||
.filter(|i| i.code == jeq)
|
||||
.map(|i| i.k)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn install(filter: &mut [sock_filter]) -> std::io::Result<()> {
|
||||
use libc::{
|
||||
PR_SET_NO_NEW_PRIVS, SECCOMP_FILTER_FLAG_TSYNC, SECCOMP_SET_MODE_FILTER, SYS_seccomp,
|
||||
prctl, sock_fprog,
|
||||
};
|
||||
|
||||
let prog = sock_fprog {
|
||||
len: filter.len() as u16,
|
||||
filter: filter.as_mut_ptr(),
|
||||
};
|
||||
|
||||
// SAFETY: standard NO_NEW_PRIVS before seccomp.
|
||||
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: prog valid for the duration of the syscall.
|
||||
// rc: 0 ok; >0 TSYNC failing TID; -1 errno.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
SYS_seccomp,
|
||||
SECCOMP_SET_MODE_FILTER as libc::c_long,
|
||||
SECCOMP_FILTER_FLAG_TSYNC as libc::c_long,
|
||||
&prog as *const sock_fprog as *const libc::c_void,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if rc > 0 {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"seccomp TSYNC failed: thread {rc} could not install filter"
|
||||
)));
|
||||
}
|
||||
Err(std::io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Install seccomp BPF filter blocking network syscalls.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
||||
/// After fork / before exec.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
use libc::{
|
||||
|
|
@ -15,33 +150,9 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
|||
|
||||
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||
const EPERM_VAL: u32 = 1; // libc::EPERM
|
||||
const EPERM_VAL: u32 = 1;
|
||||
|
||||
macro_rules! bpf_stmt {
|
||||
($code:expr, $k:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! bpf_jump {
|
||||
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: $jt,
|
||||
jf: $jf,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const NR_OFFSET: u32 = 0; // seccomp_data.nr offset
|
||||
|
||||
let blocked_syscalls: &[i64] = &[
|
||||
let blocked: &[i64] = &[
|
||||
SYS_connect,
|
||||
SYS_bind,
|
||||
SYS_sendto,
|
||||
|
|
@ -50,42 +161,42 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
|||
SYS_accept,
|
||||
SYS_accept4,
|
||||
];
|
||||
|
||||
let mut filter: Vec<sock_filter> = Vec::new();
|
||||
let total_checks = blocked_syscalls.len();
|
||||
|
||||
// 1. Load syscall number
|
||||
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
|
||||
|
||||
// 2. Check each blocked syscall
|
||||
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
|
||||
let remaining = total_checks - i - 1;
|
||||
filter.push(bpf_jump!(
|
||||
BPF_JMP | BPF_JEQ | BPF_K,
|
||||
syscall,
|
||||
remaining as u8 + 1, // match: jump to ERRNO
|
||||
0 // no match: check next
|
||||
));
|
||||
filter.push(sock_filter {
|
||||
code: (BPF_LD | BPF_W | BPF_ABS) as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: 0,
|
||||
});
|
||||
let n = blocked.len();
|
||||
for (i, &sys) in blocked.iter().enumerate() {
|
||||
let remaining = n - i - 1;
|
||||
filter.push(sock_filter {
|
||||
code: (BPF_JMP | BPF_JEQ | BPF_K) as u16,
|
||||
jt: remaining as u8 + 1,
|
||||
jf: 0,
|
||||
k: sys as u32,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Default: ALLOW
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
|
||||
// 4. Blocked: ERRNO(EPERM)
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
|
||||
filter.push(sock_filter {
|
||||
code: (BPF_RET | BPF_K) as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: SECCOMP_RET_ALLOW,
|
||||
});
|
||||
filter.push(sock_filter {
|
||||
code: (BPF_RET | BPF_K) as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: SECCOMP_RET_ERRNO | EPERM_VAL,
|
||||
});
|
||||
let prog = sock_fprog {
|
||||
len: filter.len() as u16,
|
||||
filter: filter.as_mut_ptr(),
|
||||
};
|
||||
|
||||
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
|
||||
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
|
||||
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
|
||||
if unsafe {
|
||||
prctl(
|
||||
PR_SET_SECCOMP,
|
||||
|
|
@ -98,14 +209,155 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
|||
{
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Deny nested namespace creation on all threads (TSYNC).
|
||||
/// Ordinary process creation uses legacy clone after clone3 returns ENOSYS.
|
||||
///
|
||||
/// No-op on non-Linux.
|
||||
/// # Safety
|
||||
/// Process-wide; call after bwrap re-exec / at apply.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub unsafe fn install_namespace_lockdown_filter() -> std::io::Result<()> {
|
||||
let mut filter = ns_lockdown::build_namespace_lockdown_filter();
|
||||
ns_lockdown::install(&mut filter)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub unsafe fn install_namespace_lockdown_filter() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::ns_lockdown::*;
|
||||
use libc::{SYS_clone, SYS_setns, SYS_unshare, sock_filter};
|
||||
|
||||
/// Minimal classic-BPF interpreter over synthetic seccomp_data fields.
|
||||
fn eval(filter: &[sock_filter], arch: u32, nr: u32, arg0_lo: u32) -> u32 {
|
||||
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
|
||||
let mut pc = 0usize;
|
||||
let mut a = 0u32;
|
||||
for _ in 0..filter.len().saturating_mul(2) {
|
||||
let insn = &filter[pc];
|
||||
let op = insn.code as u32;
|
||||
if op == (BPF_LD | BPF_W | BPF_ABS) {
|
||||
a = match insn.k {
|
||||
OFF_NR => nr,
|
||||
OFF_ARCH => arch,
|
||||
OFF_ARGS0_LO => arg0_lo,
|
||||
_ => 0,
|
||||
};
|
||||
pc += 1;
|
||||
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
|
||||
pc = if a == insn.k {
|
||||
pc + 1 + insn.jt as usize
|
||||
} else {
|
||||
pc + 1 + insn.jf as usize
|
||||
};
|
||||
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
|
||||
pc = if a & insn.k != 0 {
|
||||
pc + 1 + insn.jt as usize
|
||||
} else {
|
||||
pc + 1 + insn.jf as usize
|
||||
};
|
||||
} else if op == (BPF_RET | BPF_K) {
|
||||
return insn.k;
|
||||
} else {
|
||||
panic!("unsupported opcode {:#x} at {pc}", insn.code);
|
||||
}
|
||||
if pc >= filter.len() {
|
||||
panic!("pc out of range");
|
||||
}
|
||||
}
|
||||
panic!("filter did not RET");
|
||||
}
|
||||
|
||||
fn is_allow(r: u32) -> bool {
|
||||
r == SECCOMP_RET_ALLOW
|
||||
}
|
||||
fn is_eperm(r: u32) -> bool {
|
||||
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
|
||||
}
|
||||
fn is_enosys(r: u32) -> bool {
|
||||
r == (SECCOMP_RET_ERRNO | ENOSYS_VAL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_filter_targets_unshare_setns_clone3_and_clone() {
|
||||
let f = build_namespace_lockdown_filter();
|
||||
let jeqs = filter_jeq_immediates(&f);
|
||||
assert!(jeqs.contains(&(SYS_unshare as u32)), "{jeqs:?}");
|
||||
assert!(jeqs.contains(&(SYS_setns as u32)), "{jeqs:?}");
|
||||
assert!(jeqs.contains(&SYS_CLONE3), "{jeqs:?}");
|
||||
assert!(jeqs.contains(&(SYS_clone as u32)), "{jeqs:?}");
|
||||
assert!(jeqs.contains(&EXPECTED_ARCH), "{jeqs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bpf_eval_ordinary_clone_allowed_namespace_clone_denied() {
|
||||
let f = build_namespace_lockdown_filter();
|
||||
// Ordinary clone/fork flags (no NEW*)
|
||||
assert!(is_allow(eval(
|
||||
&f,
|
||||
EXPECTED_ARCH,
|
||||
SYS_clone as u32,
|
||||
0x11 /* SIGCHLD | CLONE_VM-ish low bits without NEW* */
|
||||
)));
|
||||
assert!(is_eperm(eval(
|
||||
&f,
|
||||
EXPECTED_ARCH,
|
||||
SYS_clone as u32,
|
||||
libc::CLONE_NEWUSER as u32
|
||||
)));
|
||||
assert!(is_eperm(eval(
|
||||
&f,
|
||||
EXPECTED_ARCH,
|
||||
SYS_clone as u32,
|
||||
libc::CLONE_NEWNS as u32
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bpf_eval_clone3_enosys_unshare_setns_eperm_read_allowed() {
|
||||
let f = build_namespace_lockdown_filter();
|
||||
assert!(is_enosys(eval(&f, EXPECTED_ARCH, SYS_CLONE3, 0)));
|
||||
assert!(is_eperm(eval(&f, EXPECTED_ARCH, SYS_unshare as u32, 0)));
|
||||
assert!(is_eperm(eval(&f, EXPECTED_ARCH, SYS_setns as u32, 0)));
|
||||
assert!(is_allow(eval(&f, EXPECTED_ARCH, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bpf_eval_wrong_arch_and_x32_denied() {
|
||||
let f = build_namespace_lockdown_filter();
|
||||
assert!(is_eperm(eval(&f, 0xdead_beef, SYS_clone as u32, 0)));
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
// x32: nr has high bit set
|
||||
assert!(is_eperm(eval(
|
||||
&f,
|
||||
EXPECTED_ARCH,
|
||||
(SYS_unshare as u32) | X32_SYSCALL_BIT,
|
||||
0
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_bits_cover_user_ns_and_mount_ns() {
|
||||
assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWUSER as u32), 0);
|
||||
assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWNS as u32), 0);
|
||||
assert_ne!(CLONE_NAMESPACE_BITS & (libc::CLONE_NEWNET as u32), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_ends_with_allow() {
|
||||
let f = build_namespace_lockdown_filter();
|
||||
assert_eq!(f.last().unwrap().k, SECCOMP_RET_ALLOW);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,112 @@ fn emit_seatbelt_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit write-only Seatbelt deny rules (hook sources stay readable).
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn emit_seatbelt_write_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> {
|
||||
caps.add_platform_rule(format!("(deny file-write* {filter})"))?;
|
||||
for action in SEATBELT_WRITE_DENY_ACTIONS {
|
||||
caps.add_platform_rule(format!("(deny {action} {filter})"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Unlink blocks rename of the node; create blocks replacement. Specific
|
||||
// sub-actions (not bare file-write*) win against later allow-write* grants.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
const SEATBELT_ANCESTOR_NODE_DENY_ACTIONS: &[&str] = &["file-write-unlink", "file-write-create"];
|
||||
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn emit_seatbelt_ancestor_node_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> {
|
||||
for action in SEATBELT_ANCESTOR_NODE_DENY_ACTIONS {
|
||||
caps.add_platform_rule(format!("(deny {action} {filter})"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaf parent up to deepest containing writable root; outside all roots → empty.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
pub(crate) fn ancestors_within_writable_roots(
|
||||
path: &Path,
|
||||
writable_roots: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let root = writable_roots
|
||||
.iter()
|
||||
.filter(|r| path == r.as_path() || path.starts_with(r))
|
||||
.max_by_key(|r| r.components().count());
|
||||
let Some(root) = root else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for anc in xai_grok_config::existing_ancestor_chain(path) {
|
||||
if anc == *root || anc.starts_with(root) {
|
||||
out.push(anc);
|
||||
}
|
||||
}
|
||||
if path != root.as_path() && root.exists() && !out.iter().any(|p| p == root) {
|
||||
out.push(root.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write-only deny for hook sources. Linux is a no-op (bwrap).
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn apply_write_deny_paths_to_capability_set(
|
||||
caps: &mut CapabilitySet,
|
||||
entries: &[(PathBuf, bool)],
|
||||
writable_roots: &[PathBuf],
|
||||
) -> anyhow::Result<()> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut rule_paths = Vec::new();
|
||||
let mut ancestor_seen = std::collections::HashSet::new();
|
||||
for (path, is_dir) in entries {
|
||||
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone());
|
||||
let use_subpath = *is_dir || deny_path_is_dir(&canonical);
|
||||
for form in macos_deny_aliases(path, &canonical) {
|
||||
let Some(escaped) = escape_seatbelt_path(&form) else {
|
||||
anyhow::bail!("cannot escape write-deny path {form:?} for Seatbelt");
|
||||
};
|
||||
if use_subpath {
|
||||
emit_seatbelt_write_deny(caps, &format!("(literal \"{escaped}\")"))?;
|
||||
emit_seatbelt_write_deny(caps, &format!("(subpath \"{escaped}\")"))?;
|
||||
} else {
|
||||
emit_seatbelt_write_deny(caps, &format!("(literal \"{escaped}\")"))?;
|
||||
}
|
||||
rule_paths.push(form);
|
||||
}
|
||||
for anc in ancestors_within_writable_roots(path, writable_roots) {
|
||||
if !ancestor_seen.insert(anc.clone()) {
|
||||
continue;
|
||||
}
|
||||
let anc_canon = dunce::canonicalize(&anc).unwrap_or_else(|_| anc.clone());
|
||||
for form in macos_deny_aliases(&anc, &anc_canon) {
|
||||
let Some(escaped) = escape_seatbelt_path(&form) else {
|
||||
anyhow::bail!(
|
||||
"cannot escape ancestor write-deny path {form:?} for Seatbelt"
|
||||
);
|
||||
};
|
||||
emit_seatbelt_ancestor_node_deny(caps, &format!("(literal \"{escaped}\")"))?;
|
||||
rule_paths.push(form);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = caps.remove_exact_file_caps_for_paths(&rule_paths);
|
||||
tracing::info!(
|
||||
count = entries.len(),
|
||||
"Applied Seatbelt write-deny for Grok-owned direct hook sources"
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = (caps, writable_roots);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply kernel-level deny rules for the given paths.
|
||||
///
|
||||
/// On macOS, adds Seatbelt read-deny + write-deny (incl. specific write
|
||||
|
|
@ -238,6 +344,49 @@ mod tests {
|
|||
#[cfg(all(feature = "enforce", unix))]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn ancestors_pin_under_writable_root_not_home() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"grok-anc-policy-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let grok = tmp.join("grok");
|
||||
let sessions = grok.join("sessions");
|
||||
let leaf = sessions.join("extra-hooks");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
let ws = tmp.join("ws");
|
||||
std::fs::create_dir_all(&ws).unwrap();
|
||||
|
||||
let roots = [grok.clone(), ws.clone()];
|
||||
let pin = ancestors_within_writable_roots(&leaf, &roots);
|
||||
assert!(
|
||||
pin.iter().any(|p| p == &sessions),
|
||||
"must pin sessions under GROK_HOME: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
pin.iter().any(|p| p == &grok),
|
||||
"must pin GROK_HOME grant root: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
!pin.iter().any(|p| p == &tmp),
|
||||
"must not pin above writable roots: {pin:?}"
|
||||
);
|
||||
|
||||
let outside = tmp.join("outside").join("hooks");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let pin_out = ancestors_within_writable_roots(&outside, &roots);
|
||||
assert!(
|
||||
pin_out.is_empty(),
|
||||
"source outside writable roots: leaf-only: {pin_out:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn resolve_deny_paths_relative() {
|
||||
|
|
|
|||
437
crates/codegen/xai-grok-sandbox/src/hook_write_deny.rs
Normal file
437
crates/codegen/xai-grok-sandbox/src/hook_write_deny.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
//! Grok-owned hook write-deny: plan, identity revalidation, and post-reexec checks.
|
||||
//! Namespace lockdown is in [`crate::child_net`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use xai_grok_config::{
|
||||
GlobalHookSource, ensure_grok_hook_slots, missing_configured_sources,
|
||||
resolve_global_hook_sources,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use xai_grok_config::unique_ancestors_rootward;
|
||||
#[cfg(unix)]
|
||||
use xai_grok_config::validated_hook_json_files_for_sources;
|
||||
|
||||
use crate::paths::grok_home;
|
||||
use crate::profiles::ProfileName;
|
||||
|
||||
pub fn profile_enforces_hook_write_deny(profile: &ProfileName) -> bool {
|
||||
!matches!(profile, ProfileName::Devbox | ProfileName::Off)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HookWriteDenyError {
|
||||
#[error("{0}")]
|
||||
Resolve(String),
|
||||
#[error(
|
||||
"configured absolute hooks-paths target(s) do not exist: {0}. \
|
||||
Create them outside the sandbox or remove them from hooks-paths."
|
||||
)]
|
||||
MissingConfigured(String),
|
||||
#[error("required hook write-deny path is not effectively read-only: {path}")]
|
||||
NotReadOnly { path: PathBuf },
|
||||
#[error("cannot verify hook write-deny path {path}: {detail}")]
|
||||
VerifyIo { path: PathBuf, detail: String },
|
||||
#[error("hook write-deny path identity changed before apply (possible rename race): {path}")]
|
||||
IdentityChanged { path: PathBuf },
|
||||
#[error("hook write-deny path is a symlink (retargetable): {path}")]
|
||||
Symlink { path: PathBuf },
|
||||
#[error(
|
||||
"protected regular file has hard-link aliases (st_nlink={nlink}): {path}; \
|
||||
refuse sandbox rather than leave a writable alias"
|
||||
)]
|
||||
HardLink { path: PathBuf, nlink: u64 },
|
||||
#[error("hook directory JSON snapshot changed before apply: {dir}")]
|
||||
JsonSnapshotChanged { dir: PathBuf },
|
||||
}
|
||||
|
||||
impl From<xai_grok_config::GlobalHookSourceError> for HookWriteDenyError {
|
||||
fn from(e: xai_grok_config::GlobalHookSourceError) -> Self {
|
||||
Self::Resolve(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PathIdentity {
|
||||
pub path: PathBuf,
|
||||
pub dev: u64,
|
||||
pub ino: u64,
|
||||
pub is_dir: bool,
|
||||
/// Regular files must stay `1` (no hard-link aliases).
|
||||
pub nlink: u64,
|
||||
}
|
||||
|
||||
/// No-follow identity; regular files require `st_nlink == 1`.
|
||||
#[cfg(unix)]
|
||||
pub fn capture_path_identity(path: &Path) -> Result<PathIdentity, HookWriteDenyError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|e| HookWriteDenyError::VerifyIo {
|
||||
path: path.to_path_buf(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(HookWriteDenyError::Symlink {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
let is_dir = meta.file_type().is_dir();
|
||||
let nlink = meta.nlink();
|
||||
if !is_dir && nlink != 1 {
|
||||
return Err(HookWriteDenyError::HardLink {
|
||||
path: path.to_path_buf(),
|
||||
nlink,
|
||||
});
|
||||
}
|
||||
Ok(PathIdentity {
|
||||
path: path.to_path_buf(),
|
||||
dev: meta.dev(),
|
||||
ino: meta.ino(),
|
||||
is_dir,
|
||||
nlink,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn revalidate_path_identity(id: &PathIdentity) -> Result<(), HookWriteDenyError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::symlink_metadata(&id.path).map_err(|e| HookWriteDenyError::VerifyIo {
|
||||
path: id.path.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(HookWriteDenyError::Symlink {
|
||||
path: id.path.clone(),
|
||||
});
|
||||
}
|
||||
let is_dir = meta.file_type().is_dir();
|
||||
let nlink = meta.nlink();
|
||||
if !is_dir && nlink != 1 {
|
||||
return Err(HookWriteDenyError::HardLink {
|
||||
path: id.path.clone(),
|
||||
nlink,
|
||||
});
|
||||
}
|
||||
if meta.dev() != id.dev || meta.ino() != id.ino || is_dir != id.is_dir || nlink != id.nlink {
|
||||
return Err(HookWriteDenyError::IdentityChanged {
|
||||
path: id.path.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn reject_hardlinked_files(sources: &[GlobalHookSource]) -> Result<(), HookWriteDenyError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use xai_grok_config::GlobalHookSourceKind;
|
||||
for s in sources {
|
||||
let is_file_slot = matches!(
|
||||
s.kind,
|
||||
GlobalHookSourceKind::RegistryFile | GlobalHookSourceKind::ConfiguredSource
|
||||
);
|
||||
if !is_file_slot || !s.path.exists() || s.path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let meta =
|
||||
std::fs::symlink_metadata(&s.path).map_err(|e| HookWriteDenyError::VerifyIo {
|
||||
path: s.path.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if meta.file_type().is_file() && meta.nlink() != 1 {
|
||||
return Err(HookWriteDenyError::HardLink {
|
||||
path: s.path.clone(),
|
||||
nlink: meta.nlink(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn reject_hardlinked_files(_sources: &[GlobalHookSource]) -> Result<(), HookWriteDenyError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirJsonSnapshot {
|
||||
pub dir: PathBuf,
|
||||
pub files: Vec<PathIdentity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HookWriteDenyBwrapPlan {
|
||||
pub ancestor_rw_binds: Vec<PathBuf>,
|
||||
pub leaves: Vec<PathIdentity>,
|
||||
pub dir_json_snapshots: Vec<DirJsonSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HookWriteDenyPrepare {
|
||||
NotRequired,
|
||||
#[cfg(target_os = "linux")]
|
||||
Plan(HookWriteDenyBwrapPlan),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Ensured,
|
||||
}
|
||||
|
||||
pub fn resolve_hook_write_deny_snapshot() -> Result<Vec<GlobalHookSource>, HookWriteDenyError> {
|
||||
let grok = grok_home();
|
||||
let resolved =
|
||||
resolve_global_hook_sources(Some(grok.as_path()), /* reject_symlinks */ true)?;
|
||||
if let Some(e) = resolved.configured_error {
|
||||
return Err(HookWriteDenyError::Resolve(e.to_string()));
|
||||
}
|
||||
let missing = missing_configured_sources(&resolved.sources);
|
||||
if !missing.is_empty() {
|
||||
return Err(HookWriteDenyError::MissingConfigured(
|
||||
missing
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
));
|
||||
}
|
||||
reject_hardlinked_files(&resolved.sources)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
validated_hook_json_files_for_sources(&resolved.sources)?;
|
||||
}
|
||||
Ok(resolved.sources)
|
||||
}
|
||||
|
||||
pub fn prepare_hook_write_deny(
|
||||
profile: &ProfileName,
|
||||
) -> Result<HookWriteDenyPrepare, HookWriteDenyError> {
|
||||
if !profile_enforces_hook_write_deny(profile) {
|
||||
return Ok(HookWriteDenyPrepare::NotRequired);
|
||||
}
|
||||
let grok = grok_home();
|
||||
ensure_grok_hook_slots(grok.as_path())?;
|
||||
let sources = resolve_hook_write_deny_snapshot()?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let plan = build_bwrap_plan(&sources)?;
|
||||
Ok(HookWriteDenyPrepare::Plan(plan))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = sources;
|
||||
Ok(HookWriteDenyPrepare::Ensured)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile_hook_write_deny(profile: &ProfileName) -> anyhow::Result<Vec<GlobalHookSource>> {
|
||||
if !profile_enforces_hook_write_deny(profile) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
resolve_hook_write_deny_snapshot().map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
|
||||
/// Top-level sources plus validated immediate discovery JSON under directories.
|
||||
#[cfg(unix)]
|
||||
pub fn enforcement_leaf_paths(
|
||||
sources: &[GlobalHookSource],
|
||||
) -> Result<Vec<PathBuf>, HookWriteDenyError> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for s in sources {
|
||||
if seen.insert(s.path.clone()) {
|
||||
out.push(s.path.clone());
|
||||
}
|
||||
}
|
||||
for f in validated_hook_json_files_for_sources(sources)? {
|
||||
if seen.insert(f.clone()) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn capture_dir_json_snapshot(dir: &Path) -> Result<DirJsonSnapshot, HookWriteDenyError> {
|
||||
use xai_grok_config::{list_direct_hook_json_files, validate_direct_hook_json_file};
|
||||
let listed = list_direct_hook_json_files(dir).map_err(|e| HookWriteDenyError::VerifyIo {
|
||||
path: dir.to_path_buf(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let mut files = Vec::new();
|
||||
for f in listed {
|
||||
validate_direct_hook_json_file(&f)?;
|
||||
files.push(capture_path_identity(&f)?);
|
||||
}
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(DirJsonSnapshot {
|
||||
dir: dir.to_path_buf(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn build_bwrap_plan(
|
||||
sources: &[GlobalHookSource],
|
||||
) -> Result<HookWriteDenyBwrapPlan, HookWriteDenyError> {
|
||||
let mut leaves = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut dir_json_snapshots = Vec::new();
|
||||
|
||||
for src in sources {
|
||||
if !src.path.exists() {
|
||||
return Err(HookWriteDenyError::Resolve(format!(
|
||||
"required hook write-deny path is missing: {}",
|
||||
src.path.display()
|
||||
)));
|
||||
}
|
||||
if seen.insert(src.path.clone()) {
|
||||
leaves.push(capture_path_identity(&src.path)?);
|
||||
}
|
||||
if src.is_dir() && src.path.is_dir() {
|
||||
let snap = capture_dir_json_snapshot(&src.path)?;
|
||||
for f in &snap.files {
|
||||
if seen.insert(f.path.clone()) {
|
||||
leaves.push(f.clone());
|
||||
}
|
||||
}
|
||||
dir_json_snapshots.push(snap);
|
||||
}
|
||||
}
|
||||
|
||||
let leaf_paths: Vec<PathBuf> = leaves.iter().map(|l| l.path.clone()).collect();
|
||||
let ancestor_rw_binds = unique_ancestors_rootward(sources)
|
||||
.into_iter()
|
||||
.filter(|a| !leaf_paths.iter().any(|l| l == a))
|
||||
.collect();
|
||||
Ok(HookWriteDenyBwrapPlan {
|
||||
ancestor_rw_binds,
|
||||
leaves,
|
||||
dir_json_snapshots,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn revalidate_plan(plan: &HookWriteDenyBwrapPlan) -> Result<(), HookWriteDenyError> {
|
||||
for leaf in &plan.leaves {
|
||||
revalidate_path_identity(leaf)?;
|
||||
}
|
||||
for snap in &plan.dir_json_snapshots {
|
||||
let now = capture_dir_json_snapshot(&snap.dir)?;
|
||||
if now.files.len() != snap.files.len() {
|
||||
return Err(HookWriteDenyError::JsonSnapshotChanged {
|
||||
dir: snap.dir.clone(),
|
||||
});
|
||||
}
|
||||
for (a, b) in snap.files.iter().zip(now.files.iter()) {
|
||||
if a.path != b.path || a.dev != b.dev || a.ino != b.ino || a.nlink != b.nlink {
|
||||
return Err(HookWriteDenyError::JsonSnapshotChanged {
|
||||
dir: snap.dir.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for anc in &plan.ancestor_rw_binds {
|
||||
let meta = std::fs::symlink_metadata(anc).map_err(|e| HookWriteDenyError::VerifyIo {
|
||||
path: anc.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
|
||||
return Err(HookWriteDenyError::IdentityChanged { path: anc.clone() });
|
||||
}
|
||||
if !anc.exists() {
|
||||
return Err(HookWriteDenyError::Resolve(format!(
|
||||
"required ancestor for hook write-deny is missing: {}",
|
||||
anc.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn append_hook_plan_binds(
|
||||
cmd: &mut std::process::Command,
|
||||
plan: &HookWriteDenyBwrapPlan,
|
||||
) -> Result<(), HookWriteDenyError> {
|
||||
revalidate_plan(plan)?;
|
||||
for anc in &plan.ancestor_rw_binds {
|
||||
cmd.arg("--bind").arg(anc).arg(anc);
|
||||
}
|
||||
for leaf in &plan.leaves {
|
||||
cmd.arg("--ro-bind").arg(&leaf.path).arg(&leaf.path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn path_is_effectively_readonly(path: &Path) -> Result<bool, HookWriteDenyError> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let c_path =
|
||||
CString::new(path.as_os_str().as_bytes()).map_err(|_| HookWriteDenyError::VerifyIo {
|
||||
path: path.to_path_buf(),
|
||||
detail: "path contains interior NUL".into(),
|
||||
})?;
|
||||
let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) };
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(HookWriteDenyError::VerifyIo {
|
||||
path: path.to_path_buf(),
|
||||
detail: err.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(buf.f_flag & libc::ST_RDONLY != 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn verify_required_hook_write_denies(paths: &[PathBuf]) -> Result<(), HookWriteDenyError> {
|
||||
for path in paths {
|
||||
if !path_is_effectively_readonly(path)? {
|
||||
return Err(HookWriteDenyError::NotReadOnly { path: path.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_namespace_lockdown() -> Result<(), String> {
|
||||
use std::sync::OnceLock;
|
||||
static INSTALLED: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
INSTALLED
|
||||
.get_or_init(|| {
|
||||
// SAFETY: after bwrap re-exec / at apply; TSYNC covers all threads.
|
||||
unsafe { crate::child_net::install_namespace_lockdown_filter() }
|
||||
.map_err(|e| format!("namespace lockdown seccomp failed: {e}"))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn verify_hook_write_deny_enforced() -> Result<(), String> {
|
||||
ensure_namespace_lockdown()?;
|
||||
let sources = resolve_hook_write_deny_snapshot().map_err(|e| e.to_string())?;
|
||||
let paths = enforcement_leaf_paths(&sources).map_err(|e| e.to_string())?;
|
||||
verify_required_hook_write_denies(&paths).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn verify_hook_write_deny_enforced() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn maybe_install_namespace_lockdown_inside_bwrap(profile: &ProfileName) -> Result<(), String> {
|
||||
if profile_enforces_hook_write_deny(profile) && crate::is_inside_bwrap() {
|
||||
ensure_namespace_lockdown()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn maybe_install_namespace_lockdown_inside_bwrap(_profile: &ProfileName) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "hook_write_deny_tests.rs"]
|
||||
mod tests;
|
||||
176
crates/codegen/xai-grok-sandbox/src/hook_write_deny_tests.rs
Normal file
176
crates/codegen/xai-grok-sandbox/src/hook_write_deny_tests.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn revalidate_refuses_replaced_directory() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-id-race-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let hooks = root.join("hooks");
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
let id = capture_path_identity(&hooks).unwrap();
|
||||
revalidate_path_identity(&id).unwrap();
|
||||
|
||||
let moved = root.join("hooks-old");
|
||||
std::fs::rename(&hooks, &moved).unwrap();
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
|
||||
let err = revalidate_path_identity(&id).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, HookWriteDenyError::IdentityChanged { .. }),
|
||||
"expected IdentityChanged, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revalidate_refuses_symlink_swap() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-id-symlink-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let hooks = root.join("hooks");
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
let id = capture_path_identity(&hooks).unwrap();
|
||||
|
||||
let moved = root.join("hooks-old");
|
||||
std::fs::rename(&hooks, &moved).unwrap();
|
||||
std::os::unix::fs::symlink(&moved, &hooks).unwrap();
|
||||
|
||||
let err = revalidate_path_identity(&id).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
HookWriteDenyError::Symlink { .. } | HookWriteDenyError::IdentityChanged { .. }
|
||||
),
|
||||
"expected symlink/identity error, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_refuses_hardlinked_regular_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-hardlink-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let reg = root.join("hooks-paths");
|
||||
let alias = root.join("hooks-paths-alias");
|
||||
std::fs::write(®, b"").unwrap();
|
||||
std::fs::hard_link(®, &alias).unwrap();
|
||||
|
||||
let err = capture_path_identity(®).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, HookWriteDenyError::HardLink { nlink, .. } if nlink >= 2),
|
||||
"expected HardLink, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn revalidate_rejects_late_json_file_after_plan_capture() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-late-json-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let hooks = root.join("hooks");
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
std::fs::write(hooks.join("keep.json"), b"{}").unwrap();
|
||||
let sources = [GlobalHookSource {
|
||||
path: hooks.clone(),
|
||||
kind: xai_grok_config::GlobalHookSourceKind::HookDirectory,
|
||||
}];
|
||||
let plan = build_bwrap_plan(&sources).expect("plan");
|
||||
revalidate_plan(&plan).expect("stable");
|
||||
|
||||
// Late insert after capture (hardlinked alias also exercises nlink).
|
||||
let late = hooks.join("late.json");
|
||||
let alias = root.join("late-alias.json");
|
||||
std::fs::write(&late, b"{}").unwrap();
|
||||
std::fs::hard_link(&late, &alias).unwrap();
|
||||
|
||||
let err = revalidate_plan(&plan).unwrap_err();
|
||||
// Late hardlinked JSON may surface as Resolve (config validation wrapped via From)
|
||||
// before a typed HardLink/JsonSnapshotChanged, depending on check order.
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
HookWriteDenyError::JsonSnapshotChanged { .. }
|
||||
| HookWriteDenyError::HardLink { .. }
|
||||
| HookWriteDenyError::Resolve(_)
|
||||
),
|
||||
"expected snapshot/hardlink/resolve failure, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardlinked_discovery_json_under_hooks_dir_refused() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-hl-json-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let hooks = root.join("hooks");
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
let active = hooks.join("active.json");
|
||||
let alias = hooks.join("alias.json");
|
||||
std::fs::write(&active, b"{}").unwrap();
|
||||
std::fs::hard_link(&active, &alias).unwrap();
|
||||
let sources = [GlobalHookSource {
|
||||
path: hooks,
|
||||
kind: xai_grok_config::GlobalHookSourceKind::HookDirectory,
|
||||
}];
|
||||
let err = xai_grok_config::validated_hook_json_files_for_sources(&sources).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
xai_grok_config::GlobalHookSourceError::HardLinkedHookFile { .. }
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_hardlinked_files_on_registry_source() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-hl-src-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(root.join("hooks")).unwrap();
|
||||
let reg = root.join("hooks-paths");
|
||||
let alias = root.join("alias");
|
||||
std::fs::write(®, b"").unwrap();
|
||||
std::fs::hard_link(®, &alias).unwrap();
|
||||
|
||||
let sources = [GlobalHookSource {
|
||||
path: reg,
|
||||
kind: xai_grok_config::GlobalHookSourceKind::RegistryFile,
|
||||
}];
|
||||
let err = reject_hardlinked_files(&sources).unwrap_err();
|
||||
assert!(matches!(err, HookWriteDenyError::HardLink { .. }));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
|
@ -28,27 +28,42 @@
|
|||
//! ```
|
||||
pub mod child_net;
|
||||
mod deny;
|
||||
mod hook_write_deny;
|
||||
mod logging;
|
||||
mod network_policy;
|
||||
mod paths;
|
||||
mod profiles;
|
||||
mod types;
|
||||
pub use hook_write_deny::{profile_enforces_hook_write_deny, verify_hook_write_deny_enforced};
|
||||
pub use logging::SandboxLogger;
|
||||
pub use network_policy::{
|
||||
ChildNetworkPolicy, NETWORK_POLICY_SNAPSHOT_VERSION, NetworkPolicySnapshot,
|
||||
NetworkPolicySnapshotError, WebsiteAction, WebsiteOrigin, WebsiteOriginError, WebsitePolicy,
|
||||
};
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use nono::Sandbox;
|
||||
pub use profiles::{
|
||||
ProfileName, SandboxConfig, SandboxProfile, load_sandbox_config, sandbox_profile_conflicts,
|
||||
};
|
||||
use std::path::Path;
|
||||
#[cfg(any(target_os = "linux", all(feature = "enforce", test)))]
|
||||
use std::path::PathBuf;
|
||||
pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics};
|
||||
/// Whether this profile requires direct-hook write protection (non-devbox
|
||||
/// enforcing profiles). Shell fails closed when protection cannot be applied.
|
||||
pub fn requires_hook_write_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
||||
if !profile_enforces_hook_write_deny(profile) || *profile == ProfileName::Off {
|
||||
return false;
|
||||
}
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
match profile {
|
||||
ProfileName::Custom(name) => {
|
||||
config.profiles.get(name).and_then(|p| p.extends.as_deref()) != Some("devbox")
|
||||
}
|
||||
ProfileName::Devbox => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use nono::Sandbox;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics};
|
||||
static SANDBOX: OnceLock<GlobalSandboxState> = OnceLock::new();
|
||||
static CONFIGURED_PROFILE: OnceLock<String> = OnceLock::new();
|
||||
static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false);
|
||||
|
|
@ -150,6 +165,12 @@ impl SandboxManager {
|
|||
tracing::info!("Sandbox disabled (profile: off)");
|
||||
return Ok(());
|
||||
}
|
||||
if requires_hook_write_deny(&self.profile, workspace) {
|
||||
xai_grok_config::ensure_grok_hook_slots(paths::grok_home().as_path())
|
||||
.map_err(|e| anyhow::anyhow!("hook write-deny ensure failed: {e}"))?;
|
||||
hook_write_deny::maybe_install_namespace_lockdown_inside_bwrap(&self.profile)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let mut resolved = self.profile.resolve_profile(workspace, &config)?;
|
||||
self.net_restricted = resolved.restrict_network;
|
||||
|
|
@ -251,6 +272,23 @@ impl SandboxManager {
|
|||
pub fn bwrap_reexec_command(
|
||||
deny_write: &[&str],
|
||||
deny_read: &[&str],
|
||||
) -> Option<std::process::Command> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
bwrap_reexec_command_ex(deny_write, None, deny_read)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (deny_write, deny_read);
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Like [`bwrap_reexec_command`] plus optional hook plan (via `append_hook_plan_binds`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn bwrap_reexec_command_ex(
|
||||
deny_write_optional: &[&str],
|
||||
hook_plan: Option<&hook_write_deny::HookWriteDenyBwrapPlan>,
|
||||
deny_read: &[&str],
|
||||
) -> Option<std::process::Command> {
|
||||
if is_inside_bwrap() {
|
||||
return None;
|
||||
|
|
@ -258,13 +296,19 @@ pub fn bwrap_reexec_command(
|
|||
let self_exe = std::env::current_exe().ok()?;
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut cmd = std::process::Command::new("bwrap");
|
||||
cmd.arg("--cap-drop").arg("ALL");
|
||||
cmd.arg("--bind").arg("/").arg("/");
|
||||
for path in deny_write {
|
||||
for path in deny_write_optional {
|
||||
if Path::new(path).exists() {
|
||||
cmd.arg("--ro-bind").arg(path).arg(path);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(plan) = hook_plan
|
||||
&& let Err(e) = hook_write_deny::append_hook_plan_binds(&mut cmd, plan)
|
||||
{
|
||||
eprintln!("error: hook write-deny plan materialization failed: {e}");
|
||||
return None;
|
||||
}
|
||||
if !deny_read.is_empty() {
|
||||
for path in deny_read {
|
||||
let Some(blocked) = bwrap_blocked_source_for_path(Path::new(path)) else {
|
||||
|
|
@ -277,8 +321,6 @@ pub fn bwrap_reexec_command(
|
|||
cmd.arg("--ro-bind").arg(&blocked).arg(path);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = deny_read;
|
||||
cmd.arg("--dev-bind").arg("/dev").arg("/dev");
|
||||
cmd.arg("--proc").arg("/proc");
|
||||
cmd.env(BWRAP_ENV_VAR, "1");
|
||||
|
|
@ -384,41 +426,56 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
|||
pub fn requires_read_deny(_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
|
||||
/// current matches — globs are best-effort on Linux).
|
||||
/// A profile's resolved bwrap deny plan.
|
||||
#[cfg(target_os = "linux")]
|
||||
struct BwrapDenyPlan {
|
||||
deny_write: Vec<String>,
|
||||
deny_write_optional: Vec<String>,
|
||||
hook_plan: Option<hook_write_deny::HookWriteDenyBwrapPlan>,
|
||||
deny_read: Vec<String>,
|
||||
has_globs: bool,
|
||||
}
|
||||
/// Resolve a profile's full [`BwrapDenyPlan`] in ONE config read: the `/data`
|
||||
/// write-deny (devbox and devbox-extending customs), the exact read-deny paths,
|
||||
/// and the launch-time glob expansion. Returns `None` (fail closed) if a deny
|
||||
/// glob blows past the expansion caps or is invalid, so
|
||||
/// [`bwrap_reexec_for_profile`] refuses to start.
|
||||
///
|
||||
/// Best-effort on Linux: a mount namespace can't glob at runtime, so globs are
|
||||
/// expanded once here at launch — files matching them that are created LATER are
|
||||
/// NOT covered (macOS Seatbelt enforces the same globs as runtime regexes).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
let deny_write_optional: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
vec!["/data".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let entries = if *profile == ProfileName::Off {
|
||||
Vec::new()
|
||||
let resolved = if *profile == ProfileName::Off {
|
||||
None
|
||||
} else {
|
||||
profile
|
||||
.resolve_profile(workspace, &config)
|
||||
.map(|r| r.deny)
|
||||
.unwrap_or_default()
|
||||
match profile.resolve_profile(workspace, &config) {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
if requires_hook_write_deny(profile, workspace) {
|
||||
eprintln!("error: sandbox profile resolve failed: {e}");
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let entries = resolved
|
||||
.as_ref()
|
||||
.map(|r| r.deny.clone())
|
||||
.unwrap_or_default();
|
||||
let needs_hooks = requires_hook_write_deny(profile, workspace);
|
||||
let hook_plan = if needs_hooks {
|
||||
match hook_write_deny::prepare_hook_write_deny(profile) {
|
||||
Ok(hook_write_deny::HookWriteDenyPrepare::NotRequired) => None,
|
||||
Ok(hook_write_deny::HookWriteDenyPrepare::Plan(plan)) => Some(plan),
|
||||
Err(e) => {
|
||||
eprintln!("error: hook write-deny plan failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if needs_hooks && hook_plan.is_none() {
|
||||
return None;
|
||||
}
|
||||
let (exact, globs) = deny::partition_deny_entries(&entries);
|
||||
let mut deny_read = deny::exact_deny_path_strings(workspace, &exact);
|
||||
let has_globs = !globs.is_empty();
|
||||
|
|
@ -437,55 +494,56 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
|
|||
)?);
|
||||
}
|
||||
Some(BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_write_optional,
|
||||
hook_plan,
|
||||
deny_read,
|
||||
has_globs,
|
||||
})
|
||||
}
|
||||
/// Stub when `enforce` is unavailable on Linux: read-deny needs nono, so there is
|
||||
/// none — but the devbox `/data` write-deny is a plain bwrap mount and MUST still
|
||||
/// apply (devbox `/data` is always sandboxed), so it is preserved here.
|
||||
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
let deny_write_optional: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
vec!["/data".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let hook_plan = if requires_hook_write_deny(profile, workspace) {
|
||||
match hook_write_deny::prepare_hook_write_deny(profile) {
|
||||
Ok(hook_write_deny::HookWriteDenyPrepare::NotRequired) => None,
|
||||
Ok(hook_write_deny::HookWriteDenyPrepare::Plan(plan)) => Some(plan),
|
||||
Err(e) => {
|
||||
eprintln!("error: hook write-deny plan failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_write_optional,
|
||||
hook_plan,
|
||||
deny_read: Vec::new(),
|
||||
has_globs: false,
|
||||
})
|
||||
}
|
||||
/// Build the bwrap re-exec command needed on Linux, or `None` if no mount-namespace
|
||||
/// enforcement is needed (or we are already inside bwrap). Canonical routing:
|
||||
/// devbox — and a custom profile that `extends = "devbox"` — gets write-deny on
|
||||
/// `/data`; any profile gets read-deny on its own `deny` set. These compose, so a
|
||||
/// devbox-based custom profile with a `deny` list write-denies `/data` AND
|
||||
/// read-denies its deny paths in one re-exec.
|
||||
///
|
||||
/// Glob deny entries are expanded to concrete existing matches at launch and
|
||||
/// bound over too (best-effort; post-launch matches are not covered on Linux).
|
||||
/// Returns `None` (fail closed) if a glob blows past the expansion caps, so the
|
||||
/// shell's startup refuses to run with a broad glob under-enforced.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn bwrap_reexec_for_profile(
|
||||
profile: &ProfileName,
|
||||
workspace: &Path,
|
||||
) -> Option<std::process::Command> {
|
||||
let BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_write_optional,
|
||||
hook_plan,
|
||||
deny_read,
|
||||
has_globs,
|
||||
} = bwrap_deny_plan(profile, workspace)?;
|
||||
if deny_write.is_empty() && deny_read.is_empty() && !has_globs {
|
||||
if deny_write_optional.is_empty() && hook_plan.is_none() && deny_read.is_empty() && !has_globs {
|
||||
return None;
|
||||
}
|
||||
let write_refs: Vec<&str> = deny_write.iter().map(String::as_str).collect();
|
||||
let write_opt: Vec<&str> = deny_write_optional.iter().map(String::as_str).collect();
|
||||
let read_refs: Vec<&str> = deny_read.iter().map(String::as_str).collect();
|
||||
bwrap_reexec_command(&write_refs, &read_refs)
|
||||
bwrap_reexec_command_ex(&write_opt, hook_plan.as_ref(), &read_refs)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -528,6 +586,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_reexec_returns_some_outside_bwrap() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
||||
|
|
@ -553,6 +612,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_reexec_skips_nonexistent_paths() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/nonexistent-test-path-xyz-12345"], &[]);
|
||||
|
|
@ -588,6 +648,7 @@ mod tests {
|
|||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_reexec_mounts_existing_paths_read_only() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
||||
|
|
@ -602,8 +663,95 @@ mod tests {
|
|||
"should mount existing paths as --ro-bind, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
/// Hook plan: rootward ancestor RW self-binds precede leaf RO; no bwrap
|
||||
/// version flags required. Identity revalidation is part of append.
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_hook_plan_binds_ancestors_then_leaves() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"grok-bwrap-hook-plan-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let parent = root.join("sessions");
|
||||
let leaf = parent.join("extra-hooks");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
let sources = [xai_grok_config::GlobalHookSource {
|
||||
path: leaf.clone(),
|
||||
kind: xai_grok_config::GlobalHookSourceKind::ConfiguredSource,
|
||||
}];
|
||||
let plan = hook_write_deny::build_bwrap_plan(&sources).expect("plan");
|
||||
assert!(
|
||||
!plan.ancestor_rw_binds.iter().any(|p| p == Path::new("/")),
|
||||
"must not RW-bind /: {:?}",
|
||||
plan.ancestor_rw_binds
|
||||
);
|
||||
assert!(
|
||||
plan.ancestor_rw_binds.iter().any(|p| p == &parent),
|
||||
"immediate parent must be pinned: {:?}",
|
||||
plan.ancestor_rw_binds
|
||||
);
|
||||
for w in plan.ancestor_rw_binds.windows(2) {
|
||||
assert!(
|
||||
w[0].components().count() <= w[1].components().count(),
|
||||
"ancestors not rootward: {:?}",
|
||||
plan.ancestor_rw_binds
|
||||
);
|
||||
}
|
||||
let moved = root.join("extra-hooks-old");
|
||||
std::fs::rename(&leaf, &moved).unwrap();
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
let mut refuse = std::process::Command::new("bwrap");
|
||||
let err = hook_write_deny::append_hook_plan_binds(&mut refuse, &plan);
|
||||
assert!(err.is_err(), "must refuse replaced leaf identity");
|
||||
let _ = std::fs::remove_dir_all(&leaf);
|
||||
std::fs::rename(&moved, &leaf).unwrap();
|
||||
let plan = hook_write_deny::build_bwrap_plan(&sources).expect("plan2");
|
||||
let cmd = bwrap_reexec_command_ex(&[], Some(&plan), &[]).expect("bwrap command");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
!args.iter().any(|a| a == "--disable-userns"),
|
||||
"must not require --disable-userns: {args:?}"
|
||||
);
|
||||
assert!(
|
||||
args.windows(2).any(|w| w == ["--cap-drop", "ALL"]),
|
||||
"expected --cap-drop ALL: {args:?}"
|
||||
);
|
||||
let parent_s = parent.to_string_lossy().to_string();
|
||||
let leaf_s = leaf.to_string_lossy().to_string();
|
||||
let anc_parent = args
|
||||
.windows(3)
|
||||
.position(|w| w[0] == "--bind" && w[1] == parent_s && w[2] == parent_s);
|
||||
let leaf_pos = args
|
||||
.windows(3)
|
||||
.position(|w| w[0] == "--ro-bind" && w[1] == leaf_s && w[2] == leaf_s);
|
||||
assert!(anc_parent.is_some(), "expected RW bind of parent: {args:?}");
|
||||
assert!(leaf_pos.is_some(), "expected RO bind of leaf: {args:?}");
|
||||
assert!(
|
||||
anc_parent.unwrap() < leaf_pos.unwrap(),
|
||||
"ancestor RW must precede leaf RO; args: {args:?}"
|
||||
);
|
||||
for anc in &plan.ancestor_rw_binds {
|
||||
let a = anc.to_string_lossy().to_string();
|
||||
let pos = args
|
||||
.windows(3)
|
||||
.position(|w| w[0] == "--bind" && w[1] == a && w[2] == a);
|
||||
assert!(pos.is_some(), "missing RW bind for {a}: {args:?}");
|
||||
assert!(pos.unwrap() < leaf_pos.unwrap());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_reexec_uses_dev_bind() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&[], &[]);
|
||||
|
|
@ -745,8 +893,8 @@ mod tests {
|
|||
"[profiles.wsempty]\nextends = \"workspace\"\n",
|
||||
);
|
||||
assert!(
|
||||
bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_none(),
|
||||
"non-devbox custom with no deny needs no re-exec"
|
||||
bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_some(),
|
||||
"non-devbox custom must re-exec for direct-hook write-deny"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&ws_ws);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
//! Filesystem path tables for sandbox profiles.
|
||||
//!
|
||||
//! Collects device files, temp directories, sensitive deny-paths, and
|
||||
//! ecosystem (package-manager / toolchain) writable paths into helpers
|
||||
//! consumed by [`super::profiles`].
|
||||
//! Collects device files, temp directories, and essential writable paths.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use crate::deny::{
|
||||
apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths,
|
||||
partition_deny_entries,
|
||||
apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set,
|
||||
apply_write_deny_paths_to_capability_set, effective_deny_paths, partition_deny_entries,
|
||||
};
|
||||
use crate::hook_write_deny::profile_hook_write_deny;
|
||||
use crate::paths::grok_home;
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use crate::paths::{DEVICE_DIRS, DEVICE_FILES};
|
||||
use crate::paths::{essential_writable_paths, essential_writable_paths_minimal};
|
||||
use xai_grok_config::GlobalHookSource;
|
||||
|
||||
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -30,12 +32,18 @@ pub struct SandboxProfile {
|
|||
pub read_write: Vec<PathBuf>,
|
||||
/// Paths denied entirely (overrides read_only/read_write)
|
||||
pub deny: Vec<PathBuf>,
|
||||
/// Typed direct global hook sources (write-denied, still readable).
|
||||
pub write_deny: Vec<GlobalHookSource>,
|
||||
/// Whether to grant read access to the entire filesystem by default
|
||||
pub default_read: bool,
|
||||
/// Whether child processes should have network blocked
|
||||
pub restrict_network: bool,
|
||||
}
|
||||
|
||||
fn resolve_write_deny(profile: &ProfileName) -> anyhow::Result<Vec<GlobalHookSource>> {
|
||||
profile_hook_write_deny(profile)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -280,6 +288,27 @@ impl ProfileName {
|
|||
}
|
||||
}
|
||||
|
||||
// Direct global-hook write-deny (macOS Seatbelt; Linux via bwrap).
|
||||
if !profile.write_deny.is_empty() {
|
||||
let mut pairs: Vec<(PathBuf, bool)> = profile
|
||||
.write_deny
|
||||
.iter()
|
||||
.map(|s| (s.path.clone(), s.is_dir()))
|
||||
.collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let files =
|
||||
xai_grok_config::validated_hook_json_files_for_sources(&profile.write_deny)
|
||||
.map_err(|e| anyhow::anyhow!("hook JSON alias validation failed: {e}"))?;
|
||||
for f in files {
|
||||
if !pairs.iter().any(|(p, _)| p == &f) {
|
||||
pairs.push((f, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_write_deny_paths_to_capability_set(&mut caps, &pairs, &profile.read_write)?;
|
||||
}
|
||||
|
||||
// Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over.
|
||||
// The effective deny set is the profile's own `deny` (custom profiles only;
|
||||
// built-ins carry an empty `deny`). An empty set means there is nothing to
|
||||
|
|
@ -325,6 +354,7 @@ impl ProfileName {
|
|||
read_only: vec![],
|
||||
read_write: essential_writable_paths(workspace),
|
||||
deny: vec![],
|
||||
write_deny: resolve_write_deny(self)?,
|
||||
default_read: true,
|
||||
restrict_network: false,
|
||||
}),
|
||||
|
|
@ -363,6 +393,7 @@ impl ProfileName {
|
|||
read_only: vec![],
|
||||
read_write,
|
||||
deny: vec![],
|
||||
write_deny: vec![],
|
||||
default_read: true,
|
||||
restrict_network: false,
|
||||
})
|
||||
|
|
@ -373,6 +404,7 @@ impl ProfileName {
|
|||
read_only: vec![],
|
||||
read_write: essential_writable_paths_minimal(),
|
||||
deny: vec![],
|
||||
write_deny: resolve_write_deny(self)?,
|
||||
default_read: true,
|
||||
restrict_network: true,
|
||||
}),
|
||||
|
|
@ -398,6 +430,7 @@ impl ProfileName {
|
|||
.chain(std::iter::once(home.join("Library")))
|
||||
.filter(|p| p.exists())
|
||||
.chain(std::iter::once(workspace.to_path_buf()))
|
||||
.chain(std::iter::once(grok_home()))
|
||||
.collect();
|
||||
|
||||
Ok(SandboxProfile {
|
||||
|
|
@ -405,6 +438,7 @@ impl ProfileName {
|
|||
read_only: system_read,
|
||||
read_write: essential_writable_paths(workspace),
|
||||
deny: vec![],
|
||||
write_deny: resolve_write_deny(self)?,
|
||||
default_read: false,
|
||||
restrict_network: true,
|
||||
})
|
||||
|
|
@ -422,7 +456,7 @@ impl ProfileName {
|
|||
})?;
|
||||
|
||||
// Start from the base profile if `extends` is set
|
||||
let mut profile = if let Some(base_name) = &profile_config.extends {
|
||||
let (base, mut profile) = if let Some(base_name) = &profile_config.extends {
|
||||
let base: ProfileName = base_name.parse().map_err(|e: String| {
|
||||
anyhow::anyhow!("Profile '{name}' extends invalid base: {e}")
|
||||
})?;
|
||||
|
|
@ -438,10 +472,10 @@ impl ProfileName {
|
|||
cannot extend other custom profiles (only built-ins)"
|
||||
);
|
||||
}
|
||||
base.resolve(workspace, config)?
|
||||
let resolved = base.resolve(workspace, config)?;
|
||||
(base, resolved)
|
||||
} else {
|
||||
// Default: start from workspace
|
||||
Self::Workspace.resolve(workspace, config)?
|
||||
(Self::Workspace, Self::Workspace.resolve(workspace, config)?)
|
||||
};
|
||||
|
||||
profile.name = name.clone();
|
||||
|
|
@ -466,6 +500,10 @@ impl ProfileName {
|
|||
profile.deny.push(PathBuf::from(path_str));
|
||||
}
|
||||
|
||||
if matches!(base, Self::Devbox) {
|
||||
profile.write_deny.clear();
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
|
@ -528,8 +566,26 @@ mod tests {
|
|||
assert_eq!(p.to_string(), "my-custom");
|
||||
}
|
||||
|
||||
/// Hosts with a retargetable `$GROK_HOME/hooks` symlink (fail-closed under
|
||||
/// write-deny) cannot resolve enforcing profiles against the real home.
|
||||
fn skip_if_host_hook_write_deny_unresolvable() -> bool {
|
||||
if !crate::hook_write_deny::profile_enforces_hook_write_deny(&ProfileName::Workspace) {
|
||||
return false;
|
||||
}
|
||||
match crate::hook_write_deny::resolve_hook_write_deny_snapshot() {
|
||||
Ok(_) => false,
|
||||
Err(e) => {
|
||||
eprintln!("skipping profile resolve test: host hook write-deny unresolvable ({e})");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_network_restriction_values() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig::default();
|
||||
|
||||
|
|
@ -593,6 +649,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn custom_network_restriction_inherits_and_overrides_base() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = network_inheritance_config();
|
||||
|
||||
|
|
@ -611,6 +670,9 @@ mod tests {
|
|||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn strict_allowlist_includes_run_and_var_when_present() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
// Regression: /run (resolv realpath) + /var (NSS/SSSD) when present.
|
||||
let workspace = std::env::temp_dir();
|
||||
let profile = ProfileName::Strict
|
||||
|
|
@ -636,6 +698,9 @@ mod tests {
|
|||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn base_profile_capability_set_builds() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
// A base profile with no `deny` builds a CapabilitySet without erroring.
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig::default();
|
||||
|
|
@ -646,6 +711,9 @@ mod tests {
|
|||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn custom_profile_from_config() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig {
|
||||
profiles: HashMap::from([(
|
||||
|
|
@ -901,6 +969,9 @@ read_write = ["/tmp/ci-artifacts"]
|
|||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn strict_capability_set_builds_without_openable_dev_tty() {
|
||||
if skip_if_host_hook_write_deny_unresolvable() {
|
||||
return;
|
||||
}
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let result = ProfileName::Strict.to_capability_set(&workspace);
|
||||
assert!(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue