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

@ -0,0 +1,569 @@
//! Grok-owned direct global hook paths shared by shell discovery and sandbox
//! write-deny: `$GROK_HOME/hooks`, `hooks-paths`, and absolute registry targets.
//! Relative registry lines, project hooks, and vendor compat are out of scope.
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlobalHookSourceKind {
/// `$GROK_HOME/hooks/` (discovered + protected).
HookDirectory,
/// `$GROK_HOME/hooks-paths` (protected; never loaded as hook JSON).
RegistryFile,
/// Absolute registry target (must exist before sandbox apply).
ConfiguredSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobalHookSource {
pub path: PathBuf,
pub kind: GlobalHookSourceKind,
}
impl GlobalHookSource {
pub fn is_dir(&self) -> bool {
match self.kind {
GlobalHookSourceKind::HookDirectory => true,
GlobalHookSourceKind::RegistryFile => false,
GlobalHookSourceKind::ConfiguredSource => {
if self.path.exists() {
self.path.is_dir()
} else {
true
}
}
}
}
/// False for the registry file itself (not hook JSON / not a hook dir).
pub fn is_discovery_source(&self) -> bool {
!matches!(self.kind, GlobalHookSourceKind::RegistryFile)
}
}
#[derive(Debug, thiserror::Error)]
pub enum GlobalHookSourceError {
#[error("cannot read hooks-paths {path}: {source}")]
HooksPathsRead {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("symlinked GROK_HOME is not allowed under sandbox write-deny: {path}")]
SymlinkedGrokHome { path: PathBuf },
#[error("hook source path contains a symlink component (retargetable): {path}")]
SymlinkedSource { path: PathBuf },
#[error("hook JSON file has hard-link aliases (st_nlink={nlink}): {path}")]
HardLinkedHookFile { path: PathBuf, nlink: u64 },
#[error("hook JSON path is not a regular file: {path}")]
InvalidHookJsonFile { path: PathBuf },
#[error("Grok hooks directory has wrong type (expected real directory): {path}")]
InvalidHooksDir { path: PathBuf },
#[error("Grok hooks-paths registry has wrong type (expected real file): {path}")]
InvalidRegistryFile { path: PathBuf },
#[error("cannot create Grok hooks directory {path}: {source}")]
CreateHooksDir {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("cannot create Grok hooks-paths registry {path}: {source}")]
CreateRegistryFile {
path: PathBuf,
#[source]
source: io::Error,
},
}
/// Hard-fail omits all sources. Soft `configured_error` keeps fixed slots and
/// omits configured targets (sandbox must fail closed; discovery may log).
#[derive(Debug)]
pub struct ResolvedGlobalHookSources {
pub sources: Vec<GlobalHookSource>,
pub configured_error: Option<GlobalHookSourceError>,
}
impl ResolvedGlobalHookSources {
pub fn is_incomplete(&self) -> bool {
self.configured_error.is_some()
}
pub fn discovery_sources(&self) -> impl Iterator<Item = &GlobalHookSource> {
self.sources.iter().filter(|s| s.is_discovery_source())
}
}
/// macOS firmlinks are not attacker-retargetable; ignore in symlink scans.
fn is_system_firmlink(path: &Path) -> bool {
matches!(
path.to_str(),
Some("/tmp")
| Some("/var")
| Some("/etc")
| Some("/private/tmp")
| Some("/private/var")
| Some("/private/etc")
)
}
/// True if any existing path component is a retargetable symlink (firmlinks skipped).
pub fn path_has_symlink_component(path: &Path) -> bool {
let mut cur = PathBuf::new();
for c in path.components() {
cur.push(c.as_os_str());
match std::fs::symlink_metadata(&cur) {
Ok(m) if m.file_type().is_symlink() => {
if is_system_firmlink(&cur) {
continue;
}
return true;
}
Ok(_) => {}
Err(_) => break,
}
}
false
}
fn push_unique(out: &mut Vec<GlobalHookSource>, source: GlobalHookSource) {
if !out.iter().any(|s| s.path == source.path) {
out.push(source);
}
}
/// Existing non-symlink directory ancestors (parent-first), excluding `/`.
pub fn existing_ancestor_chain(path: &Path) -> Vec<PathBuf> {
let mut chain = Vec::new();
let mut cur = path.parent().map(Path::to_path_buf);
while let Some(p) = cur {
if p.as_os_str().is_empty() || p == Path::new("/") {
break;
}
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
chain.push(p.clone());
}
Ok(_) => break,
Err(_) => break,
}
cur = p.parent().map(Path::to_path_buf);
}
chain
}
/// Linux: `st_dev` differs from parent, or listed in mountinfo. Else false.
pub(crate) fn is_filesystem_mountpoint(path: &Path) -> bool {
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
if path == Path::new("/") {
return true;
}
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if let Some(parent) = path.parent()
&& let Ok(pm) = std::fs::metadata(parent)
&& meta.dev() != pm.dev()
{
return true;
}
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
return false;
};
let path_s = path.to_string_lossy();
for line in mountinfo.lines() {
let Some((left, _)) = line.split_once(" - ") else {
continue;
};
let fields: Vec<&str> = left.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
if fields[4] == path_s.as_ref() {
return true;
}
}
false
}
#[cfg(not(target_os = "linux"))]
{
let _ = path;
false
}
}
/// Ancestors to RW self-bind so rename is EBUSY: parent→root, skip already-
/// mounted nodes but keep pinning renameable ancestors above them (never `/`).
pub fn ancestors_to_pin_as_mountpoints(path: &Path) -> Vec<PathBuf> {
ancestors_to_pin_as_mountpoints_with(path, is_filesystem_mountpoint)
}
pub(crate) fn ancestors_to_pin_as_mountpoints_with(
path: &Path,
is_mountpoint: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut chain = Vec::new();
let mut cur = path.parent().map(Path::to_path_buf);
while let Some(p) = cur {
if p.as_os_str().is_empty() || p == Path::new("/") {
break;
}
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
if is_mountpoint(&p) {
cur = p.parent().map(Path::to_path_buf);
continue;
}
chain.push(p.clone());
}
Ok(_) => break,
Err(_) => break,
}
cur = p.parent().map(Path::to_path_buf);
}
chain
}
/// Unique ancestors, rootward-first (shallowest first).
pub fn unique_ancestors_rootward(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut all = Vec::new();
for s in sources {
for anc in ancestors_to_pin_as_mountpoints(&s.path) {
if seen.insert(anc.clone()) {
all.push(anc);
}
}
}
all.sort_by_key(|p| p.components().count());
all
}
fn require_real_dir(path: &Path) -> Result<(), GlobalHookSourceError> {
let meta = std::fs::symlink_metadata(path).map_err(|source| {
GlobalHookSourceError::CreateHooksDir {
path: path.to_path_buf(),
source,
}
})?;
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
return Err(GlobalHookSourceError::InvalidHooksDir {
path: path.to_path_buf(),
});
}
Ok(())
}
fn require_real_file(path: &Path) -> Result<(), GlobalHookSourceError> {
let meta = std::fs::symlink_metadata(path).map_err(|source| {
GlobalHookSourceError::CreateRegistryFile {
path: path.to_path_buf(),
source,
}
})?;
if meta.file_type().is_symlink() || !meta.file_type().is_file() {
return Err(GlobalHookSourceError::InvalidRegistryFile {
path: path.to_path_buf(),
});
}
Ok(())
}
/// Ensure real `$GROK_HOME/hooks` dir + `hooks-paths` file (create if missing).
/// Race-resistant create (`create_dir` / `create_new`+`O_NOFOLLOW`); never
/// truncates an existing registry; rejects symlinks/wrong types.
pub fn ensure_grok_hook_slots(grok_home: &Path) -> Result<(), GlobalHookSourceError> {
if path_has_symlink_component(grok_home) {
return Err(GlobalHookSourceError::SymlinkedGrokHome {
path: grok_home.to_path_buf(),
});
}
match std::fs::create_dir(grok_home) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
std::fs::create_dir_all(grok_home).map_err(|source| {
GlobalHookSourceError::CreateHooksDir {
path: grok_home.to_path_buf(),
source,
}
})?;
}
Err(source) => {
return Err(GlobalHookSourceError::CreateHooksDir {
path: grok_home.to_path_buf(),
source,
});
}
}
if path_has_symlink_component(grok_home) {
return Err(GlobalHookSourceError::SymlinkedGrokHome {
path: grok_home.to_path_buf(),
});
}
let grok_meta = std::fs::symlink_metadata(grok_home).map_err(|source| {
GlobalHookSourceError::CreateHooksDir {
path: grok_home.to_path_buf(),
source,
}
})?;
if grok_meta.file_type().is_symlink() || !grok_meta.file_type().is_dir() {
return Err(GlobalHookSourceError::SymlinkedGrokHome {
path: grok_home.to_path_buf(),
});
}
let hooks = grok_home.join("hooks");
match std::fs::create_dir(&hooks) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
require_real_dir(&hooks)?;
}
Err(source) => {
return Err(GlobalHookSourceError::CreateHooksDir {
path: hooks,
source,
});
}
}
require_real_dir(&hooks)?;
if path_has_symlink_component(&hooks) {
return Err(GlobalHookSourceError::SymlinkedSource { path: hooks });
}
let registry = grok_home.join("hooks-paths");
match open_registry_create_new(&registry) {
Ok(f) => drop(f),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
require_real_file(&registry)?;
}
Err(source) => {
return Err(GlobalHookSourceError::CreateRegistryFile {
path: registry,
source,
});
}
}
require_real_file(&registry)?;
if path_has_symlink_component(&registry) {
return Err(GlobalHookSourceError::SymlinkedSource { path: registry });
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
const O_NOFOLLOW: i32 = 0x20000;
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
))]
const O_NOFOLLOW: i32 = 0x0100;
fn open_registry_create_new(path: &Path) -> io::Result<std::fs::File> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
}
/// Resolve Grok-owned direct global hook sources (`reject_symlinks` for sandbox).
pub fn resolve_global_hook_sources(
grok_home: Option<&Path>,
reject_symlinks: bool,
) -> Result<ResolvedGlobalHookSources, GlobalHookSourceError> {
let mut out = Vec::new();
let mut configured_error = None;
if let Some(grok) = grok_home {
if reject_symlinks && path_has_symlink_component(grok) {
return Err(GlobalHookSourceError::SymlinkedGrokHome {
path: grok.to_path_buf(),
});
}
let hooks = grok.join("hooks");
let hooks_paths = grok.join("hooks-paths");
if reject_symlinks {
for p in [&hooks, &hooks_paths] {
if path_has_symlink_component(p) {
return Err(GlobalHookSourceError::SymlinkedSource { path: p.clone() });
}
}
}
push_unique(
&mut out,
GlobalHookSource {
path: hooks,
kind: GlobalHookSourceKind::HookDirectory,
},
);
push_unique(
&mut out,
GlobalHookSource {
path: hooks_paths.clone(),
kind: GlobalHookSourceKind::RegistryFile,
},
);
match std::fs::read_to_string(&hooks_paths) {
Ok(content) => {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let path = PathBuf::from(trimmed);
if !path.is_absolute() {
continue;
}
if reject_symlinks && path_has_symlink_component(&path) {
return Err(GlobalHookSourceError::SymlinkedSource { path });
}
push_unique(
&mut out,
GlobalHookSource {
path,
kind: GlobalHookSourceKind::ConfiguredSource,
},
);
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => {
configured_error = Some(GlobalHookSourceError::HooksPathsRead {
path: hooks_paths,
source: e,
});
}
}
}
Ok(ResolvedGlobalHookSources {
sources: out,
configured_error,
})
}
pub fn missing_configured_sources(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
sources
.iter()
.filter(|s| s.kind == GlobalHookSourceKind::ConfiguredSource && !s.path.exists())
.map(|s| s.path.clone())
.collect()
}
/// Discovery filename filter: `*.json`, not hidden, not editor temps.
pub fn is_direct_hook_json_name(name: &str) -> bool {
if !name.ends_with(".json") || name.len() <= 5 {
return false;
}
if name.starts_with('.') {
return false;
}
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") {
return false;
}
true
}
/// Immediate discovery JSON files under `dir` (sorted, non-recursive).
pub fn list_direct_hook_json_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_direct_hook_json_name(name) {
continue;
}
out.push(path);
}
out.sort();
Ok(out)
}
/// Regular non-symlink file with `st_nlink == 1`.
#[cfg(unix)]
pub fn validate_direct_hook_json_file(path: &Path) -> Result<(), GlobalHookSourceError> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::symlink_metadata(path).map_err(|source| {
GlobalHookSourceError::HooksPathsRead {
path: path.to_path_buf(),
source,
}
})?;
if meta.file_type().is_symlink() {
return Err(GlobalHookSourceError::SymlinkedSource {
path: path.to_path_buf(),
});
}
if !meta.file_type().is_file() {
return Err(GlobalHookSourceError::InvalidHookJsonFile {
path: path.to_path_buf(),
});
}
if meta.nlink() != 1 {
return Err(GlobalHookSourceError::HardLinkedHookFile {
path: path.to_path_buf(),
nlink: meta.nlink(),
});
}
Ok(())
}
#[cfg(unix)]
pub fn validated_hook_json_files_for_sources(
sources: &[GlobalHookSource],
) -> Result<Vec<PathBuf>, GlobalHookSourceError> {
let mut files = Vec::new();
for s in sources {
if !s.is_dir() || !s.path.is_dir() {
continue;
}
let listed = list_direct_hook_json_files(&s.path).map_err(|source| {
GlobalHookSourceError::HooksPathsRead {
path: s.path.clone(),
source,
}
})?;
for f in listed {
validate_direct_hook_json_file(&f)?;
files.push(f);
}
}
files.sort();
files.dedup();
Ok(files)
}
#[cfg(test)]
#[path = "global_hook_sources_tests.rs"]
mod tests;

