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:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -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);
}
}

View file

@ -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() {

View 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;

View 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(&reg, b"").unwrap();
std::fs::hard_link(&reg, &alias).unwrap();
let err = capture_path_identity(&reg).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(&reg, b"").unwrap();
std::fs::hard_link(&reg, &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);
}

View file

@ -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);
}

View file

@ -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};

View file

@ -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