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
1057 lines
38 KiB
Rust
1057 lines
38 KiB
Rust
//! E2E path-deny and Grok hook write-deny (subprocess; arm64-tagged).
|
|
//! Soft-skips when enforcement is unavailable; only
|
|
//! `SANDBOX_E2E_REQUIRE_ENFORCEMENT` hard-requires a usable backend.
|
|
|
|
#![cfg(all(unix, feature = "enforce"))]
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
const SCENARIO_ENV: &str = "SANDBOX_E2E_SCENARIO";
|
|
const WORKSPACE_ENV: &str = "SANDBOX_E2E_WORKSPACE";
|
|
const GROK_HOME_ENV: &str = "SANDBOX_E2E_GROK_HOME";
|
|
const HOME_ENV: &str = "SANDBOX_E2E_HOME";
|
|
const PROFILE_ENV: &str = "SANDBOX_E2E_PROFILE";
|
|
const TARGETS_ENV: &str = "SANDBOX_E2E_TARGETS";
|
|
const CONTROLS_ENV: &str = "SANDBOX_E2E_CONTROLS";
|
|
const POSTLAUNCH_ENV: &str = "SANDBOX_E2E_POSTLAUNCH";
|
|
const MARKER: &str = "deny-paths-e2e-marker-9f3c1a";
|
|
const REQUIRE_ENV: &str = "SANDBOX_E2E_REQUIRE_ENFORCEMENT";
|
|
|
|
fn apply_fixture_env(cmd: &mut Command, home: &Path, grok_home: &Path, workspace: &Path) {
|
|
cmd.env(WORKSPACE_ENV, workspace.as_os_str())
|
|
.env(HOME_ENV, home.as_os_str())
|
|
.env(GROK_HOME_ENV, grok_home.as_os_str())
|
|
.env("HOME", home.as_os_str())
|
|
.env("GROK_HOME", grok_home.as_os_str());
|
|
}
|
|
|
|
/// Re-invoke this test binary as a subprocess driving `profile` over `targets`
|
|
/// (denied) and `controls` (must stay readable). `postlaunch` paths are created
|
|
/// AFTER apply to exercise the macOS runtime-regex (post-launch) coverage.
|
|
fn run_scenario(
|
|
home: &Path,
|
|
grok_home: &Path,
|
|
workspace: &Path,
|
|
profile: &str,
|
|
targets: &[&str],
|
|
controls: &[&str],
|
|
postlaunch: &[&str],
|
|
) -> (std::process::ExitStatus, String) {
|
|
let exe = std::env::current_exe().expect("current_exe");
|
|
let mut cmd = Command::new(exe);
|
|
apply_fixture_env(&mut cmd, home, grok_home, workspace);
|
|
let output = cmd
|
|
.env(SCENARIO_ENV, "block_deny")
|
|
.env(PROFILE_ENV, profile)
|
|
.env(TARGETS_ENV, targets.join(","))
|
|
.env(CONTROLS_ENV, controls.join(","))
|
|
.env(POSTLAUNCH_ENV, postlaunch.join(","))
|
|
.arg("--ignored")
|
|
.arg("--exact")
|
|
.arg("--nocapture")
|
|
.arg("subprocess_entry")
|
|
.output()
|
|
.expect("failed to spawn subprocess");
|
|
(
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).into_owned(),
|
|
)
|
|
}
|
|
|
|
/// Re-invoke as a subprocess for the direct-hook write-deny scenarios.
|
|
fn run_hook_write_deny_scenario(
|
|
home: &Path,
|
|
grok_home: &Path,
|
|
workspace: &Path,
|
|
scenario: &str,
|
|
) -> (std::process::ExitStatus, String) {
|
|
let exe = std::env::current_exe().expect("current_exe");
|
|
let mut cmd = Command::new(exe);
|
|
apply_fixture_env(&mut cmd, home, grok_home, workspace);
|
|
let output = cmd
|
|
.env(SCENARIO_ENV, scenario)
|
|
.arg("--ignored")
|
|
.arg("--exact")
|
|
.arg("--nocapture")
|
|
.arg("subprocess_entry")
|
|
.output()
|
|
.expect("failed to spawn subprocess");
|
|
(
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr).into_owned(),
|
|
)
|
|
}
|
|
|
|
/// Soft-skip when the platform cannot enforce kernel denials.
|
|
/// Only `SANDBOX_E2E_REQUIRE_ENFORCEMENT` hard-requires enforcement; generic
|
|
/// CI/`GITHUB_ACTIONS` alone must not (remote arm64 may lack usable bwrap).
|
|
fn skip_if_enforcement_unavailable() -> bool {
|
|
let require = std::env::var(REQUIRE_ENV).is_ok();
|
|
|
|
let support = xai_grok_sandbox::SandboxManager::support_info();
|
|
if !support.is_supported {
|
|
if require {
|
|
panic!(
|
|
"enforcement required ({REQUIRE_ENV}) but sandbox unsupported: {}",
|
|
support.details
|
|
);
|
|
}
|
|
eprintln!("skipping: sandbox not supported ({})", support.details);
|
|
return true;
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
if !bwrap_available() {
|
|
if require {
|
|
panic!(
|
|
"enforcement required ({REQUIRE_ENV}) but bwrap unavailable \
|
|
(required for Linux path / hook write-deny)"
|
|
);
|
|
}
|
|
eprintln!("skipping: bwrap not installed (required for Linux path / hook write-deny)");
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn unique_temp_dir(tag: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"grok-sandbox-e2e-{tag}-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(&dir).expect("create temp dir");
|
|
dunce::canonicalize(&dir).expect("canonicalize temp dir")
|
|
}
|
|
|
|
/// Decode a comma-joined env list (empty/missing -> empty vec).
|
|
fn list_from_env(key: &str) -> Vec<String> {
|
|
std::env::var(key)
|
|
.ok()
|
|
.map(|v| {
|
|
v.split(',')
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// EROFS too: a root writer on Linux bypasses the mode-000 DAC check via
|
|
// CAP_DAC_OVERRIDE and hits the read-only bind-mount instead — still a denial.
|
|
fn is_permission_denied(e: &std::io::Error) -> bool {
|
|
matches!(
|
|
e.raw_os_error(),
|
|
Some(libc::EACCES) | Some(libc::EPERM) | Some(libc::EROFS)
|
|
)
|
|
}
|
|
|
|
/// Unlink of a read-only bind-mounted leaf can return EBUSY (ResourceBusy) on
|
|
/// Linux bubblewrap rather than EACCES/EPERM — still an effective denial.
|
|
fn is_unlink_denied(e: &std::io::Error) -> bool {
|
|
is_permission_denied(e) || e.raw_os_error() == Some(libc::EBUSY)
|
|
}
|
|
|
|
/// Rename of a RO bind-mount leaf/mountpoint can return EXDEV or EBUSY — still
|
|
/// an effective denial (no destination created).
|
|
fn is_rename_denied(e: &std::io::Error) -> bool {
|
|
is_permission_denied(e) || matches!(e.raw_os_error(), Some(libc::EXDEV) | Some(libc::EBUSY))
|
|
}
|
|
|
|
/// Spawn a child command and `exit(1)` if its stdout exposes the secret MARKER.
|
|
/// Asserts marker-absence rather than a non-zero exit: a root reader of the
|
|
/// mode-000 placeholder gets empty output, which still means the path is shadowed.
|
|
fn assert_child_cannot_read(label: &str, program: &str, args: &[&str]) {
|
|
let out = Command::new(program)
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to spawn {program}: {e}"));
|
|
if String::from_utf8_lossy(&out.stdout).contains(MARKER) {
|
|
eprintln!("FAIL: {label} exposed MARKER");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Assert a denied file's bytes are unreadable via an in-process read, a `cat`
|
|
/// child (the `bash`/`grep` tools), and a nested `sh -c "cat"` child (the shell a
|
|
/// subagent shells out through). The property is MARKER-absence (EACCES/EPERM, or
|
|
/// empty output under root, all satisfy it).
|
|
fn assert_read_blocked(label: &str, path: &Path) {
|
|
if let Ok(content) = fs::read_to_string(path)
|
|
&& content.contains(MARKER)
|
|
{
|
|
eprintln!("FAIL: {label} in-process read exposed MARKER");
|
|
std::process::exit(1);
|
|
}
|
|
let s = path.display().to_string();
|
|
assert_child_cannot_read(label, "cat", &[s.as_str()]);
|
|
let sh_cmd = format!("cat '{s}'");
|
|
assert_child_cannot_read(label, "sh", &["-c", sh_cmd.as_str()]);
|
|
eprintln!("OK: {label} read blocked");
|
|
}
|
|
|
|
/// Assert a denied file cannot be overwritten (write must EACCES/EPERM, not
|
|
/// succeed — a permitted write would enable the relocation bypass below).
|
|
fn assert_write_denied(label: &str, path: &Path) {
|
|
match fs::write(path, "overwrite-attempt") {
|
|
Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} write denied"),
|
|
Err(e) => {
|
|
eprintln!("FAIL: unexpected {label} write error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
Ok(()) => {
|
|
eprintln!("FAIL: {label} write was permitted (relocation bypass possible)");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assert the `mv x y && cat y` relocation bypass does not expose the bytes:
|
|
/// the rename must fail (unlink of the source is denied) so the moved copy never
|
|
/// materializes with the secret.
|
|
fn assert_rename_bypass_blocked(label: &str, path: &Path, workspace: &Path) {
|
|
let name = path.file_name().unwrap().to_string_lossy();
|
|
let moved = workspace.join(format!("exfil-{name}"));
|
|
let _ = fs::rename(path, &moved); // expected to fail; bytes must not leak
|
|
match fs::read_to_string(&moved) {
|
|
Ok(c) if c.contains(MARKER) => {
|
|
eprintln!("FAIL: {label} rename bypass exposed MARKER");
|
|
std::process::exit(1);
|
|
}
|
|
_ => eprintln!("OK: {label} rename bypass blocked"),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn bwrap_available() -> bool {
|
|
// `--version` only checks the binary exists; remote CI may have bwrap but
|
|
// deny user namespace creation ("Creating new namespace failed: Operation not permitted").
|
|
Command::new("bwrap")
|
|
.args(["--bind", "/", "/", "--", "true"])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// The custom profile under test, read from the env the parent set.
|
|
fn profile_from_env() -> xai_grok_sandbox::ProfileName {
|
|
xai_grok_sandbox::ProfileName::Custom(std::env::var(PROFILE_ENV).expect(PROFILE_ENV))
|
|
}
|
|
|
|
// ── Subprocess entry point ──────────────────────────────────────────────
|
|
|
|
/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario`
|
|
/// / `run_hook_write_deny_scenario`.
|
|
#[test]
|
|
#[ignore]
|
|
fn subprocess_entry() {
|
|
let scenario = match std::env::var(SCENARIO_ENV) {
|
|
Ok(s) => s,
|
|
Err(_) => return,
|
|
};
|
|
let workspace = std::env::var(WORKSPACE_ENV).expect(WORKSPACE_ENV);
|
|
let workspace = dunce::canonicalize(&workspace).expect("canonicalize workspace");
|
|
let workspace = workspace.as_path();
|
|
|
|
// Isolate HOME/GROK_HOME before any config OnceLock init.
|
|
let home = PathBuf::from(std::env::var(HOME_ENV).expect(HOME_ENV));
|
|
let grok_home = PathBuf::from(std::env::var(GROK_HOME_ENV).expect(GROK_HOME_ENV));
|
|
// SAFETY: isolated subprocess; set before sandbox/config first use.
|
|
unsafe {
|
|
std::env::set_var("HOME", &home);
|
|
std::env::set_var("GROK_HOME", &grok_home);
|
|
}
|
|
|
|
match scenario.as_str() {
|
|
"block_deny" => subprocess_block_deny(workspace),
|
|
"hook_write_deny" => subprocess_hook_write_deny(workspace, /* first_run */ false),
|
|
"hook_write_deny_first_run" => {
|
|
subprocess_hook_write_deny(workspace, /* first_run */ true)
|
|
}
|
|
"hook_write_deny_marker_spoof" => subprocess_hook_write_deny_marker_spoof(&grok_home),
|
|
other => {
|
|
eprintln!("unknown scenario: {other}");
|
|
std::process::exit(99);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn subprocess_profile_and_bwrap_reexec(profile: &xai_grok_sandbox::ProfileName, workspace: &Path) {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if !xai_grok_sandbox::is_inside_bwrap() {
|
|
// Drive the REAL routing the shell uses at startup — computing the
|
|
// profile's deny / write-deny set, building the plan, and failing
|
|
// closed on a partial bind — rather than hand-rolling a single-path
|
|
// `bwrap_reexec_command`.
|
|
match xai_grok_sandbox::bwrap_reexec_for_profile(profile, workspace) {
|
|
Some(mut cmd) => {
|
|
use std::os::unix::process::CommandExt;
|
|
let err = cmd.exec(); // returns only if exec failed
|
|
eprintln!("bwrap re-exec failed: {err}");
|
|
std::process::exit(2);
|
|
}
|
|
// Outside bwrap with no command means the deny set could not
|
|
// be secured. The shell fails closed here; mirror that.
|
|
None => {
|
|
eprintln!("FAIL: bwrap_reexec_for_profile returned None outside bwrap");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
let _ = (profile, workspace);
|
|
}
|
|
}
|
|
|
|
fn subprocess_block_deny(workspace: &Path) {
|
|
let targets = list_from_env(TARGETS_ENV);
|
|
let controls = list_from_env(CONTROLS_ENV);
|
|
let profile = profile_from_env();
|
|
subprocess_profile_and_bwrap_reexec(&profile, workspace);
|
|
|
|
let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile, workspace);
|
|
if let Err(e) = sandbox.apply(workspace) {
|
|
eprintln!("sandbox apply failed: {e}");
|
|
std::process::exit(3);
|
|
}
|
|
if !sandbox.is_applied() {
|
|
eprintln!("sandbox was not applied (unsupported platform?)");
|
|
std::process::exit(4);
|
|
}
|
|
|
|
for rel in &targets {
|
|
let path = workspace.join(rel);
|
|
assert_read_blocked(rel, &path);
|
|
assert_write_denied(rel, &path);
|
|
assert_rename_bypass_blocked(rel, &path, workspace);
|
|
}
|
|
|
|
for rel in &controls {
|
|
match fs::read_to_string(workspace.join(rel)) {
|
|
Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"),
|
|
Ok(_) => {
|
|
eprintln!("FAIL: control {rel} readable but missing marker");
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("FAIL: control {rel} should stay readable: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
for rel in list_from_env(POSTLAUNCH_ENV) {
|
|
match fs::write(workspace.join(&rel), MARKER) {
|
|
Err(e) if is_permission_denied(&e) => {
|
|
eprintln!("OK: {rel} post-launch write denied")
|
|
}
|
|
Err(e) => {
|
|
eprintln!("FAIL: unexpected {rel} post-launch write error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
Ok(()) => {
|
|
eprintln!("FAIL: {rel} post-launch matching path was writable");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
if !list_from_env(POSTLAUNCH_ENV).is_empty() {
|
|
match fs::write(workspace.join("late-control.txt"), "hello") {
|
|
Ok(()) => eprintln!("OK: post-launch control writable"),
|
|
Err(e) => {
|
|
eprintln!("FAIL: non-matching post-launch path should be writable: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::process::exit(0);
|
|
}
|
|
|
|
/// Assert a path cannot be created via `create_dir` (mkdir denied).
|
|
fn assert_mkdir_denied(label: &str, path: &Path) {
|
|
match fs::create_dir(path) {
|
|
Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} mkdir denied"),
|
|
Err(e) => {
|
|
eprintln!("FAIL: unexpected {label} mkdir error: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
Ok(()) => {
|
|
eprintln!("FAIL: {label} mkdir was permitted");
|
|
let _ = fs::remove_dir(path);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assert a path cannot be unlinked.
|
|
fn assert_unlink_denied(label: &str, path: &Path) {
|
|
match fs::remove_file(path) {
|
|
Err(e) if is_unlink_denied(&e) => eprintln!("OK: {label} unlink denied"),
|
|
other => {
|
|
eprintln!("FAIL: {label} unlink expected denial, got {other:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assert a rename of `from` out of the deny set fails.
|
|
fn assert_rename_denied(label: &str, from: &Path, to: &Path) {
|
|
match fs::rename(from, to) {
|
|
Err(e) if is_rename_denied(&e) => eprintln!("OK: {label} rename denied"),
|
|
other => {
|
|
eprintln!("FAIL: {label} rename expected denial, got {other:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assert a non-denied sibling path is writable.
|
|
fn assert_write_ok(label: &str, path: &Path) {
|
|
match fs::write(path, "ok") {
|
|
Ok(()) => eprintln!("OK: {label} writable"),
|
|
Err(e) => {
|
|
eprintln!("FAIL: {label} should be writable: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Marker spoof: claim to be inside bwrap without real RO mounts — verify must fail.
|
|
/// Linux-only (verify is a no-op on macOS). Isolated subprocess; no shared env mutation.
|
|
fn subprocess_hook_write_deny_marker_spoof(_grok_home: &Path) {
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
eprintln!("OK: marker spoof N/A on non-linux");
|
|
std::process::exit(0);
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// Fixture already has hooks/ + hooks-paths from parent.
|
|
// SAFETY: isolated subprocess.
|
|
unsafe {
|
|
std::env::set_var("__GROK_INSIDE_BWRAP", "1");
|
|
}
|
|
match xai_grok_sandbox::verify_hook_write_deny_enforced() {
|
|
Ok(()) => {
|
|
eprintln!("FAIL: marker alone must not satisfy write-deny verification");
|
|
std::process::exit(1);
|
|
}
|
|
Err(msg) => {
|
|
if msg.contains("read-only")
|
|
|| msg.contains("NotReadOnly")
|
|
|| msg.contains("hook write-deny")
|
|
|| msg.contains("effectively read-only")
|
|
{
|
|
eprintln!("OK: marker spoof refused ({msg})");
|
|
std::process::exit(0);
|
|
}
|
|
eprintln!("FAIL: unexpected verify error: {msg}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Workspace-profile Grok-owned hook write-deny probes (existing sources + first-run).
|
|
fn subprocess_hook_write_deny(workspace: &Path, first_run: bool) {
|
|
let home = PathBuf::from(std::env::var(GROK_HOME_ENV).expect(GROK_HOME_ENV));
|
|
|
|
let profile = xai_grok_sandbox::ProfileName::Workspace;
|
|
subprocess_profile_and_bwrap_reexec(&profile, workspace);
|
|
|
|
let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile, workspace);
|
|
if let Err(e) = sandbox.apply(workspace) {
|
|
eprintln!("sandbox apply failed: {e}");
|
|
std::process::exit(3);
|
|
}
|
|
// Seatbelt is the macOS enforcement path; on Linux the write-denies are
|
|
// primarily the bwrap ro-binds established above.
|
|
#[cfg(target_os = "macos")]
|
|
if !sandbox.is_applied() {
|
|
eprintln!("sandbox was not applied");
|
|
std::process::exit(4);
|
|
}
|
|
|
|
let hooks_dir = home.join("hooks");
|
|
let hooks_paths = home.join("hooks-paths");
|
|
|
|
if first_run {
|
|
// Fixed slots are ensured as real host paths before apply; they exist
|
|
// and must be write-denied (not private placeholders).
|
|
if !hooks_dir.is_dir() {
|
|
eprintln!("FAIL: first-run expected real hooks dir to be ensured");
|
|
std::process::exit(1);
|
|
}
|
|
if !hooks_paths.is_file() {
|
|
eprintln!("FAIL: first-run expected real hooks-paths file to be ensured");
|
|
std::process::exit(1);
|
|
}
|
|
assert_write_denied("hooks-paths (first-run)", &hooks_paths);
|
|
assert_mkdir_denied("hooks nested (first-run)", &hooks_dir.join("nested"));
|
|
assert_write_denied(
|
|
"hooks nested file (first-run)",
|
|
&hooks_dir.join("planted.json"),
|
|
);
|
|
eprintln!("OK: first-run Grok hook slots denied");
|
|
} else {
|
|
// Existing hook content stays readable.
|
|
let keep = hooks_dir.join("keep.json");
|
|
match fs::read_to_string(&keep) {
|
|
Ok(c) if c.contains("keep-me") => eprintln!("OK: hooks readable"),
|
|
other => {
|
|
eprintln!("FAIL: expected readable hook, got {other:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
assert_write_denied("hooks file", &hooks_dir.join("planted.json"));
|
|
assert_write_denied("hooks-paths", &hooks_paths);
|
|
let dynamic = home.join("sessions").join("extra-hooks");
|
|
assert_write_denied("dynamic target", &dynamic.join("x.json"));
|
|
|
|
assert_unlink_denied("hooks-paths", &hooks_paths);
|
|
assert_rename_denied("hooks", &keep, &home.join("keep.exfil"));
|
|
assert_mkdir_denied("hooks nested dir", &hooks_dir.join("nested-deny"));
|
|
|
|
// Parent-rename bypass: renaming `sessions` must fail; leaf stays protected.
|
|
let sessions = home.join("sessions");
|
|
let sessions_old = home.join("sessions-old");
|
|
match fs::rename(&sessions, &sessions_old) {
|
|
Err(e) if is_rename_denied(&e) => {
|
|
eprintln!("OK: parent rename denied");
|
|
}
|
|
other => {
|
|
// If rename somehow succeeded, the lexical target must still
|
|
// not be a writable fresh tree — but success is a hard fail.
|
|
let _ = fs::rename(&sessions_old, &sessions);
|
|
eprintln!("FAIL: parent rename expected denial, got {other:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
// Sibling under sessions still writable (ancestor pin is node-only on macOS;
|
|
// on Linux the sessions dir is a RW mountpoint so creates inside still work).
|
|
assert_write_ok(
|
|
"sessions sibling",
|
|
&sessions.join(format!("runtime-{}.lock", std::process::id())),
|
|
);
|
|
|
|
// Configured source under workspace (writable grant root): parent rename
|
|
// denied; sibling under the same parent remains writable.
|
|
let ws_parent = workspace.join("extra-parent");
|
|
let ws_hooks = ws_parent.join("vendor-hooks");
|
|
if ws_hooks.is_dir() {
|
|
assert_write_denied("ws configured", &ws_hooks.join("x.json"));
|
|
let renamed = workspace.join("extra-parent-old");
|
|
match fs::rename(&ws_parent, &renamed) {
|
|
Err(e) if is_rename_denied(&e) => {
|
|
eprintln!("OK: workspace parent rename denied");
|
|
}
|
|
other => {
|
|
let _ = fs::rename(&renamed, &ws_parent);
|
|
eprintln!("FAIL: workspace parent rename expected denial, got {other:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
assert_write_ok(
|
|
"workspace sibling under parent",
|
|
&ws_parent.join(format!("sib-{}.lock", std::process::id())),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Nested userns: exploit must run *inside* unshare; seccomp must make
|
|
// unshare fail (non-success). Host hooks must stay unchanged.
|
|
#[cfg(target_os = "linux")]
|
|
if !first_run {
|
|
let planted = hooks_dir.join("userns-plant.json");
|
|
let alias = home.join("userns-alias");
|
|
let inner = format!(
|
|
"mkdir -p '{alias}' && mount --bind '{home}' '{alias}' && \
|
|
echo nested > '{alias}/hooks/userns-plant.json'",
|
|
alias = alias.display(),
|
|
home = home.display(),
|
|
);
|
|
let sh = format!("unshare -Ur -m sh -c {inner:?}");
|
|
// Confirm `unshare` exists so failure is not a missing binary.
|
|
let which = Command::new("sh")
|
|
.args(["-c", "command -v unshare"])
|
|
.output()
|
|
.expect("command -v unshare");
|
|
if !which.status.success() {
|
|
eprintln!("FAIL: unshare binary missing; cannot assert seccomp denial");
|
|
std::process::exit(1);
|
|
}
|
|
let out = Command::new("sh")
|
|
.args(["-c", &sh])
|
|
.output()
|
|
.expect("spawn unshare probe");
|
|
if out.status.success() {
|
|
eprintln!(
|
|
"FAIL: unshare exploit succeeded (seccomp should EPERM); stderr={}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
|
// Kernel/seccomp typically surfaces EPERM; also accept "not permitted".
|
|
if !(err.contains("not permitted")
|
|
|| err.contains("operation not permitted")
|
|
|| err.contains("eperm")
|
|
|| out.status.code() == Some(1))
|
|
{
|
|
eprintln!(
|
|
"FAIL: expected seccomp EPERM-style denial, got status={:?} stderr={err}",
|
|
out.status
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
if planted.exists()
|
|
&& let Ok(c) = fs::read_to_string(&planted)
|
|
&& c.contains("nested")
|
|
{
|
|
eprintln!("FAIL: nested userns rewrote host hooks");
|
|
std::process::exit(1);
|
|
}
|
|
eprintln!("OK: nested userns did not rewrite hooks");
|
|
}
|
|
|
|
// Root-only: even if CAP_SYS_ADMIN were present, --cap-drop ALL should deny
|
|
// mount; skip when not uid 0.
|
|
#[cfg(target_os = "linux")]
|
|
if !first_run {
|
|
let uid = unsafe { libc::getuid() };
|
|
if uid == 0 {
|
|
let m = Command::new("mount")
|
|
.args([
|
|
"-o",
|
|
"bind",
|
|
"/",
|
|
&home.join("cap-drop-probe").display().to_string(),
|
|
])
|
|
.output();
|
|
if let Ok(o) = m
|
|
&& o.status.success()
|
|
{
|
|
eprintln!("FAIL: mount succeeded despite --cap-drop ALL");
|
|
std::process::exit(1);
|
|
}
|
|
eprintln!("OK: cap-drop mount denied as root");
|
|
} else {
|
|
eprintln!("OK: cap-drop root probe skipped (non-root)");
|
|
}
|
|
}
|
|
|
|
// Parent grants remain creatable: Grok runtime sibling, workspace, temp.
|
|
assert_write_ok(
|
|
"grok runtime sibling",
|
|
&home.join(format!("leader-{}.lock", std::process::id())),
|
|
);
|
|
assert_write_ok("workspace sibling", &workspace.join("fresh.rs"));
|
|
let tmp = std::env::temp_dir().join(format!("hook-wd-tmp-{}", std::process::id()));
|
|
assert_write_ok("temp sibling", &tmp);
|
|
let _ = fs::remove_file(&tmp);
|
|
|
|
eprintln!("OK: hook write-deny e2e passed");
|
|
std::process::exit(0);
|
|
}
|
|
|
|
// ── Parent test cases ───────────────────────────────────────────────────
|
|
|
|
/// Create isolated HOME + GROK_HOME fixture dirs for a scenario.
|
|
fn fixture_homes(
|
|
tag: &str,
|
|
) -> (
|
|
PathBuf,
|
|
PathBuf,
|
|
PathBuf,
|
|
TempDirGuard,
|
|
TempDirGuard,
|
|
TempDirGuard,
|
|
) {
|
|
let home = unique_temp_dir(&format!("{tag}-home"));
|
|
let grok = unique_temp_dir(&format!("{tag}-grok"));
|
|
let workspace = unique_temp_dir(&format!("{tag}-ws"));
|
|
// Empty global sandbox config under fixture GROK_HOME so generic tests do
|
|
// not inherit the developer/runner's ~/.grok/sandbox.toml.
|
|
fs::write(grok.join("sandbox.toml"), "").expect("empty global sandbox.toml");
|
|
(
|
|
home.clone(),
|
|
grok.clone(),
|
|
workspace.clone(),
|
|
TempDirGuard(home),
|
|
TempDirGuard(grok),
|
|
TempDirGuard(workspace),
|
|
)
|
|
}
|
|
|
|
/// Drive one deny case end-to-end: define a custom profile whose `deny` list is
|
|
/// `deny_entries` (exact paths and/or globs), create each `target` (with the
|
|
/// MARKER) and each `control` (readable), then assert in an isolated subprocess
|
|
/// that every target is read/write/rename-denied and every control stays
|
|
/// readable. Shared by the exact-path and glob cases.
|
|
fn run_deny_case(
|
|
tag: &str,
|
|
profile: &str,
|
|
deny_entries: &[&str],
|
|
targets: &[&str],
|
|
controls: &[&str],
|
|
postlaunch: &[&str],
|
|
) {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
|
|
let (home, grok, tmp, _ch, _cg, _cw) = fixture_homes(tag);
|
|
|
|
let deny_list = deny_entries
|
|
.iter()
|
|
.map(|p| format!("\"{p}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
fs::create_dir_all(tmp.join(".grok")).expect("mkdir .grok");
|
|
fs::write(
|
|
tmp.join(".grok").join("sandbox.toml"),
|
|
format!("[profiles.{profile}]\nextends = \"workspace\"\ndeny = [{deny_list}]\n"),
|
|
)
|
|
.expect("write sandbox.toml");
|
|
|
|
// Ensure Grok fixed slots exist so workspace-based custom profiles can
|
|
// resolve hook write-deny without depending on the real user tree.
|
|
fs::create_dir_all(grok.join("hooks")).expect("mkdir fixture hooks");
|
|
fs::write(grok.join("hooks-paths"), b"").expect("write fixture hooks-paths");
|
|
|
|
for rel in targets {
|
|
let path = tmp.join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).expect("mkdir denied parent");
|
|
}
|
|
fs::write(&path, format!("SECRET={MARKER}")).expect("write denied file");
|
|
}
|
|
for rel in controls {
|
|
let path = tmp.join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).expect("mkdir control parent");
|
|
}
|
|
fs::write(&path, "hello workspace").expect("write control");
|
|
}
|
|
|
|
let (status, stderr) = run_scenario(&home, &grok, &tmp, profile, targets, controls, postlaunch);
|
|
assert!(
|
|
status.success(),
|
|
"[{tag}] custom-profile deny should block read/write/rename\nstderr: {stderr}"
|
|
);
|
|
for rel in targets {
|
|
assert!(
|
|
stderr.contains(&format!("OK: {rel} read blocked")),
|
|
"[{tag}] expected '{rel}' read block confirmation\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains(&format!("OK: {rel} write denied")),
|
|
"[{tag}] expected '{rel}' write to be denied\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains(&format!("OK: {rel} rename bypass blocked")),
|
|
"[{tag}] expected '{rel}' rename bypass to be blocked\nstderr: {stderr}"
|
|
);
|
|
}
|
|
for rel in controls {
|
|
assert!(
|
|
stderr.contains(&format!("OK: {rel} control readable")),
|
|
"[{tag}] expected non-denied control '{rel}' to stay readable\nstderr: {stderr}"
|
|
);
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
for rel in postlaunch {
|
|
assert!(
|
|
stderr.contains(&format!("OK: {rel} post-launch write denied")),
|
|
"[{tag}] expected post-launch matching '{rel}' to be write-denied\nstderr: {stderr}"
|
|
);
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
if !postlaunch.is_empty() {
|
|
assert!(
|
|
stderr.contains("OK: post-launch control writable"),
|
|
"[{tag}] expected non-matching post-launch path to stay writable\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
// Generic harness must not leave vendor stubs under fixture HOME.
|
|
assert!(
|
|
!home.join(".claude").exists(),
|
|
"generic deny must not create ~/.claude under fixture HOME"
|
|
);
|
|
assert!(
|
|
!home.join(".cursor").exists(),
|
|
"generic deny must not create ~/.cursor under fixture HOME"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deny_exact_paths_block_read_write_rename() {
|
|
run_deny_case(
|
|
"exact",
|
|
"denytest",
|
|
&[".env", "src/server.pem", "secretdir"],
|
|
&[".env", "src/server.pem", "secretdir/inner.pem"],
|
|
&["readable.txt"],
|
|
&[],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deny_globs_block_read_write_rename() {
|
|
run_deny_case(
|
|
"glob",
|
|
"denyglob",
|
|
&["**/*.pem", "**/.env", "secrets/**"],
|
|
&["sub/dir/key.pem", ".env", "sub/.env", "secrets/inner.key"],
|
|
&["readable.txt", "sub/dir/keep.txt"],
|
|
&["late.pem"],
|
|
);
|
|
}
|
|
|
|
/// Hard-linked registry file must refuse sandbox startup (writable alias).
|
|
#[test]
|
|
fn hardlinked_hooks_paths_refuses_startup() {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-hl");
|
|
fs::create_dir_all(grok.join("hooks")).unwrap();
|
|
let reg = grok.join("hooks-paths");
|
|
let alias = grok.join("hooks-paths-alias");
|
|
fs::write(®, b"").unwrap();
|
|
fs::hard_link(®, &alias).unwrap();
|
|
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny");
|
|
assert!(
|
|
!status.success(),
|
|
"hard-linked hooks-paths must refuse startup\nstderr: {stderr}"
|
|
);
|
|
// Plan/materialization path should surface hard-link or identity failure.
|
|
assert!(
|
|
stderr.contains("hard-link")
|
|
|| stderr.contains("HardLink")
|
|
|| stderr.contains("hook write-deny")
|
|
|| stderr.contains("nlink"),
|
|
"expected hard-link refusal signal\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Workspace profile: Grok-owned direct hook sources are write-denied but readable;
|
|
/// create / overwrite / unlink / rename / mkdir fail; absolute hooks-paths
|
|
/// targets are denied; parent rename is blocked; Grok/CWD/temp siblings stay writable.
|
|
#[test]
|
|
fn workspace_protects_direct_hook_sources() {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook");
|
|
|
|
fs::create_dir_all(grok.join("hooks")).expect("mkdir hooks");
|
|
fs::write(grok.join("hooks").join("keep.json"), r#"{"keep-me":true}"#)
|
|
.expect("write keep.json");
|
|
let dynamic = grok.join("sessions").join("extra-hooks");
|
|
fs::create_dir_all(&dynamic).expect("mkdir dynamic hooks target");
|
|
fs::write(dynamic.join("x.json"), r#"{"x":1}"#).expect("write dynamic hook");
|
|
// Configured target under workspace (absolute) for grant-root ancestor pins.
|
|
let ws_hooks = workspace.join("extra-parent").join("vendor-hooks");
|
|
fs::create_dir_all(&ws_hooks).expect("mkdir ws vendor hooks");
|
|
fs::write(ws_hooks.join("x.json"), r#"{"x":1}"#).expect("write ws hook");
|
|
fs::write(
|
|
grok.join("hooks-paths"),
|
|
format!("{}\n{}\n", dynamic.display(), ws_hooks.display()),
|
|
)
|
|
.expect("write hooks-paths");
|
|
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny");
|
|
assert!(
|
|
status.success(),
|
|
"hook write-deny e2e failed: {status}\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("OK: hook write-deny e2e passed"),
|
|
"missing pass marker\nstderr: {stderr}"
|
|
);
|
|
for needle in [
|
|
"OK: hooks readable",
|
|
"OK: hooks file write denied",
|
|
"OK: hooks-paths write denied",
|
|
"OK: dynamic target write denied",
|
|
"OK: hooks-paths unlink denied",
|
|
"OK: hooks rename denied",
|
|
"OK: hooks nested dir mkdir denied",
|
|
"OK: parent rename denied",
|
|
"OK: sessions sibling writable",
|
|
"OK: workspace parent rename denied",
|
|
"OK: workspace sibling under parent writable",
|
|
"OK: grok runtime sibling writable",
|
|
"OK: workspace sibling writable",
|
|
"OK: temp sibling writable",
|
|
] {
|
|
assert!(
|
|
stderr.contains(needle),
|
|
"expected '{needle}'\nstderr: {stderr}"
|
|
);
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
assert!(
|
|
stderr.contains("OK: nested userns did not rewrite hooks"),
|
|
"expected nested userns check\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Hard-linked or symlinked discovery JSON under hooks/ must refuse startup.
|
|
#[test]
|
|
fn hardlinked_hooks_json_refuses_startup() {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-json-hl");
|
|
fs::create_dir_all(grok.join("hooks")).unwrap();
|
|
fs::write(grok.join("hooks-paths"), b"").unwrap();
|
|
let active = grok.join("hooks").join("active.json");
|
|
let alias = grok.join("hooks").join("active-alias.json");
|
|
fs::write(&active, r#"{"hooks":{}}"#).unwrap();
|
|
fs::hard_link(&active, &alias).unwrap();
|
|
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny");
|
|
assert!(
|
|
!status.success(),
|
|
"hard-linked hooks JSON must refuse startup\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn symlinked_hooks_json_refuses_startup() {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-json-sym");
|
|
fs::create_dir_all(grok.join("hooks")).unwrap();
|
|
fs::write(grok.join("hooks-paths"), b"").unwrap();
|
|
let real = grok.join("real-active.json");
|
|
let active = grok.join("hooks").join("active.json");
|
|
fs::write(&real, r#"{"hooks":{}}"#).unwrap();
|
|
std::os::unix::fs::symlink(&real, &active).unwrap();
|
|
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny");
|
|
assert!(
|
|
!status.success(),
|
|
"symlinked hooks JSON must refuse startup\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// First-run: missing fixed slots are created as real Grok state before apply,
|
|
/// then write-denied. Parent asserts post-exit host tree is valid (no vendor stubs).
|
|
#[test]
|
|
fn workspace_protects_direct_hook_sources_first_run() {
|
|
if skip_if_enforcement_unavailable() {
|
|
return;
|
|
}
|
|
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-fr");
|
|
// Intentionally leave hooks/ and hooks-paths absent (first-run ensure path).
|
|
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny_first_run");
|
|
assert!(
|
|
status.success(),
|
|
"hook write-deny first-run e2e failed: {status}\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("OK: hook write-deny e2e passed"),
|
|
"missing pass marker\nstderr: {stderr}"
|
|
);
|
|
for needle in [
|
|
"OK: first-run Grok hook slots denied",
|
|
"OK: hooks-paths (first-run) write denied",
|
|
"OK: hooks nested (first-run) mkdir denied",
|
|
"OK: hooks nested file (first-run) write denied",
|
|
"OK: grok runtime sibling writable",
|
|
"OK: workspace sibling writable",
|
|
"OK: temp sibling writable",
|
|
] {
|
|
assert!(
|
|
stderr.contains(needle),
|
|
"expected '{needle}'\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
// Post-exit host: Grok slots exist and are valid; no vendor artifacts.
|
|
assert!(
|
|
grok.join("hooks").is_dir(),
|
|
"post-exit: hooks dir must exist as a real directory"
|
|
);
|
|
assert!(
|
|
grok.join("hooks-paths").is_file(),
|
|
"post-exit: hooks-paths must exist as a real file"
|
|
);
|
|
assert_eq!(
|
|
fs::read(grok.join("hooks-paths")).expect("read hooks-paths"),
|
|
b"",
|
|
"post-exit: first-run hooks-paths must be empty"
|
|
);
|
|
assert!(
|
|
!home.join(".claude").exists(),
|
|
"post-exit: must not create ~/.claude"
|
|
);
|
|
assert!(
|
|
!home.join(".cursor").exists(),
|
|
"post-exit: must not create ~/.cursor"
|
|
);
|
|
}
|
|
|
|
/// Marker spoof in an isolated subprocess (no env-mutating unit test).
|
|
#[test]
|
|
fn hook_write_deny_refuses_marker_spoof() {
|
|
// Always runnable on Linux unit path via soft-skip only when not requiring
|
|
// kernel enforcement — marker spoof only needs the verify API.
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
return;
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let (home, grok, workspace, _ch, _cg, _cw) = fixture_homes("hook-spoof");
|
|
fs::create_dir_all(grok.join("hooks")).unwrap();
|
|
fs::write(grok.join("hooks").join("x.json"), b"{}").unwrap();
|
|
fs::write(grok.join("hooks-paths"), b"").unwrap();
|
|
let (status, stderr) =
|
|
run_hook_write_deny_scenario(&home, &grok, &workspace, "hook_write_deny_marker_spoof");
|
|
assert!(
|
|
status.success(),
|
|
"marker spoof e2e failed: {status}\nstderr: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("OK: marker spoof refused"),
|
|
"expected spoof refusal\nstderr: {stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
struct TempDirGuard(std::path::PathBuf);
|
|
|
|
impl Drop for TempDirGuard {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|