View file

@ -0,0 +1,282 @@
use super::*;
use tempfile::TempDir;
#[test]
fn absolute_hooks_paths_only_and_fixed_slots() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let nested = dir.join("extra");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
dir.join("hooks-paths"),
format!("{}\nrelative/x\n", nested.display()),
)
.unwrap();
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
assert!(resolved.configured_error.is_none());
let sources = &resolved.sources;
assert!(
sources.iter().any(|s| {
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
})
);
assert!(sources.iter().any(|s| {
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
}));
assert!(
sources
.iter()
.any(|s| { s.path == nested && s.kind == GlobalHookSourceKind::ConfiguredSource })
);
assert!(!sources.iter().any(|s| s.path.ends_with("relative/x")));
assert!(missing_configured_sources(sources).is_empty());
// Discovery must never treat the registry file as a hook source.
let discovery: Vec<_> = resolved
.discovery_sources()
.map(|s| s.path.clone())
.collect();
assert!(!discovery.iter().any(|p| p == &dir.join("hooks-paths")));
assert!(discovery.iter().any(|p| p == &dir.join("hooks")));
assert!(discovery.iter().any(|p| p == &nested));
}
#[test]
fn missing_configured_is_reported() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let missing = dir.join("nope").join("hooks");
std::fs::write(dir.join("hooks-paths"), format!("{}\n", missing.display())).unwrap();
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
assert!(resolved.configured_error.is_none());
let miss = missing_configured_sources(&resolved.sources);
assert!(miss.iter().any(|p| p == &missing));
assert!(!miss.iter().any(|p| p == &dir.join("hooks")));
}
#[test]
fn hooks_paths_read_error_keeps_fixed_slots() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
// Directory named hooks-paths → read_to_string fails with IsADirectory.
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
assert!(resolved.is_incomplete());
assert!(matches!(
resolved.configured_error,
Some(GlobalHookSourceError::HooksPathsRead { .. })
));
assert!(
resolved.sources.iter().any(|s| {
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
})
);
assert!(resolved.sources.iter().any(|s| {
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
}));
assert!(
!resolved
.sources
.iter()
.any(|s| s.kind == GlobalHookSourceKind::ConfiguredSource)
);
}
#[test]
#[cfg(unix)]
fn reject_symlinked_configured_source() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let real = tmp.path().join("real-hooks");
std::fs::create_dir_all(&real).unwrap();
let link = dir.join("link-hooks");
std::os::unix::fs::symlink(&real, &link).unwrap();
std::fs::write(dir.join("hooks-paths"), format!("{}\n", link.display())).unwrap();
let err = resolve_global_hook_sources(Some(dir), true).unwrap_err();
assert!(matches!(err, GlobalHookSourceError::SymlinkedSource { .. }));
}
#[test]
fn not_found_hooks_paths_is_ok_empty_configured() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
assert!(resolved.configured_error.is_none());
assert!(missing_configured_sources(&resolved.sources).is_empty());
assert!(
resolved
.sources
.iter()
.any(|s| s.kind == GlobalHookSourceKind::HookDirectory)
);
assert!(
resolved
.sources
.iter()
.any(|s| s.kind == GlobalHookSourceKind::RegistryFile)
);
}
#[test]
fn ensure_creates_hooks_dir_and_empty_registry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("grok");
std::fs::create_dir_all(&dir).unwrap();
ensure_grok_hook_slots(&dir).unwrap();
let hooks = dir.join("hooks");
let reg = dir.join("hooks-paths");
assert!(hooks.is_dir());
assert!(reg.is_file());
assert_eq!(std::fs::read(&reg).unwrap(), b"");
// Idempotent — does not truncate existing registry content.
std::fs::write(&reg, b"/abs/extra\n").unwrap();
ensure_grok_hook_slots(&dir).unwrap();
assert_eq!(std::fs::read(&reg).unwrap(), b"/abs/extra\n");
}
#[test]
#[cfg(unix)]
fn ensure_rejects_preexisting_symlink_hooks_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("grok");
std::fs::create_dir_all(&dir).unwrap();
let real = tmp.path().join("real-hooks");
std::fs::create_dir_all(&real).unwrap();
std::os::unix::fs::symlink(&real, dir.join("hooks")).unwrap();
let err = ensure_grok_hook_slots(&dir).unwrap_err();
assert!(matches!(
err,
GlobalHookSourceError::InvalidHooksDir { .. }
| GlobalHookSourceError::SymlinkedSource { .. }
));
}
#[test]
#[cfg(unix)]
fn ensure_rejects_preexisting_symlink_registry() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("grok");
std::fs::create_dir_all(&dir).unwrap();
let target = tmp.path().join("evil-registry");
std::fs::write(&target, b"attacker\n").unwrap();
std::os::unix::fs::symlink(&target, dir.join("hooks-paths")).unwrap();
let err = ensure_grok_hook_slots(&dir).unwrap_err();
// create_new hits EEXIST on the symlink → require_real_file rejects it;
// or O_NOFOLLOW path — never write through the symlink.
assert!(matches!(
err,
GlobalHookSourceError::InvalidRegistryFile { .. }
| GlobalHookSourceError::SymlinkedSource { .. }
| GlobalHookSourceError::CreateRegistryFile { .. }
));
// Attacker target must remain unchanged (no write-through).
assert_eq!(std::fs::read(&target).unwrap(), b"attacker\n");
}
#[test]
#[cfg(unix)]
fn ensure_rejects_directory_named_hooks_paths() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("grok");
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
let err = ensure_grok_hook_slots(&dir).unwrap_err();
assert!(matches!(
err,
GlobalHookSourceError::InvalidRegistryFile { .. }
));
}
#[test]
#[cfg(unix)]
fn ensure_rejects_file_named_hooks_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("grok");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("hooks"), b"not-a-dir").unwrap();
let err = ensure_grok_hook_slots(&dir).unwrap_err();
assert!(matches!(err, GlobalHookSourceError::InvalidHooksDir { .. }));
}
#[test]
fn existing_ancestor_chain_lists_parents() {
let tmp = TempDir::new().unwrap();
let leaf = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&leaf).unwrap();
let chain = existing_ancestor_chain(&leaf);
assert_eq!(chain[0], tmp.path().join("a").join("b"));
assert!(chain.iter().any(|p| p == &tmp.path().join("a")));
}
#[test]
fn list_direct_hook_json_files_matches_discovery_filter() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("active.json"), b"{}").unwrap();
std::fs::write(dir.join(".hidden.json"), b"{}").unwrap();
std::fs::write(dir.join("backup.json~"), b"{}").unwrap();
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
let files = list_direct_hook_json_files(dir).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("active.json"));
}
#[test]
#[cfg(unix)]
fn validate_direct_hook_json_rejects_hardlink_and_symlink() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("a.json");
let hl = tmp.path().join("b.json");
std::fs::write(&f, b"{}").unwrap();
std::fs::hard_link(&f, &hl).unwrap();
assert!(matches!(
validate_direct_hook_json_file(&f),
Err(GlobalHookSourceError::HardLinkedHookFile { .. })
));
let real = tmp.path().join("real.json");
let link = tmp.path().join("link.json");
std::fs::write(&real, b"{}").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(matches!(
validate_direct_hook_json_file(&link),
Err(GlobalHookSourceError::SymlinkedSource { .. })
));
}
#[test]
fn ancestors_to_pin_skips_mountpoints_but_continues_above() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
let mid = outer.join("preexisting-bind");
let leaf = mid.join("hooks");
std::fs::create_dir_all(&leaf).unwrap();
// Synthetic: treat `preexisting-bind` as already a mountpoint.
let pin = ancestors_to_pin_as_mountpoints_with(&leaf, |p| p == mid);
assert!(
pin.iter().any(|p| p == &outer),
"must pin renameable ancestor ABOVE an intermediate mountpoint: {pin:?}"
);
assert!(
!pin.iter().any(|p| p == &mid),
"must NOT re-bind an already-mounted ancestor: {pin:?}"
);
assert!(
!pin.iter().any(|p| p == Path::new("/")),
"must never pin /: {pin:?}"
);
// Immediate parent of leaf is mid (mountpoint) — skipped; outer still present.
let sources = [GlobalHookSource {
path: leaf,
kind: GlobalHookSourceKind::ConfiguredSource,
}];
// With real mountpoint detector, under temp dirs nothing is a mount → full chain.
let rootward = unique_ancestors_rootward(&sources);
for w in rootward.windows(2) {
assert!(
w[0].components().count() <= w[1].components().count(),
"rootward order broken: {rootward:?}"
);
}
}

View file

@ -16,6 +16,7 @@
pub mod campaigns;
pub mod config_override;
pub mod fs_atomic;
pub mod global_hook_sources;
mod loader;
mod macos_managed;
mod managed_cache;
@ -31,6 +32,17 @@ pub mod version_overrides;
pub use campaigns::{
CampaignEntry, CampaignOverrides, filter_active_campaigns, ids_touching_paths,
};
pub use global_hook_sources::{
GlobalHookSource, GlobalHookSourceError, GlobalHookSourceKind, ResolvedGlobalHookSources,
ensure_grok_hook_slots, existing_ancestor_chain, is_direct_hook_json_name,
list_direct_hook_json_files, missing_configured_sources, path_has_symlink_component,
resolve_global_hook_sources, unique_ancestors_rootward,
};
#[cfg(unix)]
pub use global_hook_sources::{
validate_direct_hook_json_file, validated_hook_json_files_for_sources,
};
pub use loader::{
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
@ -43,7 +55,7 @@ pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
pub use managed_cache::{
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor,
bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at,
is_managed_config_hard_stale_for, is_managed_config_stale_for,
fail_closed_policy_armed_at, is_managed_config_hard_stale_for, is_managed_config_stale_for,
managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
};

View file

@ -175,6 +175,30 @@ fn write_marker_atomically(home: &Path, json: &str) {
}
}
/// Whether fail-closed managed policy is armed on disk for `home`.
///
/// True when the sync marker records `fail_closed`, on-disk `requirements.toml`
/// parses as fail_closed, or `requirements.toml` exists but is unreadable
/// (cannot confirm it is disarmed — must not let `clear_orphan` wipe).
/// False only when neither the marker nor the file indicates fail_closed
/// (including when the file is absent / `NotFound`).
/// Companion to the signed session gate in [`managed_policy_compromised_for`].
pub fn fail_closed_policy_armed_at(home: &Path) -> bool {
if read_managed_config_cache(home).is_some_and(|c| c.fail_closed) {
return true;
}
// Defense in depth: files remain after a stripped/corrupt marker.
match std::fs::read_to_string(home.join(crate::loader::REQUIREMENTS_FILENAME)) {
Ok(s) => prod_mc_cli_chat_proxy_types::fail_closed_flag_status(&s).is_enabled(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
// File present but unreadable: do not allow clear_orphan to wipe.
tracing::warn!("requirements.toml unreadable; treating as fail_closed armed: {e}");
true
}
}
}
/// The sync marker, or `None` if absent / unreadable / corrupt. Allow-on-unreadable:
/// a read blip or torn write mustn't lock out a managed user. Unreadable/corrupt are
/// logged (a corruption-to-disarm isn't silent) and self-heal on the next sync.

View file

@ -1316,6 +1316,50 @@ fn managed_config_stale_for_far_future_sync() {
);
}
/// Unreadable requirements (PermissionDenied) with no fail_closed marker must
/// still arm the gate so clear_orphan cannot wipe policy that may still be
/// fail_closed on disk.
#[test]
#[cfg(unix)]
fn unreadable_requirements_treats_fail_closed_as_armed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let req = home.join(crate::loader::REQUIREMENTS_FILENAME);
std::fs::write(&req, "fail_closed = true\n").unwrap();
assert!(
fail_closed_policy_armed_at(home),
"readable fail_closed requirements must arm the gate"
);
// Drop read perms so read_to_string fails with PermissionDenied (not NotFound).
std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap();
// Restore on drop so tempfile cleanup can remove the file.
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600));
}
}
let _restore = RestorePerms(&req);
assert!(
fail_closed_policy_armed_at(home),
"unreadable requirements must treat fail_closed as armed (no wipe)"
);
}
/// Absent requirements + no fail_closed marker → not armed (safe to clear).
#[test]
fn missing_requirements_and_marker_not_armed() {
let dir = tempfile::tempdir().unwrap();
assert!(
!fail_closed_policy_armed_at(dir.path()),
"NotFound requirements with no marker must not arm fail_closed"
);
}
// The is-managed claim gate tests live in a sibling child module (this file is
// past the 1k-line mark); same private access via the #[path] include below.
#[path = "claim_tests.rs"]

View file

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem};
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem, ManagedItemState};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommentSyntax {
@ -94,6 +94,27 @@ pub(super) fn outer_block(
.map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned()))
}
pub(super) fn item_state(
original: &str,
namespace: &str,
owned_item_prefix: &str,
item: &ManagedItem,
comments: &CommentSyntax,
path: &Path,
) -> Result<ManagedItemState, ManagedConfigError> {
let parsed = parse_block(original, namespace, owned_item_prefix, comments, path)?;
let Some(range) = parsed.items.get(&item.name) else {
return Ok(ManagedItemState::Absent);
};
let expected = item_section(item, comments, parsed.newline);
let actual = original[range.start..range.end].trim_end_matches(['\r', '\n']);
Ok(if actual == expected {
ManagedItemState::Exact
} else {
ManagedItemState::NeedsUpdate
})
}
pub(super) fn render_update(
original: &str,
namespace: &str,

View file

@ -45,6 +45,14 @@ pub struct ManagedConfigRequest {
pub struct ManagedTextInspection {
original_text: Option<String>,
unmanaged_text: String,
requested_items: Vec<ManagedItemState>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ManagedItemState {
Absent,
Exact,
NeedsUpdate,
}
impl ManagedTextInspection {
@ -56,6 +64,10 @@ impl ManagedTextInspection {
pub fn unmanaged_text(&self) -> &str {
&self.unmanaged_text
}
pub fn requested_item_state(&self, index: usize) -> Option<ManagedItemState> {
self.requested_items.get(index).copied()
}
}
/// Immutable source and output state presented before application.
@ -206,6 +218,20 @@ impl ManagedConfig {
let parent_plan = ParentPlan::capture(parent)?;
let original = source::read_source(&target_path)?;
let text = original.text(&target_path)?;
let requested_items = request
.items
.iter()
.map(|item| {
format::item_state(
text,
&request.namespace,
&request.owned_item_prefix,
item,
&request.comments,
&target_path,
)
})
.collect::<Result<Vec<_>, _>>()?;
let rendered = format::render_update(
text,
&request.namespace,
@ -217,6 +243,7 @@ impl ManagedConfig {
let inspection = ManagedTextInspection {
original_text: original.bytes.as_ref().map(|_| text.to_owned()),
unmanaged_text: rendered.unmanaged_text,
requested_items,
};
let updated = rendered.updated.into_bytes();
let changes =
@ -244,6 +271,13 @@ impl ManagedConfig {
transaction::apply(plan, &transaction::NoopObserver)
}
/// Verify that the exact source path, parent identities, symlink target,
/// bytes, mode, and file identity captured by `plan` are unchanged without
/// publishing its proposed update.
pub fn verify_unchanged(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
source::revalidate(plan)
}
#[cfg(test)]
fn apply_with_observer(
plan: ManagedConfigPlan,