Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,38 @@
[package]
license = "Apache-2.0"
name = "xai-grok-config"
version = "0.1.0"
edition.workspace = true
description = "Shared config loading for Grok — grok_home, effective config (requirements > user > managed), TOML merge"
[dependencies]
base64 = { workspace = true }
blake3 = { workspace = true }
dunce = { workspace = true }
# Shared signed deployment-config envelope contract with the cli-chat-proxy signer.
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
ring = { workspace = true }
semver = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shellexpand = "3.1"
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
urlencoding = { workspace = true }
which = { workspace = true }
xai-tty-utils = { workspace = true }
xai-grok-version = { workspace = true }
# core-foundation: read macOS MDM managed preferences (`ai.x.grok`) for the
# admin-deployed requirements layer.
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[features]
[lints]
workspace = true

View file

@ -0,0 +1,294 @@
//! `[[campaigns]]` overlays. Priority (first id wins): requirements > remote >
//! user > managed > system_managed. Applied after layer merge.
use serde::{Deserialize, Serialize};
use crate::config_override::{
ConfigOverrideEntry, PATCH_STRIP_KEYS, PatchPath, apply_patches, patch_touches_any,
take_patch_array,
};
pub const CAMPAIGNS_KEY: &str = "campaigns";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CampaignMeta {
#[serde(default, alias = "campaign_id")]
pub id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CampaignEntry {
pub id: String,
pub patch: toml::Table,
}
/// Disk campaigns grouped by source layer. Merged with the remote layer (by
/// priority, first id wins) in [`crate::loader::ConfigLayers::resolve_campaigns`].
#[derive(Debug, Clone, Default)]
pub struct CampaignOverrides {
pub requirements: Vec<CampaignEntry>,
pub user: Vec<CampaignEntry>,
pub managed: Vec<CampaignEntry>,
pub system_managed: Vec<CampaignEntry>,
}
pub fn take_campaigns(config: &mut toml::Value) -> Vec<ConfigOverrideEntry<CampaignMeta>> {
match take_patch_array::<CampaignMeta>(config, CAMPAIGNS_KEY) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(error = %e, "campaigns: failed to deserialize; ignoring entries");
Vec::new()
}
}
}
pub fn build_campaign_entries(
taken: Vec<ConfigOverrideEntry<CampaignMeta>>,
layer: &'static str,
) -> Vec<CampaignEntry> {
let mut out = Vec::with_capacity(taken.len());
for entry in taken {
let Some(id) = entry
.meta
.id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
else {
tracing::warn!(layer, "campaigns: entry missing id; skipped");
continue;
};
// Skip no-op entries (id only, no fields to overlay).
if entry.patch.is_empty() {
continue;
}
out.push(CampaignEntry {
id,
patch: entry.patch,
});
}
out
}
/// `sources` in priority order; first `id` wins.
pub fn merge_campaign_entries(sources: &[&[CampaignEntry]]) -> Vec<CampaignEntry> {
let mut seen = std::collections::HashSet::<String>::new();
let mut out = Vec::new();
for source in sources {
for entry in *source {
if !seen.insert(entry.id.clone()) {
continue;
}
out.push(entry.clone());
}
}
out
}
/// Drop dismissed ids from a priority-merged list, preserving order.
pub fn filter_active_campaigns(
merged: Vec<CampaignEntry>,
dismissed_ids: &std::collections::HashSet<String>,
) -> Vec<CampaignEntry> {
merged
.into_iter()
.filter(|e| !dismissed_ids.contains(&e.id))
.collect()
}
/// Ids of `active` campaigns whose patch touches any of `paths` — used to dismiss
/// campaigns when the user persists a value at one of those paths.
pub fn ids_touching_paths(active: &[CampaignEntry], paths: &[PatchPath]) -> Vec<String> {
active
.iter()
.filter(|e| patch_touches_any(&e.patch, paths))
.map(|e| e.id.clone())
.collect()
}
/// `active` is highest-priority-first; patches apply lowest-first (`.rev()`) so the
/// highest-priority source wins a leaf conflict.
pub fn apply_active_campaign_patches(effective: &mut toml::Value, active: &[CampaignEntry]) {
apply_patches(
effective,
active.iter().rev().map(|e| e.patch.clone()),
PATCH_STRIP_KEYS,
);
}
pub fn take_campaign_entries(config: &mut toml::Value, layer: &'static str) -> Vec<CampaignEntry> {
build_campaign_entries(take_campaigns(config), layer)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> toml::Value {
toml::from_str(s).unwrap()
}
fn models_default_patch(default: &str) -> toml::Table {
let mut models = toml::map::Map::new();
models.insert("default".into(), toml::Value::String(default.into()));
let mut t = toml::map::Map::new();
t.insert("models".into(), toml::Value::Table(models));
t
}
#[test]
fn campaign_overlays_any_field_over_user_config() {
let mut layer = parse(
r#"
[[campaigns]]
id = "c1"
[campaigns.models]
default = "new-model"
[campaigns.features]
web_fetch = true
"#,
);
let entries = take_campaign_entries(&mut layer, "managed");
assert!(layer.get(CAMPAIGNS_KEY).is_none());
assert_eq!(entries.len(), 1);
let mut effective =
parse("[models]\ndefault = \"old-model\"\n[features]\nweb_fetch = false\n");
apply_active_campaign_patches(&mut effective, &entries);
assert_eq!(effective["models"]["default"].as_str(), Some("new-model"));
assert_eq!(effective["features"]["web_fetch"].as_bool(), Some(true));
}
#[test]
fn merge_first_source_wins_duplicate_id() {
let req = [CampaignEntry {
id: "same".into(),
patch: models_default_patch("from-req"),
}];
let remote = [CampaignEntry {
id: "same".into(),
patch: models_default_patch("from-remote"),
}];
let merged = merge_campaign_entries(&[&req, &remote]);
assert_eq!(merged.len(), 1);
assert_eq!(
merged[0].patch["models"]["default"].as_str(),
Some("from-req")
);
}
#[test]
fn apply_highest_priority_wins_on_leaf_conflict() {
// Two *distinct* ids both set models.default; the higher-priority source
// (earlier in the merged list) must win the leaf.
let req = [CampaignEntry {
id: "req".into(),
patch: models_default_patch("from-req"),
}];
let managed = [CampaignEntry {
id: "managed".into(),
patch: models_default_patch("from-managed"),
}];
let merged = merge_campaign_entries(&[&req, &managed]);
assert_eq!(merged.len(), 2);
let mut effective = parse("[models]\ndefault = \"user-old\"\n");
apply_active_campaign_patches(&mut effective, &merged);
assert_eq!(effective["models"]["default"].as_str(), Some("from-req"));
}
#[test]
fn build_campaign_entries_skips_missing_id() {
// A `None` id and a whitespace-only id are both dropped (with a warn);
// only the entry carrying a real id survives.
let taken = vec![
ConfigOverrideEntry {
meta: CampaignMeta { id: None },
patch: models_default_patch("dropped-none"),
},
ConfigOverrideEntry {
meta: CampaignMeta {
id: Some(" ".into()),
},
patch: models_default_patch("dropped-blank"),
},
ConfigOverrideEntry {
meta: CampaignMeta {
id: Some("valid".into()),
},
patch: models_default_patch("kept"),
},
];
let out = build_campaign_entries(taken, "managed");
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "valid");
assert_eq!(out[0].patch["models"]["default"].as_str(), Some("kept"));
}
#[test]
fn campaign_id_alias_is_accepted_in_toml_and_does_not_leak_into_patch() {
for src in [
"[[campaigns]]\ncampaign_id = \"c1\"\n[campaigns.models]\ndefault = \"m\"\n",
"[[campaigns]]\nid = \"c1\"\n[campaigns.models]\ndefault = \"m\"\n",
] {
let mut layer = parse(src);
let entries = take_campaign_entries(&mut layer, "user");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "c1");
// The id key (either spelling) must be consumed by the meta, never
// land in the patch — a leaked key would deep-merge a junk top-level
// `id` into every effective config.
assert!(
entries[0].patch.get("id").is_none()
&& entries[0].patch.get("campaign_id").is_none(),
"id keys must not leak into the patch: {src}"
);
}
}
#[test]
fn requirements_win_over_campaign() {
use crate::loader::ConfigLayers;
// A campaign (even from a lower layer) can't override a field the admin
// set in requirements: `apply_campaign_overrides` re-merges requirements on top.
let mut layers = ConfigLayers {
user: parse("[models]\ndefault = \"user-old\"\n"),
user_requirements: Some(parse("[models]\ndefault = \"pinned\"\n")),
..Default::default()
};
layers.campaigns.user = vec![CampaignEntry {
id: "c1".into(),
patch: models_default_patch("campaign"),
}];
let effective =
layers.effective_config_with_campaigns(&[], &std::collections::HashSet::new());
assert_eq!(
effective["models"]["default"].as_str(),
Some("pinned"),
"requirements must beat a campaign for the same field"
);
}
#[test]
fn effective_config_honors_dismiss() {
use crate::loader::ConfigLayers;
// A dismissed campaign id stops overriding; the user's stored value returns.
let mut layers = ConfigLayers {
user: parse("[models]\ndefault = \"user-old\"\n"),
..Default::default()
};
layers.campaigns.managed = vec![CampaignEntry {
id: "c1".into(),
patch: models_default_patch("new"),
}];
let none = std::collections::HashSet::new();
let active = layers.effective_config_with_campaigns(&[], &none);
assert_eq!(active["models"]["default"].as_str(), Some("new"));
let dismissed: std::collections::HashSet<_> = ["c1".into()].into_iter().collect();
let off = layers.effective_config_with_campaigns(&[], &dismissed);
assert_eq!(off["models"]["default"].as_str(), Some("user-old"));
}
}

View file

@ -0,0 +1,142 @@
//! Shared take/apply for `[[version_overrides]]` / `[[campaigns]]` arrays.
use serde::de::DeserializeOwned;
use crate::deep_merge_toml;
pub type PatchPath = &'static [&'static str];
#[derive(Debug, Clone)]
pub struct ConfigOverrideEntry<M> {
pub meta: M,
pub patch: toml::Table,
}
/// Strip `key` from the root table; each element is `M` + remaining keys as patch.
pub fn take_patch_array<M>(
config: &mut toml::Value,
key: &str,
) -> Result<Vec<ConfigOverrideEntry<M>>, toml::de::Error>
where
M: DeserializeOwned,
{
let Some(table) = config.as_table_mut() else {
return Ok(Vec::new());
};
let Some(array_value) = table.remove(key) else {
return Ok(Vec::new());
};
#[derive(serde::Deserialize)]
struct FlatEntry<M> {
#[serde(flatten)]
meta: M,
#[serde(flatten)]
patch: toml::Table,
}
let entries: Vec<FlatEntry<M>> = array_value.try_into()?;
Ok(entries
.into_iter()
.map(|e| ConfigOverrideEntry {
meta: e.meta,
patch: e.patch,
})
.collect())
}
/// Whether `patch` affects the value at `path`: it sets a value there (any leaf
/// under it counts), **or** it sets a non-table ancestor — deep-merge replaces
/// the whole subtree in that case, so every leaf beneath is touched (a patch
/// like `models = "oops"` wipes `models.default` and must still be dismissable
/// / flagged as driving it).
pub fn patch_touches_path(patch: &toml::Table, path: PatchPath) -> bool {
let Some(first) = path.first() else {
return false;
};
let Some(mut cur) = patch.get(*first) else {
return false;
};
for seg in path.iter().skip(1) {
match cur.as_table() {
Some(t) => match t.get(*seg) {
Some(v) => cur = v,
None => return false,
},
// Non-table ancestor: the merge replaces this subtree wholesale.
None => return true,
}
}
true
}
/// Whether `patch` touches any of `paths`.
pub fn patch_touches_any(patch: &toml::Table, paths: &[PatchPath]) -> bool {
paths.iter().any(|p| patch_touches_path(patch, p))
}
/// Keys stripped from every applied patch so an override can't re-introduce a
/// nested `version_overrides`/`campaigns` array (recursive re-injection). This
/// const owns the recursive-injection keys for every override kind; [`apply_patches`]
/// takes the strip list as a parameter so the strip step itself stays key-agnostic.
pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns"];
/// Deep-merge each patch in iteration order (later wins on a leaf), stripping
/// `strip_keys` from every patch first.
pub fn apply_patches(
config: &mut toml::Value,
patches: impl IntoIterator<Item = toml::Table>,
strip_keys: &[&str],
) {
for mut patch in patches {
for key in strip_keys {
patch.remove(*key);
}
deep_merge_toml(config, &toml::Value::Table(patch));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
/// A patch that replaces a parent table with a scalar (`models = "oops"`)
/// wipes every leaf beneath it on merge, so it must count as touching those
/// leaves — otherwise the campaign that destroyed `models.default` would be
/// neither dismissable nor flagged as driving the field.
#[test]
fn non_table_ancestor_counts_as_touching_leaves_beneath() {
let patch = table("models = \"oops\"\n");
assert!(patch_touches_path(&patch, &["models", "default"]));
assert!(patch_touches_path(&patch, &["models"]));
// Sibling sections are unaffected.
assert!(!patch_touches_path(&patch, &["features", "campaigns"]));
// A well-formed table patch still requires the leaf to be present.
let tbl = table("[models]\ndefault = \"m\"\n");
assert!(patch_touches_path(&tbl, &["models", "default"]));
assert!(!patch_touches_path(&tbl, &["models", "other"]));
}
#[test]
fn apply_patches_strips_requested_keys() {
let mut cfg = toml::Value::Table(table("[models]\ndefault = \"old\"\n"));
let patch = table("[models]\ndefault = \"new\"\n");
apply_patches(&mut cfg, std::iter::once(patch), PATCH_STRIP_KEYS);
assert_eq!(cfg["models"]["default"].as_str(), Some("new"));
// Top-level strip keys are removed before merge.
let mut cfg2 = toml::Value::Table(toml::Table::new());
let mut p = toml::Table::new();
p.insert("version_overrides".into(), toml::Value::Array(vec![]));
p.insert("campaigns".into(), toml::Value::Array(vec![]));
p.insert("keep".into(), toml::Value::Boolean(true));
apply_patches(&mut cfg2, std::iter::once(p), PATCH_STRIP_KEYS);
assert!(cfg2.get("version_overrides").is_none());
assert!(cfg2.get("campaigns").is_none());
assert_eq!(cfg2["keep"].as_bool(), Some(true));
}
}

View file

@ -0,0 +1,43 @@
//! Atomic file writes, shared by the managed-cache marker and the signature
//! sidecar writers.
use std::path::Path;
/// Atomic temp + rename so a torn write can't leave a half-written file. The temp
/// name is unique per writer (pid + counter) and `create_new`, so concurrent
/// writers don't collide. `mode` (unix only) is applied at temp-file creation, so
/// the final file never exists with looser permissions.
pub(crate) fn write_atomically(
final_path: &Path,
contents: &str,
mode: Option<u32>,
) -> std::io::Result<()> {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static WRITE_NONCE: AtomicU64 = AtomicU64::new(0);
let dir = final_path.parent().unwrap_or_else(|| Path::new("."));
let name = final_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_owned());
let nonce = WRITE_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!("{name}.{}.{nonce}.tmp", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if let Some(mode) = mode {
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
let result = options
.open(&tmp)
.and_then(|mut f| f.write_all(contents.as_bytes()))
.and_then(|()| std::fs::rename(&tmp, final_path));
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}

View file

@ -0,0 +1,67 @@
//! Config file loading for Grok.
//!
//! Merge order (lowest → highest priority):
//! 1. `/etc/grok/managed_config.toml`
//! 2. `$GROK_HOME/managed_config.toml`
//! 3. `$GROK_HOME/config.toml`
//! 4. `$GROK_HOME/requirements.toml` (cloud cache; Ed25519-signed at rest once a
//! key is embedded — see [`signed_policy`] — below the OS-protected layers)
//! 5. `/etc/grok/requirements.toml`
//! 6. macOS MDM managed preferences (`ai.x.grok`, admin-forced) — macOS only
//!
//! Each layer applies its own [`[[version_overrides]]`](version_overrides)
//! before merge. Requirements layers (#4#6) may opt into fail-closed startup;
//! see [`validate_requirements`].
pub mod campaigns;
pub mod config_override;
mod fs_atomic;
mod loader;
mod macos_managed;
mod managed_cache;
mod paths;
pub mod shell;
pub mod signed_policy;
mod validation;
pub mod version_overrides;
// Only the cross-crate campaign surface is re-exported at the root; the rest stays
// reachable via the `pub mod` paths for in-crate use without widening the API.
pub use campaigns::{
CampaignEntry, CampaignOverrides, filter_active_campaigns, ids_touching_paths,
};
pub use loader::{
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
apply_version_overrides_with_registered, campaigns_application_disabled, campaigns_state_path,
deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, load_config_file,
load_dismissed_ids_from_home, load_effective_config_disk_only, load_from_disk,
load_managed_config, load_system_managed_config, load_toml_file, managed_config_layers,
managed_config_layers_at, toml_error_detail,
};
pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
pub use managed_cache::{
ServingIdentity, SyncMarker, is_managed_config_hard_stale_for, is_managed_config_stale_for,
managed_config_identity_changed, managed_deployment_id, managed_policy_compromised_for,
mark_managed_config_synced,
};
pub use paths::{
claude_managed_settings_path, claude_managed_settings_probe_path, decode_cwd_from_dirname,
default_grok_home, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application, grok_home,
sessions_cwd_dir, system_config_dir, user_grok_home,
};
pub use validation::{
RequirementsError, RequirementsLayer, RequirementsSource, fail_closed_flag_from_str,
load_merged_requirements, requirements_layers, validate_requirements,
};
pub use version_overrides::{VersionOverrideError, apply_version_overrides};
/// Parse an env var as a boolean. `None` if unset or unrecognized.
pub fn env_bool(name: &str) -> Option<bool> {
let value = std::env::var(name).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"" => None,
"1" | "true" | "yes" | "on" | "enabled" => Some(true),
"0" | "false" | "no" | "off" | "disabled" => Some(false),
_ => None,
}
}

View file

@ -0,0 +1,706 @@
//! TOML loading, layered merging, and `$VAR` expansion.
//!
//! The merged result is the **default** config; requirements layers
//! sit on top via [`crate::validation`].
use std::path::Path;
use crate::paths::{system_config_dir, user_grok_home};
use crate::validation::{load_requirements, load_system_requirements};
use crate::version_overrides::{self, apply_version_overrides};
/// Load and parse a TOML file, expanding `$VAR` references. Empty table if absent.
pub fn load_toml_file(path: &Path) -> std::io::Result<toml::Value> {
match std::fs::read_to_string(path) {
Ok(s) => match toml::from_str::<toml::Value>(&s) {
Ok(mut v) => {
expand_env_vars_in_toml(&mut v);
Ok(v)
}
Err(e) => {
// Built from the span, never from Display — Display echoes the
// offending source line, which may carry a secret. Safe to log and
// to return to a client.
let detail = toml_error_detail(&s, &e);
tracing::error!(file = %path.display(), "config toml has syntax errors: {detail}");
Err(std::io::Error::other(detail))
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(toml::Value::Table(toml::map::Map::new()))
}
Err(e) => {
tracing::error!(file = %path.display(), "config file unreadable: {e}");
Err(e)
}
}
}
/// A snippet-free description of a TOML parse error: `"TOML parse error at line
/// L, column C: <what>"` (or just the message when there's no span). Never
/// includes the offending source line — `Display` echoes it and it may carry a
/// secret — so this is safe to log or surface to a client. Shared with the trace
/// `config_files` artifact so the redaction rule lives in one place.
pub fn toml_error_detail(src: &str, e: &toml::de::Error) -> String {
match e.span() {
Some(span) => {
let (line, col) = line_col(src, span.start);
format!(
"TOML parse error at line {line}, column {col}: {}",
e.message()
)
}
None => e.message().to_owned(),
}
}
/// 1-based (line, column) of a byte offset within `src`.
fn line_col(src: &str, byte: usize) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (i, ch) in src.char_indices() {
if i >= byte {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
/// [`load_toml_file`] plus that layer's `[[version_overrides]]`. Use for
/// grok config files; use [`load_toml_file`] directly for unrelated TOML.
pub fn load_config_file(path: &Path) -> std::io::Result<toml::Value> {
let mut v = load_toml_file(path)?;
apply_version_overrides_with_registered(&mut v)?;
Ok(v)
}
pub fn load_from_disk() -> std::io::Result<toml::Value> {
load_user_config_layer(user_grok_home().as_deref(), "config.toml")
}
/// Managed config filename, shared by the loaders in this module.
pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml";
pub fn load_managed_config() -> std::io::Result<toml::Value> {
load_user_config_layer(user_grok_home().as_deref(), MANAGED_CONFIG_FILENAME)
}
/// Load a user-tier config layer from `<home>/<filename>`. With no resolvable
/// user home, returns an empty table rather than reading a cwd-relative
/// `.grok/<filename>` (the cwd-fallback would silently promote an untrusted
/// project `.grok` to the user tier).
fn load_user_config_layer(home: Option<&Path>, filename: &str) -> std::io::Result<toml::Value> {
match home {
Some(g) => load_config_file(&g.join(filename)),
None => Ok(toml::Value::Table(toml::map::Map::new())),
}
}
pub fn load_system_managed_config() -> std::io::Result<toml::Value> {
let mut v = match system_config_dir() {
Some(dir) => load_toml_file(&dir.join(MANAGED_CONFIG_FILENAME))?,
None => toml::Value::Table(toml::map::Map::new()),
};
apply_version_overrides_with_registered(&mut v)?;
Ok(v)
}
/// One managed-config layer: the parsed TOML and the file it came from.
#[derive(Debug, Clone)]
pub struct ManagedConfigLayer {
pub value: toml::Value,
pub path: std::path::PathBuf,
/// `true` for the root-owned system layer (`/etc/grok`), derived from the
/// load directory.
pub is_system: bool,
}
/// All `managed_config.toml` layers in apply order (system first, user last).
/// Absent layers are skipped; unparsable layers are skipped with a warning.
/// One bad layer never drops the others.
pub fn managed_config_layers() -> Vec<ManagedConfigLayer> {
managed_config_layers_at(system_config_dir().as_deref(), user_grok_home().as_deref())
}
/// [`managed_config_layers`] with explicit directories.
pub fn managed_config_layers_at(
system_dir: Option<&Path>,
user_home: Option<&Path>,
) -> Vec<ManagedConfigLayer> {
let mut layers = Vec::new();
for (dir, is_system) in [(system_dir, true), (user_home, false)] {
let Some(path) = dir.map(|d| d.join(MANAGED_CONFIG_FILENAME)) else {
continue;
};
if !path.is_file() {
continue;
}
match load_config_file(&path) {
Ok(value) => layers.push(ManagedConfigLayer {
value,
path,
is_system,
}),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping managed_config.toml layer that failed to load or parse")
}
}
}
layers
}
/// Layers lowest→highest priority. `[[campaigns]]` taken off each layer at load.
#[derive(Clone)]
pub struct ConfigLayers {
pub system_managed: toml::Value,
pub managed: toml::Value,
pub user: toml::Value,
pub user_requirements: Option<toml::Value>,
pub system_requirements: Option<toml::Value>,
/// macOS MDM requirements; highest requirements tier when present.
pub mdm_requirements: Option<toml::Value>,
pub campaigns: crate::campaigns::CampaignOverrides,
}
impl Default for ConfigLayers {
fn default() -> Self {
Self {
system_managed: toml::Value::Table(Default::default()),
managed: toml::Value::Table(Default::default()),
user: toml::Value::Table(Default::default()),
user_requirements: None,
system_requirements: None,
mdm_requirements: None,
campaigns: crate::campaigns::CampaignOverrides::default(),
}
}
}
impl ConfigLayers {
pub fn load() -> std::io::Result<Self> {
use crate::campaigns::{CampaignOverrides, take_campaign_entries};
let mut system_managed = load_system_managed_config()?;
let system_managed_campaigns = take_campaign_entries(&mut system_managed, "system_managed");
let mut managed = load_managed_config()?;
let managed_campaigns = take_campaign_entries(&mut managed, "managed");
let mut user = load_from_disk()?;
let user_campaigns = take_campaign_entries(&mut user, "user");
let mut user_requirements = load_requirements();
let mut system_requirements = load_system_requirements();
let mut mdm_requirements = crate::validation::mdm_requirements_value();
// Highest-authority requirements tier first: `merge_campaign_entries` is
// first-id-wins, so a duplicate campaign id must resolve mdm > system >
// user — matching the layer precedence in `effective_config_base` (where
// mdm is merged last/highest).
let mut requirements_campaigns = Vec::new();
if let Some(ref mut req) = mdm_requirements {
requirements_campaigns.extend(take_campaign_entries(req, "requirements"));
}
if let Some(ref mut req) = system_requirements {
requirements_campaigns.extend(take_campaign_entries(req, "requirements"));
}
if let Some(ref mut req) = user_requirements {
requirements_campaigns.extend(take_campaign_entries(req, "requirements"));
}
Ok(Self {
system_managed,
managed,
user,
user_requirements,
system_requirements,
mdm_requirements,
campaigns: CampaignOverrides {
requirements: requirements_campaigns,
user: user_campaigns,
managed: managed_campaigns,
system_managed: system_managed_campaigns,
},
})
}
/// Layer merge only (no campaign overlay).
pub fn effective_config_base(&self) -> toml::Value {
let mut merged = self.system_managed.clone();
deep_merge_toml(&mut merged, &self.managed);
deep_merge_toml(&mut merged, &self.user);
if let Some(req) = &self.user_requirements {
deep_merge_toml(&mut merged, req);
}
if let Some(sys_req) = &self.system_requirements {
deep_merge_toml(&mut merged, sys_req);
}
if let Some(mdm_req) = &self.mdm_requirements {
deep_merge_toml(&mut merged, mdm_req);
}
merged
}
/// Campaign source slices in priority order (first id wins):
/// requirements > remote > user > managed > system_managed. Single source of
/// truth for the precedence; both this crate and the shell resolver consume it.
pub fn campaign_source_slices<'a>(
&'a self,
remote_campaigns: &'a [crate::campaigns::CampaignEntry],
) -> [&'a [crate::campaigns::CampaignEntry]; 5] {
[
&self.campaigns.requirements,
remote_campaigns,
&self.campaigns.user,
&self.campaigns.managed,
&self.campaigns.system_managed,
]
}
/// Active campaigns against `base`: kill switch → priority merge (first-id-wins)
/// → drop dismissed. The single place disk campaign resolution lives; the shell
/// wraps this with the `GROK_CAMPAIGNS_OVERRIDE` env layer.
pub fn resolve_campaigns(
&self,
base: &toml::Value,
remote_campaigns: &[crate::campaigns::CampaignEntry],
dismissed_ids: &std::collections::HashSet<String>,
) -> Vec<crate::campaigns::CampaignEntry> {
if campaigns_application_disabled(base) {
return Vec::new();
}
let merged = crate::campaigns::merge_campaign_entries(
&self.campaign_source_slices(remote_campaigns),
);
crate::campaigns::filter_active_campaigns(merged, dismissed_ids)
}
/// Re-merge the requirements layers so an admin's `requirements.toml` always
/// wins over a campaign overlay, regardless of the campaign's source layer.
/// Campaigns are full-power (any field), so this is the structural guarantee
/// that a lower-trust layer's campaign can't override an admin-set field.
fn reapply_requirements(&self, merged: &mut toml::Value) {
for req in [
&self.user_requirements,
&self.system_requirements,
&self.mdm_requirements,
]
.into_iter()
.flatten()
{
deep_merge_toml(merged, req);
}
}
/// Apply active campaign patches onto `merged`, then restore requirements
/// precedence. The single overlay step shared by this crate and the shell.
pub fn apply_campaign_overrides(
&self,
merged: &mut toml::Value,
active: &[crate::campaigns::CampaignEntry],
) {
crate::campaigns::apply_active_campaign_patches(merged, active);
self.reapply_requirements(merged);
}
/// Layer merge + disk/remote campaign overlay, honoring the kill switch. The
/// shell's `load_effective_config` is the remote/override-aware path; this is
/// used by `effective_config_disk_only` and tests.
pub fn effective_config_with_campaigns(
&self,
remote_campaigns: &[crate::campaigns::CampaignEntry],
dismissed_ids: &std::collections::HashSet<String>,
) -> toml::Value {
let mut merged = self.effective_config_base();
let active = self.resolve_campaigns(&merged, remote_campaigns, dismissed_ids);
self.apply_campaign_overrides(&mut merged, &active);
merged
}
/// Disk campaigns + on-disk dismiss (`campaigns_state.json`); **no remote, no
/// env override**. Named to make the divergence from the shell's remote-aware
/// `load_effective_config` explicit at every call site.
pub fn effective_config_disk_only(&self) -> toml::Value {
self.effective_config_with_campaigns(&[], &load_dismissed_ids_from_home())
}
pub fn has_managed(&self) -> bool {
self.managed.as_table().is_some_and(|t| !t.is_empty())
|| self
.system_managed
.as_table()
.is_some_and(|t| !t.is_empty())
}
pub fn has_system_managed(&self) -> bool {
self.system_managed
.as_table()
.is_some_and(|t| !t.is_empty())
}
}
/// `GROK_CAMPAIGNS=0` or `[features] campaigns = false` on pre-campaign base.
pub fn campaigns_application_disabled(base_effective: &toml::Value) -> bool {
if crate::env_bool("GROK_CAMPAIGNS") == Some(false) {
return true;
}
base_effective
.get("features")
.and_then(|f| f.get("campaigns"))
.and_then(|c| c.as_bool())
== Some(false)
}
/// Disk layers only (no remote, no env override). Prefer the shell loader
/// (`xai_grok_shell::util::config::load_effective_config`) when remote campaigns
/// or `GROK_CAMPAIGNS_OVERRIDE` must be honored. The name mirrors the
/// [`ConfigLayers::effective_config_disk_only`] method so the divergence from the
/// remote-aware loader is un-ignorable at every call site.
pub fn load_effective_config_disk_only() -> std::io::Result<toml::Value> {
Ok(ConfigLayers::load()?.effective_config_disk_only())
}
/// On-disk campaign dismiss state. Single source of truth for the file's name,
/// location, and JSON shape — the shell's writer reuses these so the read and
/// write sides can't drift.
pub const CAMPAIGNS_STATE_FILE: &str = "campaigns_state.json";
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct CampaignsState {
#[serde(default)]
pub dismissed_ids: Vec<String>,
}
/// Path to `$GROK_HOME/campaigns_state.json` under `home`.
pub fn campaigns_state_path(home: &std::path::Path) -> std::path::PathBuf {
home.join(CAMPAIGNS_STATE_FILE)
}
/// Fail-open dismissed ids from `$GROK_HOME/campaigns_state.json`.
pub fn load_dismissed_ids_from_home() -> std::collections::HashSet<String> {
let Some(home) = crate::user_grok_home() else {
return std::collections::HashSet::new();
};
let Ok(contents) = std::fs::read_to_string(campaigns_state_path(&home)) else {
return std::collections::HashSet::new();
};
serde_json::from_str::<CampaignsState>(&contents)
.map(|s| s.dismissed_ids.into_iter().collect())
.unwrap_or_default()
}
/// Applies matching `[[version_overrides]]` patches against the running
/// CLI version; strips the section either way. If the installed version
/// can't be parsed (broken `GROK_TEST_VERSION` in dev), silently strips
/// without applying — keeps the CLI usable on a bad dev override.
pub fn apply_version_overrides_with_registered(value: &mut toml::Value) -> std::io::Result<()> {
match xai_grok_version::installed_semver() {
Ok(version) => apply_version_overrides(value, &version)
.map_err(|e| std::io::Error::other(e.to_string())),
Err(_) => {
if let Some(table) = value.as_table_mut() {
table.remove(version_overrides::VERSION_OVERRIDES_KEY);
}
Ok(())
}
}
}
/// Recursively merge `overrides` into `base`. Values in `overrides` win.
pub fn deep_merge_toml(base: &mut toml::Value, overrides: &toml::Value) {
if let toml::Value::Table(overrides_table) = overrides
&& let toml::Value::Table(base_table) = base
{
for (key, value) in overrides_table {
if let Some(existing) = base_table.get_mut(key) {
deep_merge_toml(existing, value);
} else {
base_table.insert(key.clone(), value.clone());
}
}
} else {
*base = overrides.clone();
}
}
/// Expand `$VAR` / `${VAR}` in all string values.
pub fn expand_env_vars_in_toml(value: &mut toml::Value) {
match value {
toml::Value::String(s) => {
let expanded = expand_env_vars_in_string(s);
if expanded != *s {
*s = expanded;
}
}
toml::Value::Array(items) => {
for item in items {
expand_env_vars_in_toml(item);
}
}
toml::Value::Table(table) => {
for (_, item) in table.iter_mut() {
expand_env_vars_in_toml(item);
}
}
_ => {}
}
}
/// Expand `$VAR` / `${VAR}` in a single string.
pub fn expand_env_vars_in_string(input: &str) -> String {
let context = |name: &str| std::env::var(name).ok();
shellexpand::env_with_context_no_errors(input, context).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_layer_precedence_requirements_over_config_over_managed() {
let system_managed: toml::Value =
toml::from_str("[telemetry]\nmode = \"system_managed_value\"\n").unwrap();
let managed: toml::Value =
toml::from_str("[telemetry]\nmode = \"managed_value\"\n").unwrap();
let user: toml::Value = toml::from_str("[telemetry]\nmode = \"user_value\"\n").unwrap();
let user_requirements: toml::Value =
toml::from_str("[telemetry]\nmode = \"user_requirements_value\"\n").unwrap();
let system_requirements: toml::Value =
toml::from_str("[telemetry]\nmode = \"system_requirements_value\"\n").unwrap();
let mut merged = system_managed;
deep_merge_toml(&mut merged, &managed);
deep_merge_toml(&mut merged, &user);
deep_merge_toml(&mut merged, &user_requirements);
deep_merge_toml(&mut merged, &system_requirements);
assert_eq!(
merged["telemetry"]["mode"].as_str(),
Some("system_requirements_value")
);
}
#[test]
fn effective_config_mdm_requirements_win_over_system_and_user() {
// MDM is merged last, so an admin-forced value clamps the effective
// config over both the user config and the system requirements layer.
let layers = ConfigLayers {
user: toml::from_str("[features]\nweb_fetch = true\n").unwrap(),
system_requirements: Some(toml::from_str("[features]\nweb_fetch = true\n").unwrap()),
mdm_requirements: Some(toml::from_str("[features]\nweb_fetch = false\n").unwrap()),
..Default::default()
};
assert_eq!(
layers.effective_config_disk_only()["features"]["web_fetch"].as_bool(),
Some(false),
);
}
#[test]
fn full_precedence_holds_when_values_come_from_version_overrides() {
let cli_version = semver::Version::parse("1.8.0").unwrap();
let mut managed: toml::Value = toml::from_str(
r#"
[[version_overrides]]
minimum_version = "1.0.0"
[version_overrides.telemetry]
mode = "managed_versioned"
"#,
)
.unwrap();
let mut user: toml::Value = toml::from_str(
r#"
[[version_overrides]]
minimum_version = "1.0.0"
[version_overrides.telemetry]
mode = "user_versioned"
"#,
)
.unwrap();
let mut requirements: toml::Value = toml::from_str(
r#"
[[version_overrides]]
minimum_version = "1.0.0"
[version_overrides.telemetry]
mode = "requirements_versioned"
"#,
)
.unwrap();
apply_version_overrides(&mut managed, &cli_version).unwrap();
apply_version_overrides(&mut user, &cli_version).unwrap();
apply_version_overrides(&mut requirements, &cli_version).unwrap();
let mut merged = managed;
deep_merge_toml(&mut merged, &user);
deep_merge_toml(&mut merged, &requirements);
assert_eq!(
merged["telemetry"]["mode"].as_str(),
Some("requirements_versioned")
);
}
/// Direct contract for `deep_merge_toml`: nested tables merge (siblings
/// preserved), arrays replace (not concatenate), missing keys insert.
#[test]
fn deep_merge_toml_table_merge_array_replace_and_insert() {
let mut base: toml::Value = toml::from_str(
r#"
[features.telemetry]
enabled = false
sample_rate = 0.0
[server]
allowed = ["a", "b"]
"#,
)
.unwrap();
let overrides: toml::Value = toml::from_str(
r#"
[features.telemetry]
enabled = true
[server]
allowed = ["c"]
[brand_new]
x = 1
"#,
)
.unwrap();
deep_merge_toml(&mut base, &overrides);
assert_eq!(
base["features"]["telemetry"]["enabled"].as_bool(),
Some(true)
);
assert_eq!(
base["features"]["telemetry"]["sample_rate"].as_float(),
Some(0.0)
);
let arr: Vec<_> = base["server"]["allowed"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(arr, vec!["c"]);
assert_eq!(base["brand_new"]["x"].as_integer(), Some(1));
}
#[test]
fn user_version_overrides_dont_escape_their_layer() {
let cli_version = semver::Version::parse("1.8.0").unwrap();
let mut user: toml::Value = toml::from_str(
r#"
[[version_overrides]]
minimum_version = "1.0.0"
[version_overrides.telemetry]
mode = "enabled"
"#,
)
.unwrap();
apply_version_overrides(&mut user, &cli_version).unwrap();
assert_eq!(user["telemetry"]["mode"].as_str(), Some("enabled"));
let requirements: toml::Value = toml::from_str(
r#"
[telemetry]
mode = "disabled"
"#,
)
.unwrap();
let mut merged = user;
deep_merge_toml(&mut merged, &requirements);
assert_eq!(merged["telemetry"]["mode"].as_str(), Some("disabled"));
}
#[test]
fn load_user_config_layer_is_empty_without_user_home() {
// No resolvable user home: no user layer, and crucially no
// cwd-relative .grok read.
let v = load_user_config_layer(None, "config.toml").unwrap();
assert_eq!(v.as_table().map(|t| t.is_empty()), Some(true));
}
#[test]
fn load_user_config_layer_reads_file_when_home_present() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-load-layer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut f = std::fs::File::create(dir.join("config.toml")).unwrap();
writeln!(f, "[telemetry]\nmode = \"from_file\"\n").unwrap();
let v = load_user_config_layer(Some(&dir), "config.toml").unwrap();
assert_eq!(v["telemetry"]["mode"].as_str(), Some("from_file"));
let _ = std::fs::remove_dir_all(&dir);
}
/// The returned error keeps the parser's kind + location but never the source
/// snippet, which can carry a secret and would reach a client caller.
#[test]
fn parse_error_keeps_kind_but_not_snippet() {
let dir = std::env::temp_dir().join(format!("grok-toml-leak-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bad.toml");
// Duplicate key: the message names the key; the secret-bearing source line is only in Display.
std::fs::write(
&path,
"api_key = \"xai-secretmustnotleak\"\napi_key = \"xai-secretmustnotleak2\"\n",
)
.unwrap();
let msg = load_toml_file(&path).unwrap_err().to_string();
assert!(
msg.contains("TOML parse error at line 2"),
"want location: {msg}"
);
assert!(msg.contains("duplicate key"), "want parser kind: {msg}");
assert!(
!msg.contains("xai-secretmustnotleak"),
"leaked the secret value: {msg}"
);
assert!(
!msg.contains('|') && !msg.contains('^'),
"leaked the source snippet/caret: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// `GROK_CAMPAIGNS=0` disables campaign application regardless of config.
/// `GROK_CAMPAIGNS` is process-global, so this test serializes itself with a
/// module-local mutex and save/restores the prior value. (This crate has no
/// `serial_test` dev-dep and no other test reads this var, so a local guard
/// is sufficient.)
#[test]
fn kill_switch_env_var_disables() {
static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var_os("GROK_CAMPAIGNS");
let empty = toml::Value::Table(Default::default());
// SAFETY: ENV_GUARD serializes this against itself; no other test in the
// crate mutates or reads GROK_CAMPAIGNS concurrently.
unsafe { std::env::set_var("GROK_CAMPAIGNS", "0") };
assert!(campaigns_application_disabled(&empty));
unsafe { std::env::remove_var("GROK_CAMPAIGNS") };
assert!(!campaigns_application_disabled(&empty));
match prior {
Some(v) => unsafe { std::env::set_var("GROK_CAMPAIGNS", v) },
None => unsafe { std::env::remove_var("GROK_CAMPAIGNS") },
}
}
}

View file

@ -0,0 +1,196 @@
//! macOS MDM managed-preferences layer.
//!
//! Admins push a device profile with standard-base64 (padded) TOML under
//! preference domain `ai.x.grok` (`requirements_toml_base64`). Only admin-*forced*
//! values are read, so a local user can't forge it via their own preference
//! domain; trusted on every launch, independent of network/cache. `None` off macOS.
#[cfg(target_os = "macos")]
const MANAGED_PREFERENCES_DOMAIN: &str = "ai.x.grok";
#[cfg(target_os = "macos")]
const REQUIREMENTS_KEY: &str = "requirements_toml_base64";
/// Synthetic source label for the MDM layer (no file on disk); diagnostics only.
pub const MDM_REQUIREMENTS_SOURCE: &str = "ai.x.grok:requirements_toml_base64";
/// The MDM-forced requirements TOML, or `None` when none is forced (or not macOS).
pub(crate) fn managed_preferences_requirements() -> Option<toml::Value> {
// Read once and cache for the process lifetime: the forced policy is fixed per
// launch, so a mid-session profile change isn't picked up until restart — fine
// for a short-lived CLI, and it avoids re-crossing the CoreFoundation boundary.
static CACHED: std::sync::OnceLock<Option<toml::Value>> = std::sync::OnceLock::new();
CACHED
.get_or_init(|| managed_requirements_from(read_forced_requirements))
.clone()
}
/// Decode the forced requirements from a raw-string reader. Split from the FFI
/// read (`read_forced_requirements`) so the forced → decode path is unit-testable
/// without CoreFoundation (the CFPreferences read/downcast itself stays FFI).
fn managed_requirements_from(read: impl FnOnce() -> Option<String>) -> Option<toml::Value> {
decode_managed_toml(&read()?)
}
/// Decode a base64 TOML payload into a non-empty table. The forced payload is
/// used **verbatim** — `$VAR`/`${VAR}` are deliberately NOT expanded: this is
/// the trusted, non-forgeable admin layer, and expanding from the local process
/// environment would let the very user the forced check excludes influence the
/// policy (which feeds yolo / permission / minimum-version enforcement). FFI-free,
/// so unit-tested on every platform; invalid base64/UTF-8/TOML or an empty table
/// yields `None`.
fn decode_managed_toml(encoded: &str) -> Option<toml::Value> {
use base64::Engine as _;
// Strip all whitespace: profile tooling line-wraps payloads and the STANDARD
// engine rejects interior whitespace.
let compact: String = encoded
.chars()
.filter(|c| !c.is_ascii_whitespace())
.collect();
let decoded = base64::engine::general_purpose::STANDARD
.decode(compact.as_bytes())
.map_err(|e| tracing::warn!("managed preference is not valid base64: {e}"))
.ok()?;
let toml_str = String::from_utf8(decoded)
.map_err(|e| tracing::warn!("managed preference is not valid UTF-8: {e}"))
.ok()?;
let value = toml::from_str::<toml::Value>(&toml_str)
.map_err(|e| {
// Redact via the span-only detail: a TOML error's Display echoes the
// offending source line, and an admin payload may carry secrets.
tracing::warn!(
"managed preference is not valid TOML: {}",
crate::loader::toml_error_detail(&toml_str, &e)
)
})
.ok()?;
value
.as_table()
.is_some_and(|t| !t.is_empty())
.then_some(value)
}
/// The raw forced `requirements_toml_base64` MDM string via CoreFoundation, or
/// `None`. macOS only.
#[cfg(target_os = "macos")]
fn read_forced_requirements() -> Option<String> {
use core_foundation::base::{CFType, CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
// `CFPreferencesCopyAppValue` returns a +1 CFPropertyListRef (Copy rule).
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> CFTypeRef;
fn CFPreferencesAppValueIsForced(key: CFStringRef, application_id: CFStringRef) -> u8;
}
let cf_key = CFString::new(REQUIREMENTS_KEY);
let cf_app = CFString::new(MANAGED_PREFERENCES_DOMAIN);
// Trust only admin-forced values: otherwise the lookup falls through to the
// per-user domain, which a local user can set (`defaults write ai.x.grok`)
// to forge an `is_system`-trusted layer.
let forced = unsafe {
CFPreferencesAppValueIsForced(cf_key.as_concrete_TypeRef(), cf_app.as_concrete_TypeRef())
};
if forced == 0 {
return None;
}
let value_ref = unsafe {
CFPreferencesCopyAppValue(cf_key.as_concrete_TypeRef(), cf_app.as_concrete_TypeRef())
};
if value_ref.is_null() {
return None;
}
// Type-check before reading as text: reading a non-CFString through CFString
// APIs is UB. `wrap_under_create_rule` owns the +1, freeing it even if the
// downcast fails.
let value = unsafe { CFType::wrap_under_create_rule(value_ref) };
value.downcast_into::<CFString>().map(|s| s.to_string())
}
#[cfg(not(target_os = "macos"))]
fn read_forced_requirements() -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn b64(s: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
}
#[test]
fn decodes_valid_base64_toml_table() {
let v = decode_managed_toml(&b64("allowed_sandbox_modes = [\"read-only\"]\n"))
.expect("valid payload decodes");
assert_eq!(
v.get("allowed_sandbox_modes")
.and_then(|m| m.as_array())
.map(|a| a.len()),
Some(1)
);
}
#[test]
fn decodes_line_wrapped_base64() {
// Profile tooling line-wraps base64; interior newlines must be tolerated.
let raw = b64("allowed_sandbox_modes = [\"read-only\"]\n");
let wrapped = format!("{}\n{}", &raw[..4], &raw[4..]);
assert!(decode_managed_toml(&wrapped).is_some());
}
/// The forced payload is used verbatim: `$VAR`/`${VAR}` must NOT be expanded,
/// so a local user can't influence the trusted admin layer through their env.
#[test]
fn forced_payload_is_not_env_expanded() {
// SAFETY: process-global env mutation, restored before return.
let prior = std::env::var("GROK_MDM_NO_EXPAND_TEST").ok();
unsafe { std::env::set_var("GROK_MDM_NO_EXPAND_TEST", "attacker") };
let decoded = decode_managed_toml(&b64("base_url = \"${GROK_MDM_NO_EXPAND_TEST}/v1\"\n"));
unsafe {
match prior {
Some(p) => std::env::set_var("GROK_MDM_NO_EXPAND_TEST", p),
None => std::env::remove_var("GROK_MDM_NO_EXPAND_TEST"),
}
}
assert_eq!(
decoded
.as_ref()
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("${GROK_MDM_NO_EXPAND_TEST}/v1"),
"forced payload must keep ${{VAR}} literal, not expand from the user env",
);
}
/// The forced gating around the FFI read: nothing forced → no layer; a forced
/// valid payload → a layer; a forced but unparseable payload → no layer (never
/// a partial/garbage layer). Exercises the seam without CoreFoundation.
#[test]
fn managed_requirements_gated_on_the_forced_read() {
assert!(managed_requirements_from(|| None).is_none());
assert!(
managed_requirements_from(|| Some(b64("allowed_sandbox_modes = [\"read-only\"]\n")))
.is_some()
);
assert!(managed_requirements_from(|| Some("not base64!!!".to_string())).is_none());
}
#[test]
fn rejects_garbage_and_empty() {
// not valid base64
assert!(decode_managed_toml("not base64!!!").is_none());
// valid base64, but the bytes aren't valid UTF-8
let bad_utf8 = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe]);
assert!(decode_managed_toml(&bad_utf8).is_none());
// valid base64 + valid UTF-8, but not parseable TOML
assert!(decode_managed_toml(&b64("= not toml =")).is_none());
// empty table → skipped (no managed preference effectively)
assert!(decode_managed_toml(&b64("")).is_none());
}
}

View file

@ -0,0 +1,464 @@
//! The managed-config cloud-cache subsystem: the sync marker, serving identity,
//! staleness (timer + hard), and the fail-closed enforcement gate that combines
//! the signed-cache verdict with the best-effort marker.
//!
//! The marker is **unsigned** and user-writable — a refresh hint, not a tamper
//! control; real tamper resistance is [`crate::signed_policy`] plus the
//! OS-protected layers (root-owned `/etc/grok`, MDM).
use std::path::Path;
use crate::paths::user_grok_home;
/// Sync marker; staleness keys on this, not mtimes.
const MANAGED_CONFIG_CACHE_FILE: &str = "managed_config_cache.json";
/// The on-disk marker: unsigned, detects only deletion / identity change, not
/// in-place edits (see the module doc).
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct ManagedConfigCache {
/// Unix seconds of the last successful fetch.
synced_at: Option<u64>,
/// Team id, or the deploy-key path's server `deployment_id` (reported via
/// [`managed_deployment_id`]; identity is `key_fingerprint`).
principal: Option<String>,
/// Artifacts this sync served, so staleness spots a later deletion; `default` false so pre-upgrade markers don't over-claim.
#[serde(default)]
had_managed_config: bool,
#[serde(default)]
had_requirements: bool,
/// Deploy-key fingerprint (never the raw key) — the deploy-key identity (see [`ServingIdentity`]); `None` on the team path.
#[serde(default)]
key_fingerprint: Option<String>,
/// Served opt-in (`fail_closed = true`); `default` false so a pre-upgrade or un-opted marker never fails closed.
#[serde(default)]
fail_closed: bool,
}
/// What the cache is bound to (one value, so a (team, key) combo can't form). The
/// deploy-key fingerprint is the only identity verifiable offline (no key→deployment_id map without the network).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServingIdentity {
Team(String),
DeploymentKey { fingerprint: String },
None,
}
/// Whether to refetch for `identity`: no marker, past the timer, different identity, or a served artifact now missing.
/// Best-effort — callers continue without managed config on failure.
pub fn is_managed_config_stale_for(identity: &ServingIdentity) -> bool {
managed_config_stale_at(user_grok_home().as_deref(), identity)
}
/// Fields a successful sync records. A struct (destructured without `..`) so a new field is
/// a compile error at every writer — three adjacent positional bools would silently transpose.
pub struct SyncMarker<'a> {
pub principal: Option<&'a str>,
pub had_managed_config: bool,
pub had_requirements: bool,
pub key_fingerprint: Option<&'a str>,
pub fail_closed: bool,
}
/// Record a successful sync (best-effort; called even for a config-less principal so it doesn't refetch every tick).
pub fn mark_managed_config_synced(marker: SyncMarker<'_>) {
if let Some(home) = user_grok_home() {
mark_managed_config_synced_at(&home, marker);
}
}
/// Server-side GrokBuildDeployment UUID from the last deploy-key managed-config
/// sync, bound to the key that synced it: returns the marker's `principal` only
/// when the marker's `key_fingerprint` equals `key_fingerprint`, so a rotated or
/// removed key never reports the previous deployment's id. Team-path syncs store
/// a team id and no fingerprint, so they never match.
pub fn managed_deployment_id(key_fingerprint: &str) -> Option<String> {
managed_deployment_id_at(user_grok_home()?.as_path(), key_fingerprint)
}
fn managed_deployment_id_at(home: &Path, key_fingerprint: &str) -> Option<String> {
if key_fingerprint.trim().is_empty() {
return None;
}
let cache = read_managed_config_cache(home)?;
if cache.key_fingerprint.as_deref() != Some(key_fingerprint) {
return None;
}
cache.principal.filter(|p| !p.trim().is_empty())
}
fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
let SyncMarker {
principal,
had_managed_config,
had_requirements,
key_fingerprint,
fail_closed,
} = marker;
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.ok();
let cache = ManagedConfigCache {
synced_at,
principal: principal.map(str::to_owned),
// What THIS sync served, not on-disk presence — a confirmed switch already evicted any prior files.
had_managed_config,
had_requirements,
key_fingerprint: key_fingerprint.map(str::to_owned),
fail_closed,
};
match serde_json::to_string(&cache) {
Ok(json) => write_marker_atomically(home, &json),
Err(e) => tracing::warn!("failed to serialize managed config cache: {e}"),
}
}
/// Atomic write of the marker; best-effort (failure is logged, never surfaced).
fn write_marker_atomically(home: &Path, json: &str) {
if let Err(e) =
crate::fs_atomic::write_atomically(&home.join(MANAGED_CONFIG_CACHE_FILE), json, None)
{
tracing::warn!("failed to write managed config cache: {e}");
}
}
/// 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.
fn read_managed_config_cache(home: &Path) -> Option<ManagedConfigCache> {
let json = match std::fs::read_to_string(home.join(MANAGED_CONFIG_CACHE_FILE)) {
Ok(json) => json,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!("managed config cache unreadable; treating as no marker: {e}");
return None;
}
};
match serde_json::from_str(&json) {
Ok(cache) => Some(cache),
Err(e) => {
tracing::warn!(
"managed config cache is corrupt; treating as no marker, next sync rewrites it: {e}"
);
None
}
}
}
/// A confirmed identity switch vs the marker — both sides of a dimension present and differing (team id or fingerprint).
/// Callers evict prior artifacts on true; a missing marker / `None` / pre-upgrade never counts (first sync / signed-out / legacy never evict).
/// A blank/whitespace value on either side of either dimension (principal or key fingerprint)
/// is "unknown", not a distinct tenant — a malformed `auth.json` parse blip must not confirm a
/// switch and shed a real tenant's policy.
pub fn managed_config_identity_changed(
new_principal: Option<&str>,
new_key_fingerprint: Option<&str>,
) -> bool {
user_grok_home().is_some_and(|home| {
managed_config_identity_changed_at(&home, new_principal, new_key_fingerprint)
})
}
fn managed_config_identity_changed_at(
home: &Path,
new_principal: Option<&str>,
new_key_fingerprint: Option<&str>,
) -> bool {
let Some(cache) = read_managed_config_cache(home) else {
return false;
};
let principal_changed = matches!(
(cache.principal.as_deref(), new_principal),
(Some(old), Some(new))
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
);
let key_changed = matches!(
(cache.key_fingerprint.as_deref(), new_key_fingerprint),
(Some(old), Some(new))
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
);
principal_changed || key_changed
}
/// True when an artifact the marker recorded serving is now absent. Only served artifacts count, so a config-less
/// principal (or legacy marker) isn't misread as stale. Detects deletion, not edits.
fn cache_missing_required_artifact(cache: &ManagedConfigCache, home: &Path) -> bool {
(cache.had_requirements && !home.join("requirements.toml").exists())
|| (cache.had_managed_config && !home.join("managed_config.toml").exists())
}
/// Whether the cached principal differs from the team serving now — the team dimension only.
/// Deploy-key identity is verified by fingerprint ([`cache_key_fingerprint_mismatch`]); `None` never fires.
fn cache_identity_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
match identity {
ServingIdentity::Team(team_id) => cache.principal.as_deref() != Some(team_id.as_str()),
ServingIdentity::DeploymentKey { .. } | ServingIdentity::None => false,
}
}
/// Whether the configured deployment key differs from the cache's, by one-way fingerprint (never the raw key) —
/// the only identity verifiable offline. A pre-upgrade marker (no fingerprint) never fires; only a *changed* key.
fn cache_key_fingerprint_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
match identity {
ServingIdentity::DeploymentKey { fingerprint } => {
matches!(cache.key_fingerprint.as_deref(), Some(recorded) if recorded != fingerprint)
}
ServingIdentity::Team(_) | ServingIdentity::None => false,
}
}
/// The team id for the signed-cache check; `None` for a deployment key (bound by the
/// marker's deployment id, not a team) or no identity.
fn serving_team_id(identity: &ServingIdentity) -> Option<&str> {
match identity {
ServingIdentity::Team(team_id) => Some(team_id.as_str()),
ServingIdentity::DeploymentKey { .. } | ServingIdentity::None => None,
}
}
/// Tamper signals for the current identity, split two ways: [`Self::needs_refetch`] (staleness) on ANY
/// signal; [`Self::compromised_for_gate`] (gate) only on artifact-missing or key-change — never a pure
/// identity mismatch (a foreign marker the online refetch rebinds).
#[derive(Clone, Copy)]
struct TamperSignals {
artifact_missing: bool,
identity_mismatch: bool,
key_fingerprint_mismatch: bool,
}
impl TamperSignals {
fn evaluate(cache: &ManagedConfigCache, home: &Path, identity: &ServingIdentity) -> Self {
Self {
artifact_missing: cache_missing_required_artifact(cache, home),
identity_mismatch: cache_identity_mismatch(cache, identity),
key_fingerprint_mismatch: cache_key_fingerprint_mismatch(cache, identity),
}
}
fn needs_refetch(self) -> bool {
self.artifact_missing || self.identity_mismatch || self.key_fingerprint_mismatch
}
fn compromised_for_gate(self) -> bool {
self.artifact_missing || self.key_fingerprint_mismatch
}
}
/// Cache unusable now: different identity, a served artifact missing, or no marker. The session-start refresh blocks
/// (bounded) on this but not timer-staleness, so a present same-identity cache never delays startup offline.
pub fn is_managed_config_hard_stale_for(identity: &ServingIdentity) -> bool {
match user_grok_home() {
Some(home) => is_managed_config_hard_stale_for_at(&home, identity),
None => false,
}
}
/// Whether the cache can't be used for `identity` — a served artifact missing or a different
/// identity. Shared by the staleness and session-start paths so the siblings can't drift.
fn cache_unusable_for(cache: &ManagedConfigCache, home: &Path, identity: &ServingIdentity) -> bool {
TamperSignals::evaluate(cache, home, identity).needs_refetch()
}
/// The principal the SIGNED cache must be bound to: the live team id, else the marker
/// principal (the recorded deployment id on a deployment-key machine). One derivation
/// shared by the gate and both staleness checks, so a foreign-but-authentic cache
/// reads foreign on every sibling path.
fn expected_signed_principal<'a>(
cache: Option<&'a ManagedConfigCache>,
identity: &'a ServingIdentity,
) -> Option<&'a str> {
serving_team_id(identity).or_else(|| cache.and_then(|c| c.principal.as_deref()))
}
/// A signing-enabled build over a legacy unsigned / edited / forged or foreign-bound
/// cache refetches a signed copy. Dark build or no policy on disk → false, so this is
/// inert until a key is provisioned.
fn signed_cache_needs_refetch(
home: &Path,
cache: Option<&ManagedConfigCache>,
identity: &ServingIdentity,
) -> bool {
crate::signed_policy::cloud_cache_signature_invalid(
home,
expected_signed_principal(cache, identity),
crate::signed_policy::now_unix(),
)
}
fn is_managed_config_hard_stale_for_at(home: &Path, identity: &ServingIdentity) -> bool {
let cache = read_managed_config_cache(home);
cache
.as_ref()
.is_none_or(|cache| cache_unusable_for(cache, home, identity))
|| signed_cache_needs_refetch(home, cache.as_ref(), identity)
}
/// No-network fail-closed predicate: true only on a `fail_closed` policy with tamper for
/// the current identity. With a key compiled in the SIGNED verdict leads (non-forgeable
/// opt-in, catches edits the marker can't, and a fail-closed marker then REQUIRES an
/// authentic sidecar); the dark build uses only the best-effort marker decision.
pub fn managed_policy_compromised_for(identity: &ServingIdentity) -> bool {
user_grok_home().is_some_and(|home| managed_policy_compromised_for_at(&home, identity))
}
/// Apply writes the policy files before the sidecar with no lock shared with gate
/// readers, so a session start racing a background sync can pair new files with the
/// old sidecar and transiently read Compromised. One pause covers the tiny write gap.
const APPLY_RACE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
fn managed_policy_compromised_for_at(home: &Path, identity: &ServingIdentity) -> bool {
compromised_with_apply_race_retry(
|| managed_policy_compromised_once(home, identity),
|| std::thread::sleep(APPLY_RACE_RETRY_DELAY),
)
}
/// GATE-only retry for the apply race (see [`APPLY_RACE_RETRY_DELAY`]): on a refusing
/// Compromised verdict, re-evaluate once and use the second verdict — real tamper is
/// still Compromised on the second pass. The staleness/refetch siblings never retry:
/// flagging a transient mismatch there is exactly the self-heal.
fn compromised_with_apply_race_retry(
mut evaluate: impl FnMut() -> (bool, crate::signed_policy::SignedVerdict),
pause: impl FnOnce(),
) -> bool {
match evaluate() {
(false, _) => false,
(true, crate::signed_policy::SignedVerdict::Compromised) => {
pause();
evaluate().0
}
(true, _) => true,
}
}
/// One full evaluation of the gate decision, returning the signed verdict alongside so
/// the retry wrapper can distinguish a (possibly racing) Compromised refusal.
fn managed_policy_compromised_once(
home: &Path,
identity: &ServingIdentity,
) -> (bool, crate::signed_policy::SignedVerdict) {
let cache = read_managed_config_cache(home);
let signed_verdict = crate::signed_policy::signed_cache_compromised(
home,
expected_signed_principal(cache.as_ref(), identity),
crate::signed_policy::now_unix(),
);
// The signature binds a deployment_id, not the local deploy key, so a Trusted verdict
// can't attest the configured key — pass the fingerprint mismatch through so it gates
// on every path.
let key_fingerprint_mismatch = cache
.as_ref()
.is_some_and(|c| cache_key_fingerprint_mismatch(c, identity));
let compromised = managed_policy_compromised_decision(
signed_verdict,
key_fingerprint_mismatch,
cache.as_ref(),
home,
identity,
);
(compromised, signed_verdict)
}
/// Combine the signed verdict with the best-effort marker fallback — one row per
/// verdict; each row's reasoning lives on its [`SignedVerdict`] variant doc. Split
/// out so the signed↔marker integration is unit-testable without a compiled-in key.
fn managed_policy_compromised_decision(
signed_verdict: crate::signed_policy::SignedVerdict,
key_fingerprint_mismatch: bool,
cache: Option<&ManagedConfigCache>,
home: &Path,
identity: &ServingIdentity,
) -> bool {
use crate::signed_policy::SignedVerdict;
// A fail-closed marker that recorded served policy requires an authentic sidecar.
let sidecar_required_but_missing = || {
let required =
cache.is_some_and(|c| c.fail_closed && (c.had_managed_config || c.had_requirements));
if required {
tracing::warn!(
"managed policy fail-closed gate: refusing session — signed sidecar missing or unverifiable"
);
}
required
};
// The best-effort marker decision: refuse only an opted-in marker with gate-grade tamper.
let marker_compromised = || {
cache.is_some_and(|cache| {
if !cache.fail_closed {
return false;
}
let signals = TamperSignals::evaluate(cache, home, identity);
let compromised = signals.compromised_for_gate();
// Booleans only — never the raw key (the fingerprint is already a one-way hash).
if compromised {
tracing::warn!(
artifact_missing = signals.artifact_missing,
identity_mismatch = signals.identity_mismatch,
key_fingerprint_mismatch = signals.key_fingerprint_mismatch,
"managed policy fail-closed gate: refusing session on tamper evidence"
);
} else if signals.identity_mismatch {
tracing::debug!(
identity_mismatch = true,
"managed policy fail-closed gate: foreign marker, not refusing (online refetch rebinds)"
);
}
compromised
})
};
match signed_verdict {
SignedVerdict::Compromised => true,
// Trusted clears the gate — except the deploy-key fingerprint, which the signature can't attest.
SignedVerdict::Trusted => key_fingerprint_mismatch && marker_compromised(),
SignedVerdict::NoAuthenticSidecar => sidecar_required_but_missing() || marker_compromised(),
SignedVerdict::SidecarUnreadable => marker_compromised(),
SignedVerdict::Inactive => marker_compromised(),
}
}
/// Stale when never synced, past the threshold, identity differs, a served artifact is now missing,
/// or (keyed builds) the signed cache no longer verifies. No home → nothing to refresh into → not
/// stale. Reads the marker once.
fn managed_config_stale_at(home: Option<&Path>, identity: &ServingIdentity) -> bool {
let Some(home) = home else {
return false;
};
let Some(cache) = read_managed_config_cache(home) else {
return true; // no marker → never synced → stale
};
if cache_unusable_for(&cache, home, identity) {
return true;
}
// Same signed check as the session-start hard-stale sibling: the background tick
// must also refetch a tampered/foreign-signed cache, not leave it until startup.
if signed_cache_needs_refetch(home, Some(&cache), identity) {
return true;
}
match cache.synced_at {
// `duration_since` errs when `synced_at` is in the future (clock skew);
// treat that as freshly synced rather than stale.
Some(secs) => {
let synced_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
std::time::SystemTime::now()
.duration_since(synced_at)
.is_ok_and(|age| age > managed_config_stale_threshold())
}
None => true,
}
}
/// Override with `GROK_DEPLOYMENT_CONFIG_CACHE_TTL_SECS` for testing.
fn managed_config_stale_threshold() -> std::time::Duration {
if let Ok(s) = std::env::var("GROK_DEPLOYMENT_CONFIG_CACHE_TTL_SECS")
&& let Ok(secs) = s.parse::<u64>()
{
return std::time::Duration::from_secs(secs);
}
std::time::Duration::from_secs(30 * 60)
}
// Tests in a sibling file (they dwarf the module) but a child module, for private access.
#[cfg(test)]
#[path = "managed_cache/tests.rs"]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,327 @@
//! Filesystem locations for grok config files and binaries.
use std::path::PathBuf;
use std::sync::OnceLock;
static GROK_HOME: OnceLock<PathBuf> = OnceLock::new();
#[cfg(target_os = "macos")]
const CLAUDE_MANAGED_SETTINGS_PATH: &str =
"/Library/Application Support/ClaudeCode/managed-settings.json";
#[cfg(target_os = "linux")]
const CLAUDE_MANAGED_SETTINGS_PATH: &str = "/etc/claude-code/managed-settings.json";
/// The default user grok directory (`~/.grok`, canonicalized) used when
/// `GROK_HOME` is unset. Exposed so callers (e.g. display helpers) can detect
/// whether [`grok_home()`] is the default without duplicating the computation.
///
/// Uses [`dunce::canonicalize`] instead of [`std::fs::canonicalize`]: on
/// Windows, std returns a verbatim path (`\\?\C:\Users\...`) which external
/// tools choke on — e.g. `git clone` rejects `\\?\` destinations with
/// "Invalid argument", breaking marketplace cache clones under
/// `~/.grok/marketplace-cache`. `dunce` strips the prefix whenever the path
/// is safely representable in legacy form; on non-Windows it is identical to
/// `std::fs::canonicalize`.
///
/// Keep the dunce canonicalization in sync with the hand-rolled duplicate in
/// `xai_fast_worktree::db::resolve_grok_home` (deliberately standalone crate).
pub fn default_grok_home() -> PathBuf {
#[allow(deprecated)]
let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from("."));
dunce::canonicalize(&home).unwrap_or(home).join(".grok")
}
/// Per-user config directory: `$GROK_HOME` or `~/.grok`. Created if needed.
pub fn grok_home() -> PathBuf {
GROK_HOME
.get_or_init(|| {
let grok_home = if let Ok(v) = std::env::var("GROK_HOME") {
PathBuf::from(v)
} else {
default_grok_home()
};
let _ = std::fs::create_dir_all(&grok_home);
grok_home
})
.clone()
}
/// The user-global grok home, but only when one genuinely resolves: `Some` when
/// `$GROK_HOME` is set or a home directory is found, `None` otherwise. Unlike
/// [`grok_home()`], this never falls back to a cwd-relative `.grok`, so callers
/// that *scan* user-global grok resources (hooks, marketplace sources, ...) don't
/// mistake a project's `.grok` tree for the user-global one when no home resolves.
pub fn user_grok_home() -> Option<PathBuf> {
#[allow(deprecated)]
let resolvable = std::env::var_os("GROK_HOME").is_some() || std::env::home_dir().is_some();
resolvable.then(grok_home)
}
/// Canonical grok application path: `$GROK_HOME/bin/grok` (Unix) or `grok.exe` (Windows).
pub fn grok_application() -> PathBuf {
let name = if cfg!(windows) { "grok.exe" } else { "grok" };
grok_home().join("bin").join(name)
}
/// System-wide config directory: `/etc/grok/` on Unix, `None` on Windows.
pub fn system_config_dir() -> Option<PathBuf> {
if cfg!(unix) {
Some(PathBuf::from("/etc/grok"))
} else {
None
}
}
/// System path for the managed-settings.json used for settings compat, if it exists.
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn claude_managed_settings_path() -> Option<PathBuf> {
let path = PathBuf::from(CLAUDE_MANAGED_SETTINGS_PATH);
path.exists().then_some(path)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn claude_managed_settings_path() -> Option<PathBuf> {
None
}
/// The platform path where managed-settings.json would live for settings
/// compat, whether or not it exists. `None` on unsupported platforms.
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn claude_managed_settings_probe_path() -> Option<PathBuf> {
Some(PathBuf::from(CLAUDE_MANAGED_SETTINGS_PATH))
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn claude_managed_settings_probe_path() -> Option<PathBuf> {
None
}
/// Max bytes for a single directory name component (macOS APFS, Linux ext4,
/// NTFS all enforce 255 bytes).
const MAX_DIRNAME_BYTES: usize = 255;
/// Encode a CWD string into a filesystem-safe directory name component.
///
/// Short CWDs (URL-encoded form <= 255 bytes) use URL-encoding for backward
/// compatibility and human readability on disk.
///
/// Long CWDs (> 255 bytes encoded) use a compact `{slug}-{blake3_hex16}`
/// form that is always <= 57 bytes. Callers must write a `.cwd` metadata
/// file via [`ensure_sessions_cwd_dir`] so the original CWD can be
/// recovered by [`decode_cwd_from_dirname`].
pub fn encode_cwd_dirname(cwd: &str) -> String {
let url_encoded = urlencoding::encode(cwd);
if url_encoded.len() <= MAX_DIRNAME_BYTES {
return url_encoded.into_owned();
}
let hash = blake3::hash(cwd.as_bytes());
let hash16 = &hash.to_hex()[..16];
let leaf = std::path::Path::new(cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace");
let slug = slugify(leaf, 40);
let slug = if slug.is_empty() { "workspace" } else { &slug };
format!("{slug}-{hash16}")
}
/// Recover the original CWD from a sessions CWD directory.
///
/// Tries URL-decoding the directory name first (works for short/legacy dirs).
/// Falls back to reading a `.cwd` metadata file inside the directory (written
/// by [`ensure_sessions_cwd_dir`] for hash-based dirs).
pub fn decode_cwd_from_dirname(dir: &std::path::Path) -> Option<String> {
let name = dir.file_name()?.to_str()?;
if let Ok(decoded) = urlencoding::decode(name) {
let s = decoded.into_owned();
// URL-decoded absolute CWDs always start with `/` (Unix) or a drive
// letter (Windows). The slug-hash form never does, so this
// distinguishes the two encodings unambiguously.
if s.starts_with('/') || (cfg!(windows) && s.chars().nth(1) == Some(':')) {
return Some(s);
}
}
std::fs::read_to_string(dir.join(".cwd"))
.ok()
.map(|s| s.trim().to_string())
}
/// Build the CWD-level session directory path:
/// `grok_home()/sessions/{encode_cwd_dirname(cwd)}`.
///
/// Does **not** create the directory on disk — use [`ensure_sessions_cwd_dir`]
/// when the directory must exist.
pub fn sessions_cwd_dir(cwd: &str) -> PathBuf {
grok_home().join("sessions").join(encode_cwd_dirname(cwd))
}
/// Create the CWD-level session directory and write a `.cwd` metadata file
/// when hash-based encoding is used (long paths).
///
/// For short paths the `.cwd` file is not written because the directory name
/// itself is reversible via URL-decoding.
pub fn ensure_sessions_cwd_dir(cwd: &str) -> std::io::Result<PathBuf> {
let encoded_name = encode_cwd_dirname(cwd);
let dir = grok_home().join("sessions").join(&encoded_name);
std::fs::create_dir_all(&dir)?;
// Hash-based encoding is in use when the dirname differs from the
// plain URL-encoded form. Write a `.cwd` file so decode can recover
// the original path. O_CREAT|O_EXCL via create_new avoids TOCTOU
// races with parallel session starts.
if encoded_name != urlencoding::encode(cwd).as_ref() {
let cwd_file = dir.join(".cwd");
match std::fs::File::create_new(&cwd_file) {
Ok(mut f) => {
std::io::Write::write_all(&mut f, cwd.as_bytes())?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
Ok(dir)
}
/// Generate a URL-safe slug from a string.
///
/// Lowercases, replaces non-alphanumeric chars with `-`, collapses
/// consecutive dashes, and truncates to `max_len` characters.
fn slugify(input: &str, max_len: usize) -> String {
let mut result = String::with_capacity(input.len());
let mut prev_dash = false;
for c in input.to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
result.push(c);
prev_dash = false;
} else if !prev_dash {
result.push('-');
prev_dash = true;
}
}
let trimmed = result.trim_matches('-');
trimmed.chars().take(max_len).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Realistic CWDs that trigger the bug (URL-encoded > 255 bytes).
const LONG_CWDS: &[&str] = &[
"/Users/dev/Documents/開発プロジェクト/機能追加/テスト環境/ソースコード/main-branch",
"/Users/user/Library/Mobile Documents/com~apple~CloudDocs/项目文件/深层嵌套目录/更深层次的/工作区域/project",
"/Users/user/Library/CloudStorage/OneDrive-대한민국회사/프로젝트/개발환경/소스코드/백엔드/서비스/my-app",
"/Users/user/Documents/工作文件夹/二零二六年项目/子目录一/子目录二/子目录三/源代码/code",
];
#[test]
fn long_cwd_uses_hash_fallback_within_name_max() {
let long_cwd = format!("/Users/test/{}", "".repeat(30));
let encoded = encode_cwd_dirname(&long_cwd);
assert!(encoded.len() <= MAX_DIRNAME_BYTES);
assert!(!encoded.starts_with("%2F"));
}
#[test]
fn different_long_paths_produce_different_hashes() {
let a = format!("/Users/test/{}", "".repeat(30));
let b = format!("/Users/test/{}", "".repeat(30));
assert_ne!(encode_cwd_dirname(&a), encode_cwd_dirname(&b));
}
#[test]
fn decode_reads_cwd_file_for_hash_dirs() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("some-slug-abcdef0123456789");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".cwd"), "/original/long/path").unwrap();
assert_eq!(
decode_cwd_from_dirname(&dir),
Some("/original/long/path".to_string())
);
}
#[test]
fn decode_returns_none_without_cwd_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("some-slug-abcdef0123456789");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(decode_cwd_from_dirname(&dir), None);
}
#[test]
fn cwd_file_write_is_idempotent_via_excl() {
let tmp = TempDir::new().unwrap();
let long_cwd = format!("/Users/test/{}", "".repeat(30));
let dir = tmp.path().join(encode_cwd_dirname(&long_cwd));
std::fs::create_dir_all(&dir).unwrap();
let cwd_file = dir.join(".cwd");
std::fs::write(&cwd_file, &long_cwd).unwrap();
match std::fs::File::create_new(&cwd_file) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
other => panic!("expected AlreadyExists, got: {other:?}"),
}
assert_eq!(std::fs::read_to_string(&cwd_file).unwrap(), long_cwd);
}
#[test]
fn url_encoded_long_cwd_fails_on_real_filesystem() {
let tmp = TempDir::new().unwrap();
let url_encoded = urlencoding::encode(LONG_CWDS[0]).into_owned();
let result = std::fs::create_dir_all(tmp.path().join(&url_encoded));
assert!(result.is_err());
}
#[test]
fn full_roundtrip_on_real_filesystem_for_long_cwds() {
let tmp = TempDir::new().unwrap();
for cwd in LONG_CWDS {
let encoded = encode_cwd_dirname(cwd);
let dir = tmp.path().join(&encoded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".cwd"), cwd).unwrap();
assert_eq!(decode_cwd_from_dirname(&dir).as_deref(), Some(*cwd));
}
}
#[test]
fn short_cwds_use_url_encoding_and_roundtrip_on_real_filesystem() {
let tmp = TempDir::new().unwrap();
for cwd in [
"/Users/foo/project",
"/tmp",
"/Users/user/Documents/project-名前",
] {
let encoded = encode_cwd_dirname(cwd);
assert_eq!(encoded, urlencoding::encode(cwd).into_owned());
let dir = tmp.path().join(&encoded);
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(decode_cwd_from_dirname(&dir).as_deref(), Some(cwd));
}
}
#[test]
fn default_grok_home_has_no_verbatim_prefix() {
// On Windows, std::fs::canonicalize returns `\\?\C:\...` verbatim
// paths that external tools (notably `git clone`) reject. The dunce
// canonicalization must yield a plain path. No-op assertion on Unix.
let home = default_grok_home();
assert!(!home.to_string_lossy().starts_with(r"\\?\"));
assert!(home.ends_with(".grok"));
}
#[test]
fn slugify_basic() {
assert_eq!(slugify("Hello World!", 40), "hello-world");
}
#[test]
fn slugify_cjk_produces_empty() {
assert_eq!(slugify("深层目录", 40), "");
}
#[test]
fn slugify_truncates() {
assert_eq!(slugify(&"a".repeat(100), 10).len(), 10);
}
}

View file

@ -0,0 +1,682 @@
//! Windows shell detection for terminal command execution.
//!
//! Default cascade: pwsh → powershell.exe → Git Bash → powershell.exe (fallback).
//!
//! PowerShell is preferred over Git Bash because MSYS2/Git Bash performs
//! POSIX-to-Windows path translation, mangling every flag starting with `/`
//! (e.g. MSBuild `/t:Build`, cl.exe `/nologo`). This breaks native Windows
//! C++/C#/.NET builds.
//!
//! Set `GROK_SHELL` to override auto-detection: `pwsh`, `powershell`,
//! `bash`, or `cmd`. Result is cached for the process lifetime.
/// Detected Windows shell and how to invoke it.
#[cfg(not(unix))]
#[derive(Clone, Debug)]
pub enum WindowsShell {
GitBash(String),
Pwsh,
PowerShell,
Cmd,
}
/// Detect the best available shell on Windows.
///
/// If `GROK_SHELL` is set, it takes precedence over auto-detection.
/// Otherwise the cascade is: pwsh → powershell.exe → Git Bash → cmd.exe.
///
/// Result is cached for the process lifetime.
#[cfg(not(unix))]
pub fn detect_windows_shell() -> &'static WindowsShell {
use std::sync::OnceLock;
static CACHED: OnceLock<WindowsShell> = OnceLock::new();
CACHED.get_or_init(|| {
// Explicit override via GROK_SHELL.
if let Ok(val) = std::env::var("GROK_SHELL") {
match val.trim().to_ascii_lowercase().as_str() {
"pwsh" => {
tracing::info!("Windows shell (GROK_SHELL override): pwsh");
return WindowsShell::Pwsh;
}
"powershell" => {
tracing::info!("Windows shell (GROK_SHELL override): powershell.exe");
return WindowsShell::PowerShell;
}
"bash" | "gitbash" | "git-bash" => {
if let Some(path) = find_git_bash() {
tracing::info!(
shell = path,
"Windows shell (GROK_SHELL override): Git Bash"
);
return WindowsShell::GitBash(path);
}
tracing::warn!(
"GROK_SHELL={val} but Git Bash not found; falling through to auto-detect"
);
}
"cmd" | "cmd.exe" => {
tracing::info!("Windows shell (GROK_SHELL override): cmd.exe");
return WindowsShell::Cmd;
}
other => {
tracing::warn!(
"GROK_SHELL={other} is not recognized \
(expected pwsh|powershell|bash|cmd); falling through to auto-detect"
);
}
}
}
// Auto-detect: prefer PowerShell over Git Bash. PowerShell
// passes `/flag` arguments through unchanged, which is required
// for native Windows toolchains (MSBuild, cl.exe, dotnet).
// pwsh (PowerShell 7+).
if let Ok(output) = {
let mut cmd = std::process::Command::new("where");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
}
// powershell.exe (Windows PowerShell 5.1).
if std::path::Path::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
.exists()
{
tracing::info!("Windows shell: powershell.exe");
return WindowsShell::PowerShell;
}
// Git Bash: available but not preferred (MSYS2 path translation
// breaks `/flag` arguments for native toolchains).
if let Some(path) = find_git_bash() {
tracing::info!(shell = path, "Windows shell: Git Bash");
return WindowsShell::GitBash(path);
}
tracing::info!("Windows shell: powershell.exe (fallback)");
WindowsShell::PowerShell
})
}
/// Locate Git Bash on disk. Checks common install paths, then falls back
/// to `where bash.exe` (filtering for Git paths to avoid WSL bash).
#[cfg(not(unix))]
fn find_git_bash() -> Option<String> {
let candidates = [
std::env::var("PROGRAMFILES")
.map(|pf| format!("{pf}\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
std::env::var("PROGRAMFILES(X86)")
.map(|pf| format!("{pf}\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
std::env::var("LOCALAPPDATA")
.map(|la| format!("{la}\\Programs\\Git\\bin\\bash.exe"))
.unwrap_or_default(),
];
for candidate in &candidates {
if !candidate.is_empty() && std::path::Path::new(candidate).exists() {
return Some(candidate.clone());
}
}
// Fall back to PATH; prefer Git Bash over WSL bash.
if let Ok(output) = {
let mut cmd = std::process::Command::new("where");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
}
}
}
None
}
#[cfg(not(unix))]
impl WindowsShell {
/// Short display name for user-facing contexts (e.g. "bash", "pwsh").
pub fn name(&self) -> &'static str {
match self {
Self::GitBash(_) => "bash",
Self::Pwsh => "pwsh",
Self::PowerShell => "powershell",
Self::Cmd => "cmd.exe",
}
}
/// Whether this shell supports the `&&` pipeline chain operator for
/// error-propagating command chaining.
///
/// - `pwsh` (PowerShell 7+): `&&` added in PS 7.0.
/// - Git Bash: standard bash `&&`.
/// - `powershell.exe` (5.1): no `&&` support; use `;`.
/// - `cmd.exe`: `&&` works but is inconsistent with the `-Command`
/// invocation style used elsewhere; use `;` for uniformity.
pub fn supports_chain_operator(&self) -> bool {
matches!(self, Self::Pwsh | Self::GitBash(_))
}
/// Whether `grep`, `head`, `tail`, `sed`, `awk`, `find` are usable
/// from this shell. True for Git Bash (MSYS2 bundles them inside the
/// bash subprocess); false for PowerShell and `cmd.exe`.
pub fn has_unix_utilities(&self) -> bool {
matches!(self, Self::GitBash(_))
}
/// How this shell interprets a bare `&` token. Drives the `run_terminal_cmd`
/// background-operator validation, which must differ per shell.
pub fn ampersand_semantics(&self) -> AmpersandSemantics {
match self {
Self::GitBash(_) => AmpersandSemantics::PosixBackground,
Self::Pwsh => AmpersandSemantics::PowerShellCore,
Self::PowerShell => AmpersandSemantics::WindowsPowerShell,
Self::Cmd => AmpersandSemantics::CmdSeparator,
}
}
}
/// Returns the appropriate command chaining separator for the current
/// platform and detected shell.
///
/// - Unix: always `"&&"` (bash/zsh).
/// - Windows with pwsh or Git Bash: `"&&"` (both support pipeline chain
/// operators).
/// - Windows with powershell.exe (5.1) or cmd.exe: `";"`.
pub fn chain_separator() -> &'static str {
#[cfg(unix)]
{
"&&"
}
#[cfg(not(unix))]
{
if detect_windows_shell().supports_chain_operator() {
"&&"
} else {
";"
}
}
}
/// Whether `grep`, `head`, `tail`, `sed`, `awk`, `find` are usable from
/// the active shell. True on Unix and Windows + Git Bash; false on
/// Windows + PowerShell or `cmd.exe`.
///
/// Tool descriptions branch on this to swap Unix-centric guidance for
/// shell-aware guidance and avoid `'grep' is not recognized` failures.
pub fn has_unix_utilities() -> bool {
#[cfg(unix)]
{
true
}
#[cfg(not(unix))]
{
detect_windows_shell().has_unix_utilities()
}
}
/// Whether `name` resolves to an executable on the current `$PATH`.
///
/// Used by the truncated-MCP steer to name only tools that are actually present
/// on the tool server's `$PATH` (no "if available" hedge). `which` handles the
/// platform details (PATHEXT and App Execution Aliases on Windows).
///
/// Probes the base environment (tool server is co-located with the shell tool
/// in production). Per-session `export PATH` mutations inside the persistent
/// shell are not reflected (uncommon for `jq`/`python`/`sed`/`cut`).
pub fn is_command_available(name: &str) -> bool {
which::which(name).is_ok()
}
/// How a shell interprets a bare `&` token. Drives `run_terminal_cmd`
/// background-operator detection and remediation, which must differ per shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AmpersandSemantics {
/// Bash/POSIX: a bare `&` backgrounds the command (Unix shells, Git Bash).
PosixBackground,
/// PowerShell 7+ (`pwsh`): a *leading* `&` is the call/invocation operator;
/// a *trailing* `&` starts a background job.
PowerShellCore,
/// Windows PowerShell 5.1 (`powershell.exe`): a *leading* `&` is the call
/// operator; a *trailing* `&` is a parse error.
WindowsPowerShell,
/// `cmd.exe`: `&` is an unconditional sequential command separator.
CmdSeparator,
}
/// How the active shell interprets a bare `&`. Unix shells are always
/// [`AmpersandSemantics::PosixBackground`]; on Windows it depends on the
/// detected shell (Git Bash vs. PowerShell vs. `cmd.exe`).
pub fn ampersand_semantics() -> AmpersandSemantics {
#[cfg(unix)]
{
AmpersandSemantics::PosixBackground
}
#[cfg(not(unix))]
{
detect_windows_shell().ampersand_semantics()
}
}
/// How to invoke a command in the detected Windows shell.
#[cfg(not(unix))]
pub struct ShellInvocation {
pub program: String,
pub args: Vec<String>,
/// Env vars that must be set on the child process (e.g. `MSYS_NO_PATHCONV`
/// for Git Bash to prevent POSIX-to-Windows path translation of `/flags`).
pub env: Vec<(&'static str, &'static str)>,
}
/// Build `(program, args, env)` for running `command` in the detected shell.
#[cfg(not(unix))]
pub fn shell_command_argv(command: &str) -> ShellInvocation {
invocation_for(detect_windows_shell(), command)
}
/// Pure builder split out of `shell_command_argv` so tests can exercise every
/// `WindowsShell` variant, not just the one installed on the test host.
#[cfg(not(unix))]
fn invocation_for(shell: &WindowsShell, command: &str) -> ShellInvocation {
// Force UTF-8 for descendant tools. Windows' legacy ANSI codepage (cp1252)
// makes locale-sensitive children mis-decode UTF-8 subprocess output — e.g.
// Python's text-mode `subprocess` raised `UnicodeDecodeError` on `gh` output.
// `PYTHONUTF8=1` is the fix (forces `locale.getpreferredencoding` to utf-8);
// `PYTHONIOENCODING` covers the interpreter's own stdio, `surrogateescape`
// matching UTF-8 Mode's leniency. Applied before the per-request env, so an
// explicit caller value still overrides these defaults.
let utf8_env = [
("PYTHONUTF8", "1"),
("PYTHONIOENCODING", "utf-8:surrogateescape"),
];
match shell {
WindowsShell::GitBash(path) => ShellInvocation {
program: path.clone(),
args: vec!["-c".to_string(), command.to_string()],
// Disable MSYS2 POSIX-to-Windows path translation so `/flag`
// arguments (MSBuild /t:, cl.exe /nologo, etc.) pass through.
env: vec![
("MSYS_NO_PATHCONV", "1"),
("MSYS2_ARG_CONV_EXCL", "*"),
utf8_env[0],
utf8_env[1],
],
},
WindowsShell::Pwsh => ShellInvocation {
program: "pwsh".to_string(),
args: vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
env: utf8_env.to_vec(),
},
WindowsShell::PowerShell => ShellInvocation {
program: "powershell.exe".to_string(),
args: vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
env: utf8_env.to_vec(),
},
WindowsShell::Cmd => ShellInvocation {
program: "cmd".to_string(),
args: vec!["/C".to_string(), command.to_string()],
env: utf8_env.to_vec(),
},
}
}
// =============================================================================
// Unix shell resolution
// =============================================================================
//
// Locates an absolute path to a bash/zsh binary on Unix:
//
// 1. `$GROK_SHELL` override, if it names the requested kind and is runnable.
// 2. `$SHELL`, if it names the requested kind and is runnable.
// Covers most NixOS / Homebrew / `nix-darwin` setups where the user's
// login shell already lives at the resolved path (e.g.
// `/run/current-system/sw/bin/bash`, `/opt/homebrew/bin/bash`).
// 3. `which::which(name)` — walks `$PATH`. Catches NixOS profile shells in
// `/nix/store/...` or `/etc/profiles/per-user/<u>/bin/` when `/bin/bash`
// is absent.
// 4. A fixed candidate list: `{/bin, /usr/bin, /usr/local/bin,
// /opt/homebrew/bin} × {bash,zsh}`.
// 5. Hardcoded `/bin/<name>` — historical behavior, only reached when every
// earlier step has failed.
//
// The result is cached per kind in a process-wide `OnceLock`, so the cascade
// is run at most once per shell kind per process.
/// Which Unix shell we're asking about. Bash and zsh are the only kinds
/// supported by the persistent shell-state backend (the dump scripts are
/// bash/zsh-specific). Fish / dash / ksh users fall through to bash.
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnixShellKind {
Bash,
Zsh,
}
#[cfg(unix)]
impl UnixShellKind {
/// Binary file name (`"bash"` / `"zsh"`).
pub fn name(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
}
}
/// Hardcoded historical default. Only used as the last-resort fallback.
fn hardcoded_default(self) -> &'static str {
match self {
Self::Bash => "/bin/bash",
Self::Zsh => "/bin/zsh",
}
}
}
/// Detect the user's preferred Unix shell kind from `$SHELL`. Defaults to
/// `Bash` when `$SHELL` is unset or unrecognized. Cheap; not cached.
#[cfg(unix)]
pub fn detect_unix_shell_kind() -> UnixShellKind {
match std::env::var("SHELL") {
Ok(s) if s.contains("zsh") => UnixShellKind::Zsh,
_ => UnixShellKind::Bash,
}
}
/// Absolute path to the requested Unix shell binary, computed via the
/// cascade above. Cached for the process lifetime.
#[cfg(unix)]
pub fn unix_shell_path(kind: UnixShellKind) -> &'static str {
use std::sync::OnceLock;
static BASH: OnceLock<String> = OnceLock::new();
static ZSH: OnceLock<String> = OnceLock::new();
let cache = match kind {
UnixShellKind::Bash => &BASH,
UnixShellKind::Zsh => &ZSH,
};
cache.get_or_init(|| {
let path = resolve_unix_shell_path(kind);
tracing::debug!(kind = ?kind, resolved = %path, "resolved Unix shell path");
path
})
}
#[cfg(unix)]
fn resolve_unix_shell_path(kind: UnixShellKind) -> String {
let name = kind.name();
let matches_kind = |p: &std::path::Path| p.file_name().and_then(|n| n.to_str()) == Some(name);
// 1) Explicit override via $GROK_SHELL.
if let Ok(s) = std::env::var("GROK_SHELL") {
let p = std::path::PathBuf::from(&s);
if matches_kind(&p) && is_executable(&p) {
return s;
}
}
// 2) $SHELL, when it matches the requested kind.
if let Ok(s) = std::env::var("SHELL") {
let p = std::path::PathBuf::from(&s);
if matches_kind(&p) && is_executable(&p) {
return s;
}
}
// 3) `which` walks $PATH (handles NixOS, Homebrew, custom profiles).
if let Ok(p) = which::which(name)
&& is_executable(&p)
{
return p.to_string_lossy().into_owned();
}
// 4) Common install dirs.
for dir in ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] {
let p = std::path::PathBuf::from(dir).join(name);
if is_executable(&p) {
return p.to_string_lossy().into_owned();
}
}
// 5) Hardcoded fallback — same as historical behavior. Spawn will fail at
// runtime on a pure NixOS host with no bash, but that's no worse than
// before this resolver existed.
kind.hardcoded_default().to_string()
}
/// Whether `path` is an executable file.
///
/// First tries the file's mode bits (any-x). If that's inconclusive, falls
/// back to actually invoking `<path> --version`. The `--version` fallback
/// exists for Nix and other environments where the `X_OK` mode-bit check can
/// be misleading: some Nix overlay filesystems expose binaries whose
/// owner/group/world mode bits don't reflect their real executability.
///
/// The probe is spawned via `xai_tty_utils::detach_std_command` so that
/// the child does NOT inherit the parent's controlling TTY. The resolver
/// runs lazily inside `unix_shell_path`'s `OnceLock::get_or_init` which
/// can fire during interactive TUI/pager startup; without detach, a
/// misbehaving shell binary that emits mouse-tracking escapes or asks
/// for a controlling tty during `--version` would spew garbage onto the
/// pager screen. `stdin`, `stdout`, and `stderr` are pinned to `null`
/// to drop any output the binary does emit. See `codegen-conventions`
/// SKILL.md for the workspace-wide subprocess rule.
#[cfg(unix)]
fn is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
&& meta.permissions().mode() & 0o111 != 0
{
return true;
}
// Nix fallback. Detach from the controlling TTY via xai_tty_utils so
// the probe (which the resolver may run during interactive TUI/pager
// startup) cannot leak escapes onto the parent's terminal.
let mut cmd = std::process::Command::new(path);
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn has_unix_utilities_is_true_on_unix() {
assert!(has_unix_utilities());
}
#[cfg(unix)]
#[test]
fn chain_separator_is_ampersand_on_unix() {
assert_eq!(chain_separator(), "&&");
}
#[test]
fn is_command_available_detects_present_and_absent() {
// A shell present on every host of this OS resolves; a bogus name never
// does. `cmd` resolves via PATHEXT on Windows, `sh` lives on $PATH on Unix.
#[cfg(windows)]
let present = "cmd";
#[cfg(not(windows))]
let present = "sh";
assert!(is_command_available(present));
assert!(!is_command_available(
"xai-definitely-not-a-real-command-xyz"
));
}
#[cfg(unix)]
#[test]
fn ampersand_semantics_is_posix_background_on_unix() {
assert_eq!(ampersand_semantics(), AmpersandSemantics::PosixBackground);
}
#[cfg(unix)]
#[test]
fn unix_shell_path_returns_a_bash() {
// Whatever it returns, it must end in "bash" (the resolver guarantees
// the result's file_name matches the requested kind, even for the
// hardcoded `/bin/bash` fallback).
let p = unix_shell_path(UnixShellKind::Bash);
assert!(
std::path::Path::new(p).file_name().and_then(|n| n.to_str()) == Some("bash"),
"expected a path ending in 'bash', got {p}"
);
}
#[cfg(unix)]
#[test]
fn unix_shell_path_is_cached() {
// Two calls return the same `&'static str` (pointer equality).
let a = unix_shell_path(UnixShellKind::Bash);
let b = unix_shell_path(UnixShellKind::Bash);
assert!(
std::ptr::eq(a.as_ptr(), b.as_ptr()),
"result should be cached"
);
}
#[cfg(unix)]
#[test]
fn is_executable_recognizes_bin_sh() {
// /bin/sh is the one path POSIX promises across every Unix variant
// we care about; on macOS and Linux distros it's always executable.
// (Pure NixOS images may lack it, in which case this test is
// skipped — same approach as the existing `/bin/bash` gated tests.)
if !std::path::Path::new("/bin/sh").exists() {
return;
}
assert!(is_executable(std::path::Path::new("/bin/sh")));
}
#[cfg(unix)]
#[test]
fn is_executable_rejects_non_executable() {
let tmp = tempfile::NamedTempFile::new().unwrap();
// Mode bits explicitly cleared — not executable.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(!is_executable(tmp.path()));
}
#[cfg(unix)]
#[test]
fn detect_unix_shell_kind_falls_back_to_bash() {
// We can't safely mutate $SHELL in a multithreaded test runner, so just
// sanity-check the function returns *something* and doesn't panic.
let _ = detect_unix_shell_kind();
}
/// Only Git Bash bundles the Unix utilities; the PowerShell and cmd
/// variants do not.
#[cfg(not(unix))]
#[test]
fn has_unix_utilities_only_true_for_gitbash() {
assert!(
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into())
.has_unix_utilities()
);
assert!(!WindowsShell::Pwsh.has_unix_utilities());
assert!(!WindowsShell::PowerShell.has_unix_utilities());
assert!(!WindowsShell::Cmd.has_unix_utilities());
}
/// Git Bash backgrounds with a bare `&`; PowerShell uses `&` as the call
/// operator; `cmd.exe` uses it as a sequential separator.
#[cfg(not(unix))]
#[test]
fn ampersand_semantics_per_windows_shell() {
assert_eq!(
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into())
.ampersand_semantics(),
AmpersandSemantics::PosixBackground
);
assert_eq!(
WindowsShell::Pwsh.ampersand_semantics(),
AmpersandSemantics::PowerShellCore
);
assert_eq!(
WindowsShell::PowerShell.ampersand_semantics(),
AmpersandSemantics::WindowsPowerShell
);
assert_eq!(
WindowsShell::Cmd.ampersand_semantics(),
AmpersandSemantics::CmdSeparator
);
}
/// Every Windows shell variant injects the UTF-8 env defaults. Builds all
/// four variants directly so it doesn't depend on the test host's shell.
#[cfg(not(unix))]
#[test]
fn invocation_for_sets_utf8_env_on_every_variant() {
let variants = [
WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into()),
WindowsShell::Pwsh,
WindowsShell::PowerShell,
WindowsShell::Cmd,
];
for shell in &variants {
let inv = invocation_for(shell, "echo hi");
assert!(
inv.env.contains(&("PYTHONUTF8", "1")),
"expected PYTHONUTF8=1 in env for {shell:?}, got {:?}",
inv.env
);
assert!(
inv.env
.contains(&("PYTHONIOENCODING", "utf-8:surrogateescape")),
"expected PYTHONIOENCODING=utf-8:surrogateescape in env for {shell:?}, got {:?}",
inv.env
);
}
}
/// GitBash keeps its pre-existing MSYS2 path-translation guards in addition
/// to the UTF-8 defaults (the UTF-8 entries are appended, not replacing).
#[cfg(not(unix))]
#[test]
fn invocation_for_gitbash_keeps_msys_vars() {
let inv = invocation_for(
&WindowsShell::GitBash("C:\\Program Files\\Git\\bin\\bash.exe".into()),
"echo hi",
);
assert!(
inv.env.contains(&("MSYS_NO_PATHCONV", "1")),
"{:?}",
inv.env
);
assert!(
inv.env.contains(&("MSYS2_ARG_CONV_EXCL", "*")),
"{:?}",
inv.env
);
}
}

View file

@ -0,0 +1,413 @@
//! Ed25519-signed, identity-bound managed-policy envelope.
//!
//! The server signs a canonical payload (the served policy, the bound principal,
//! an expiry) with an Ed25519 private key; the client verifies it against a
//! compiled-in trusted key set (selected by the signed `key_id`, so keys can
//! rotate), binds it to the active principal, and checks the on-disk policy
//! matches the signed bytes — so an in-place edit is caught, not just a deletion.
//! Inert until a public key is provisioned: with no embedded keys the cache
//! marker stays the (best-effort) authority.
use base64::Engine;
pub use prod_mc_cli_chat_proxy_types::{SignatureEnvelope, SignedPayload, now_unix};
/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one
/// entry only during a rotation. Empty ships dark (see [`verification_active`]).
/// Compile-time, not an env flag: the local attacker controls their env.
pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[];
const _: () = {
let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS;
let mut i = 0;
while i < keys.len() {
assert!(
keys[i].1.len() == 32,
"every embedded key must be exactly 32 raw Ed25519 bytes"
);
assert!(
!keys[i].0.is_empty(),
"every embedded key id must be non-empty"
);
let mut j = i + 1;
while j < keys.len() {
assert!(
!const_str_eq(keys[i].0, keys[j].0),
"embedded key ids must be unique"
);
j += 1;
}
i += 1;
}
};
const fn const_str_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
/// Run `f` over the trusted key set — the compiled-in [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`],
/// unless the compile-time-excluded test seam overrides it.
fn with_embedded_keys<R>(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R {
f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS)
}
/// Sidecar persisted next to the policy so the load-time gate can re-verify it offline.
pub const SIGNATURE_SIDECAR_FILE: &str = "managed_config.sig.json";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SigError {
#[error("signature is not valid base64")]
BadSignatureEncoding,
#[error("signature does not verify against the provided public key")]
SignatureMismatch,
#[error("signed payload is not valid JSON")]
BadPayload,
#[error("signed payload names a key_id outside the trusted set")]
UnknownKeyId,
#[error("signed policy is bound to a different principal")]
PrincipalMismatch,
#[error("signed policy has expired")]
Expired,
#[error("on-disk {0} does not match the signed policy")]
ContentMismatch(&'static str),
/// The file exists but can't be read (EACCES etc. — never plain absence). Not
/// tamper evidence: callers refetch but don't refuse on a read blip.
#[error("on-disk {0} cannot be read")]
Unreadable(&'static str),
}
/// Whether the client must require + verify a signature — true iff the key set is
/// non-empty (no env toggle; see [`EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS`]).
pub fn verification_active() -> bool {
with_embedded_keys(|keys| !keys.is_empty())
}
/// Whether `key_id` names a trusted key. Only PICKS among served envelopes;
/// verification re-selects the key from the signed bytes, so a lying hint can at
/// most cause a verification failure.
pub fn embedded_key_id_trusted(key_id: &str) -> bool {
with_embedded_keys(|keys| keys.iter().any(|(id, _)| *id == key_id))
}
/// Verify `signature_b64` over `signed_payload` against `trusted_keys`, returning the
/// parsed payload. The verifying key is selected by the SIGNED payload's `key_id` —
/// safe to read pre-verification because selection can only land within the trusted
/// set (a forged id either misses or picks a key the signature won't match). Pure:
/// callers supply the keys so tests can use throwaway keypairs.
pub fn verify_signed_payload(
signed_payload: &str,
signature_b64: &str,
trusted_keys: &[(&str, &[u8])],
) -> Result<SignedPayload, SigError> {
let payload: SignedPayload =
serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?;
let (_, public_key) = trusted_keys
.iter()
.find(|(id, _)| *id == payload.key_id)
.ok_or(SigError::UnknownKeyId)?;
let sig = base64::engine::general_purpose::STANDARD
.decode(signature_b64.trim())
.map_err(|_| SigError::BadSignatureEncoding)?;
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(signed_payload.as_bytes(), &sig)
.map_err(|_| SigError::SignatureMismatch)?;
Ok(payload)
}
/// Fetch-time identity binding for a VERIFIED payload, expiry enforced: a
/// deployment-signed payload is trusted on signature alone; a team-signed payload
/// must match the active team. Lenient on a missing active team — an `auth.json`
/// read blip must not brick a session (a cross-team attacker has a team of their
/// own). The at-rest checks use [`signed_principal_matches`] instead.
pub fn check_fetch_identity(
payload: &SignedPayload,
active_team_id: Option<&str>,
now_unix: u64,
) -> Result<(), SigError> {
if now_unix > payload.expires_at {
return Err(SigError::Expired);
}
if payload.deployment_id.is_some() {
return Ok(());
}
if let (Some(signed), Some(active)) = (payload.team_id.as_deref(), active_team_id)
&& signed != active
{
return Err(SigError::PrincipalMismatch);
}
Ok(())
}
/// Whether the payload's effective principal (`deployment_id`, else `team_id`) matches
/// ours — the at-rest identity rule, so another tenant's cache reads foreign. Lenient
/// when either side is unknown. Deliberately expiry-free: the gate orders identity
/// BEFORE the `fail_closed` short-circuit and expiry after it (see [`SignedCacheFacts`]).
fn signed_principal_matches(payload: &SignedPayload, expected_principal: Option<&str>) -> bool {
let signed = payload
.deployment_id
.as_deref()
.or(payload.team_id.as_deref());
!matches!(
(signed, expected_principal), (Some(signed), Some(expected)) if signed !=
expected
)
}
/// Full verification of a fetched envelope against the embedded trusted keys
/// (signature, binding, expiry), returning the trusted payload to persist.
pub fn verify_fetched(
sidecar: &SignatureEnvelope,
active_team_id: Option<&str>,
now_unix: u64,
) -> Result<SignedPayload, SigError> {
with_embedded_keys(|keys| verify_fetched_with_keys(sidecar, keys, active_team_id, now_unix))
}
/// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs.
fn verify_fetched_with_keys(
sidecar: &SignatureEnvelope,
trusted_keys: &[(&str, &[u8])],
active_team_id: Option<&str>,
now_unix: u64,
) -> Result<SignedPayload, SigError> {
let payload = verify_signed_payload(&sidecar.signed_payload, &sidecar.signature, trusted_keys)?;
check_fetch_identity(&payload, active_team_id, now_unix)?;
Ok(payload)
}
/// True when something occupies `path` that is not a regular file — directory,
/// symlink, fifo, … NO-FOLLOW, so even a symlink to a byte-identical file counts:
/// a squatter blocks or redirects reads/rewrites, which is tamper, never a blip.
/// The clearing side stays no-follow too (a symlink squat is removed as the link).
fn non_regular_file_at(path: &std::path::Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_file())
}
/// Confirm the on-disk artifacts match the signed payload byte-for-byte — an in-place
/// edit is caught, not just a deletion. A signed-ABSENT slot must be empty on disk: a
/// locally planted `requirements.toml` (the highest-precedence layer) is tamper, not
/// noise. An unreadable file is [`SigError::Unreadable`] (refetch, don't refuse — a
/// read blip); anything non-regular squatting the slot ([`non_regular_file_at`])
/// reads as tamper.
pub fn check_on_disk_matches(
home: &std::path::Path,
payload: &SignedPayload,
) -> Result<(), SigError> {
for (name, label, signed) in [
(
"managed_config.toml",
"managed_config",
payload.managed_config.as_deref(),
),
(
"requirements.toml",
"requirements",
payload.requirements.as_deref(),
),
] {
let path = home.join(name);
if non_regular_file_at(&path) {
return Err(SigError::ContentMismatch(label));
}
let on_disk = match std::fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => return Err(SigError::Unreadable(label)),
};
let matches = match signed.filter(|s| !s.is_empty()) {
Some(signed) => on_disk.as_deref() == Some(signed),
None => on_disk.as_deref().is_none_or(str::is_empty),
};
if !matches {
return Err(SigError::ContentMismatch(label));
}
}
Ok(())
}
pub(crate) fn sidecar_path(home: &std::path::Path) -> std::path::PathBuf {
home.join(SIGNATURE_SIDECAR_FILE)
}
/// Outcome of reading the on-disk sidecar; mirrors the artifact-slot semantics of
/// [`check_on_disk_matches`].
enum SidecarRead {
Present(SignatureEnvelope),
/// NotFound, unparseable JSON, or a squatting non-regular file (directory,
/// symlink, …) — not an authentic sidecar.
Absent,
/// EACCES-style transient failure on a regular file — not tamper evidence: the
/// gate must not refuse on it, but the refetch trigger fires to self-heal.
Unreadable,
}
fn read_sidecar(home: &std::path::Path) -> SidecarRead {
let path = sidecar_path(home);
if non_regular_file_at(&path) {
return SidecarRead::Absent;
}
let json = match std::fs::read_to_string(&path) {
Ok(json) => json,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SidecarRead::Absent,
Err(_) => return SidecarRead::Unreadable,
};
match serde_json::from_str(&json) {
Ok(sidecar) => SidecarRead::Present(sidecar),
Err(_) => SidecarRead::Absent,
}
}
/// Persist the sidecar atomically — a torn sidecar would fail the load-time gate.
/// Written 0600 on unix: for a deployment-key principal the signed payload embeds
/// the key, so the sidecar is a second at-rest copy of a bearer credential.
pub fn write_sidecar(home: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> {
let json = serde_json::to_string(sidecar)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::fs_atomic::write_atomically(&sidecar_path(home), &json, Some(0o600))
}
/// True when signature verification is active AND a cloud-cache policy on disk is
/// NOT covered by a valid, in-date, identity-bound, content-matching signature.
/// Dark build or no policy on disk → false (behavior unchanged until a key ships).
pub fn cloud_cache_signature_invalid(
home: &std::path::Path,
expected_principal: Option<&str>,
now_unix: u64,
) -> bool {
if !verification_active() {
return false;
}
with_embedded_keys(|keys| {
cloud_cache_signature_invalid_with_keys(home, keys, expected_principal, now_unix)
})
}
/// Key-injected core of [`cloud_cache_signature_invalid`] so tests can supply throwaway keys.
fn cloud_cache_signature_invalid_with_keys(
home: &std::path::Path,
trusted_keys: &[(&str, &[u8])],
expected_principal: Option<&str>,
now_unix: u64,
) -> bool {
let has_policy =
home.join("requirements.toml").exists() || home.join("managed_config.toml").exists();
if !has_policy {
return false;
}
use SignedCacheEvaluation as Eval;
match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
Eval::NoAuthenticSidecar | Eval::SidecarUnreadable => true,
Eval::Facts(f) => !f.identity_ok || f.expired || f.disk != DiskStatus::Match,
}
}
/// On-disk status of the signed artifact slots, from [`check_on_disk_matches`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiskStatus {
/// Every slot matches the signed payload (content and absence).
Match,
/// Tamper: edited, deleted-while-signed, planted, or a squatting non-file.
Mismatch,
/// A read blip (EACCES on a regular file) — stale for the refetch, lenient at
/// the gate.
Unreadable,
}
/// What one verification pass over the on-disk sidecar establishes. The two public
/// checks are projections over the same facts: the refetch trigger flags ANY
/// deviation; the gate applies the fail-closed rules.
struct SignedCacheFacts {
/// The payload's effective principal matches ours ([`signed_principal_matches`]).
identity_ok: bool,
expired: bool,
/// The SIGNED opt-in — read from the payload, never the forgeable marker.
fail_closed: bool,
disk: DiskStatus,
}
/// One evaluation of the on-disk sidecar; both public checks project from this.
enum SignedCacheEvaluation {
/// No authentic sidecar: missing, corrupt, a squatting non-file, forged, or
/// keyed outside the trusted set — never facts from unverified bytes.
NoAuthenticSidecar,
/// The sidecar exists but a transient IO error blocked the read
/// ([`SidecarRead::Unreadable`]) — nothing verified, nothing tamper-shaped.
SidecarUnreadable,
Facts(SignedCacheFacts),
}
/// Read the sidecar, verify it against `trusted_keys`, reduce to a [`SignedCacheEvaluation`].
fn evaluate_signed_cache(
home: &std::path::Path,
trusted_keys: &[(&str, &[u8])],
expected_principal: Option<&str>,
now_unix: u64,
) -> SignedCacheEvaluation {
let sidecar = match read_sidecar(home) {
SidecarRead::Present(sidecar) => sidecar,
SidecarRead::Absent => return SignedCacheEvaluation::NoAuthenticSidecar,
SidecarRead::Unreadable => return SignedCacheEvaluation::SidecarUnreadable,
};
let Ok(payload) =
verify_signed_payload(&sidecar.signed_payload, &sidecar.signature, trusted_keys)
else {
return SignedCacheEvaluation::NoAuthenticSidecar;
};
SignedCacheEvaluation::Facts(SignedCacheFacts {
identity_ok: signed_principal_matches(&payload, expected_principal),
expired: now_unix > payload.expires_at,
fail_closed: payload.fail_closed,
disk: match check_on_disk_matches(home, &payload) {
Ok(()) => DiskStatus::Match,
Err(SigError::Unreadable(_)) => DiskStatus::Unreadable,
Err(_) => DiskStatus::Mismatch,
},
})
}
/// Verdict of the signed-sidecar check for the load-time gate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignedVerdict {
/// Verification is not active (no embedded keys — the dark build): the marker is
/// the only signal. A distinct variant, not an `Option`, so a dark build can never
/// be confused with [`Self::NoAuthenticSidecar`], whose absence rule must never
/// fire keyless.
Inactive,
/// No sidecar, or one whose signature doesn't verify — not an authentic verdict.
/// Under a fail-closed marker that recorded served policy, absence is itself
/// tamper: stripping the sidecar must not downgrade enforcement to the forgeable
/// marker path (a first keyed launch over a pre-signing cache also refuses until
/// one online refetch writes it — deliberate). Residual: wiping the marker with
/// the sidecar — inherent to user-writable state, covered by the root-owned
/// /etc/grok and MDM layers. Otherwise the marker decides.
NoAuthenticSidecar,
/// The sidecar exists but a transient IO error (EACCES-style, never plain absence
/// or a squatting non-file) blocked the read. Not tamper evidence: the gate falls
/// back to the marker decision, and the refetch trigger fires to rewrite it.
SidecarUnreadable,
/// Authentic sidecar; the policy is valid for this principal (or never opted into
/// fail-closed enforcement).
Trusted,
/// Authentic sidecar proving an opted-in policy is no longer valid here: edited on
/// disk, expired, or bound to a different principal. Refuse — always.
Compromised,
}
/// The signed verdict for the on-disk cache; see [`SignedVerdict`]. The fail-closed
/// opt-in is read from the SIGNED bytes, not the forgeable marker. `expected_principal`
/// is the machine's managed principal (active team id, or the recorded deployment id);
/// a payload bound elsewhere is a cross-tenant replay and reads compromised.
pub fn signed_cache_compromised(
home: &std::path::Path,
expected_principal: Option<&str>,
now_unix: u64,
) -> SignedVerdict {
if !verification_active() {
return SignedVerdict::Inactive;
}
with_embedded_keys(|keys| {
signed_cache_compromised_with_keys(home, keys, expected_principal, now_unix)
})
}
/// Key-injected core of [`signed_cache_compromised`] so tests can supply throwaway keys.
fn signed_cache_compromised_with_keys(
home: &std::path::Path,
trusted_keys: &[(&str, &[u8])],
expected_principal: Option<&str>,
now_unix: u64,
) -> SignedVerdict {
use SignedCacheEvaluation as Eval;
match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
Eval::NoAuthenticSidecar => SignedVerdict::NoAuthenticSidecar,
Eval::SidecarUnreadable => SignedVerdict::SidecarUnreadable,
Eval::Facts(f) if !f.identity_ok => SignedVerdict::Compromised,
Eval::Facts(f) if !f.fail_closed => SignedVerdict::Trusted,
Eval::Facts(f) if f.expired || f.disk == DiskStatus::Mismatch => SignedVerdict::Compromised,
Eval::Facts(_) => SignedVerdict::Trusted,
}
}
#[cfg(test)]
#[path = "signed_policy/tests.rs"]
mod tests;

View file

@ -0,0 +1,958 @@
use super::*;
use ring::signature::KeyPair;
fn test_keypair() -> (ring::signature::Ed25519KeyPair, Vec<u8>) {
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let pubkey = kp.public_key().as_ref().to_vec();
(kp, pubkey)
}
fn keyset<'a>(id: &'a str, pubkey: &'a [u8]) -> Vec<(&'a str, &'a [u8])> {
vec![(id, pubkey)]
}
fn sign(kp: &ring::signature::Ed25519KeyPair, payload: &SignedPayload) -> SignatureEnvelope {
let signed_payload = serde_json::to_string(payload).unwrap();
let sig = kp.sign(signed_payload.as_bytes());
SignatureEnvelope {
signed_payload,
signature: base64::engine::general_purpose::STANDARD.encode(sig.as_ref()),
key_id: payload.key_id.clone(),
}
}
fn payload() -> SignedPayload {
SignedPayload {
version: 1,
deployment_id: None,
team_id: Some("team-007".into()),
managed_config: Some("[cli]\ntheme = \"dark\"\n".into()),
requirements: Some("[features]\nweb_fetch = false\n".into()),
fail_closed: false,
expires_at: 4_000_000_000,
key_id: "v1".into(),
}
}
fn write_policy(home: &std::path::Path, p: &SignedPayload) {
std::fs::write(
home.join("managed_config.toml"),
p.managed_config.as_ref().unwrap(),
)
.unwrap();
std::fs::write(
home.join("requirements.toml"),
p.requirements.as_ref().unwrap(),
)
.unwrap();
}
/// Pins the wire contract: a raw server-shaped JSON payload must verify and parse here.
#[test]
fn server_wire_format_is_client_verifiable() {
let (kp, pubkey) = test_keypair();
let signed_payload = serde_json::json!({
"deployment_id": serde_json::Value::Null,
"team_id": "team-007",
"managed_config": "[cli]\n",
"requirements": "[features]\n",
"fail_closed": true,
"expires_at": 4_000_000_000u64,
"key_id": "v1",
})
.to_string();
let sig = kp.sign(signed_payload.as_bytes());
let sidecar = SignatureEnvelope {
signed_payload,
signature: base64::engine::general_purpose::STANDARD.encode(sig.as_ref()),
key_id: "v1".into(),
};
let payload =
verify_fetched_with_keys(&sidecar, &keyset("v1", &pubkey), Some("team-007"), 1_000)
.expect("server format must verify");
assert_eq!(payload.team_id.as_deref(), Some("team-007"));
assert_eq!(payload.requirements.as_deref(), Some("[features]\n"));
assert!(payload.fail_closed);
}
/// A payload missing `fail_closed` (an older server) parses lenient — the field is additive.
#[test]
fn missing_fail_closed_defaults_false() {
let (kp, pubkey) = test_keypair();
let signed_payload = serde_json::json!({
"team_id": "team-007",
"expires_at": 4_000_000_000u64,
"key_id": "v1",
})
.to_string();
let sig = kp.sign(signed_payload.as_bytes());
let sidecar = SignatureEnvelope {
signed_payload,
signature: base64::engine::general_purpose::STANDARD.encode(sig.as_ref()),
key_id: "v1".into(),
};
let payload =
verify_fetched_with_keys(&sidecar, &keyset("v1", &pubkey), Some("team-007"), 1_000)
.expect("must verify");
assert!(!payload.fail_closed);
}
#[test]
fn valid_signature_round_trips() {
let (kp, pubkey) = test_keypair();
let sidecar = sign(&kp, &payload());
let out = verify_signed_payload(
&sidecar.signed_payload,
&sidecar.signature,
&keyset("v1", &pubkey),
)
.expect("valid signature must verify");
assert_eq!(out, payload());
}
#[test]
fn tampered_payload_fails() {
let (kp, pubkey) = test_keypair();
let mut sidecar = sign(&kp, &payload());
// Flip a byte in the signed payload (an attacker editing the policy).
sidecar.signed_payload = sidecar.signed_payload.replace("dark", "evil");
assert_eq!(
verify_signed_payload(
&sidecar.signed_payload,
&sidecar.signature,
&keyset("v1", &pubkey)
),
Err(SigError::SignatureMismatch)
);
}
#[test]
fn wrong_key_fails() {
let (kp, _) = test_keypair();
let (_, other_pubkey) = test_keypair();
let sidecar = sign(&kp, &payload());
assert_eq!(
verify_signed_payload(
&sidecar.signed_payload,
&sidecar.signature,
&keyset("v1", &other_pubkey)
),
Err(SigError::SignatureMismatch)
);
}
/// Pins where the fetch binding ([`check_fetch_identity`], expiry enforced, deployment
/// trusted on signature alone) diverges from the at-rest rule
/// ([`signed_principal_matches`], strict effective-principal equality, expiry-free).
#[test]
fn binding_rejects_other_team_and_expiry() {
let p = payload();
assert_eq!(
check_fetch_identity(&p, Some("team-evil"), 1_000),
Err(SigError::PrincipalMismatch)
);
assert_eq!(
check_fetch_identity(&p, Some("team-007"), p.expires_at + 1),
Err(SigError::Expired)
);
assert!(check_fetch_identity(&p, Some("team-007"), 1_000).is_ok());
// No resolvable active team is lenient (an auth.json read blip must not brick a session).
assert!(check_fetch_identity(&p, None, 1_000).is_ok());
// A DEPLOYMENT-signed policy is accepted even when the active team differs.
let dep = SignedPayload {
deployment_id: Some("dep-1".into()),
team_id: Some("team-other".into()),
..payload()
};
assert!(check_fetch_identity(&dep, Some("team-007"), 1_000).is_ok());
// The at-rest rule instead requires the effective principal (its deployment_id)...
assert!(signed_principal_matches(&dep, Some("dep-1")));
assert!(!signed_principal_matches(&dep, Some("team-007")));
// ...and is expiry-free: an expired payload still matches, while the fetch binding rejects it.
let expired = SignedPayload {
expires_at: 10,
..payload()
};
assert!(signed_principal_matches(&expired, Some("team-007")));
assert_eq!(
check_fetch_identity(&expired, Some("team-007"), 1_000),
Err(SigError::Expired)
);
}
#[test]
fn on_disk_content_must_match_signed() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let p = payload();
write_policy(home, &p);
assert!(check_on_disk_matches(home, &p).is_ok());
// Editing the enforced file is caught even though it still exists.
std::fs::write(
home.join("requirements.toml"),
"[features]\nweb_fetch = true\n",
)
.unwrap();
assert_eq!(
check_on_disk_matches(home, &p),
Err(SigError::ContentMismatch("requirements"))
);
}
/// A locally planted file in a signed-ABSENT slot is tamper on both the refetch and
/// gate paths; an absent or empty on-disk file is clean.
#[test]
fn planted_artifact_in_signed_absent_slot_is_tamper() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
requirements: None,
fail_closed: true,
..payload()
};
std::fs::write(
home.join("managed_config.toml"),
p.managed_config.as_ref().unwrap(),
)
.unwrap();
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Absent → clean; empty file → clean (some tooling touches empty files).
assert!(check_on_disk_matches(home, &p).is_ok());
std::fs::write(home.join("requirements.toml"), "").unwrap();
assert!(check_on_disk_matches(home, &p).is_ok());
// Planted non-empty requirements → tamper on both paths.
std::fs::write(home.join("requirements.toml"), "[endpoints]\n").unwrap();
assert_eq!(
check_on_disk_matches(home, &p),
Err(SigError::ContentMismatch("requirements"))
);
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised
);
// A signed EMPTY artifact ("" in the payload) binds absence the same way.
let p_empty = SignedPayload {
requirements: Some(String::new()),
..p
};
assert_eq!(
check_on_disk_matches(home, &p_empty),
Err(SigError::ContentMismatch("requirements"))
);
}
/// An unreadable regular file (EACCES) is a read blip, not tamper: the refetch trigger
/// fires but the gate does not refuse. Unix-only; self-skips when running as root.
#[cfg(unix)]
#[test]
fn unreadable_artifact_refetches_but_does_not_refuse() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
let path = home.join("requirements.toml");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&path).is_ok() {
eprintln!("skipping: permissions not enforced (running as root?)");
return;
}
assert_eq!(
check_on_disk_matches(home, &p),
Err(SigError::Unreadable("requirements"))
);
assert!(
cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
),
"an unreadable artifact must trigger a refetch"
);
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Trusted,
"an unreadable artifact must not refuse at the gate"
);
}
/// A directory squatting in an artifact slot is tamper, not a read blip: the gate
/// refuses and the refetch fires.
#[test]
fn directory_squat_is_tamper_not_unreadable() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
std::fs::remove_file(home.join("requirements.toml")).unwrap();
std::fs::create_dir(home.join("requirements.toml")).unwrap();
assert_eq!(
check_on_disk_matches(home, &p),
Err(SigError::ContentMismatch("requirements"))
);
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised,
"a directory squat must read compromised at the gate"
);
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
}
/// A read blip on the SIDECAR mirrors the artifact-slot semantics: SidecarUnreadable
/// at the gate (no refusal) while the refetch trigger fires. Unix-only; self-skips as root.
#[cfg(unix)]
#[test]
fn sidecar_read_blip_is_lenient_at_gate_but_refetches() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
let path = home.join(SIGNATURE_SIDECAR_FILE);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&path).is_ok() {
eprintln!("skipping: permissions not enforced (running as root?)");
return;
}
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::SidecarUnreadable,
"a sidecar read blip is not tamper — it must not read NoAuthenticSidecar"
);
assert!(
cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
),
"the blip must trigger a refetch"
);
}
/// A symlink at an artifact slot is tamper (no-follow classification): even one
/// pointing at a byte-identical file (reads could be redirected later), and one
/// pointing at a directory — never the lenient Unreadable.
#[cfg(unix)]
#[test]
fn symlink_at_artifact_slot_is_tamper() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Symlink → file carrying the exact signed bytes.
let slot = home.join("requirements.toml");
let target = home.join("elsewhere.toml");
std::fs::rename(&slot, &target).unwrap();
std::os::unix::fs::symlink(&target, &slot).unwrap();
assert_eq!(
check_on_disk_matches(home, &p),
Err(SigError::ContentMismatch("requirements")),
"a matching-content symlink is still tamper"
);
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised
);
// Symlink → directory.
std::fs::remove_file(&slot).unwrap();
let squat_dir = home.join("squat_dir");
std::fs::create_dir(&squat_dir).unwrap();
std::os::unix::fs::symlink(&squat_dir, &slot).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised
);
}
/// A symlink at the SIDECAR slot reads NoAuthenticSidecar (absence-with-teeth under a
/// fail-closed marker), never the lenient SidecarUnreadable — even one pointing at a
/// perfectly valid sidecar file, and one pointing at a directory.
#[cfg(unix)]
#[test]
fn symlink_at_sidecar_slot_is_absence_not_a_blip() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Symlink → a byte-identical valid sidecar elsewhere.
let path = home.join(SIGNATURE_SIDECAR_FILE);
let target = home.join("sidecar_copy.json");
std::fs::rename(&path, &target).unwrap();
std::os::unix::fs::symlink(&target, &path).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar,
"a symlinked sidecar is not an authentic sidecar"
);
// Symlink → directory.
std::fs::remove_file(&path).unwrap();
let squat_dir = home.join("squat_dir");
std::fs::create_dir(&squat_dir).unwrap();
std::os::unix::fs::symlink(&squat_dir, &path).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar
);
}
/// A directory squatting at the SIDECAR path reads NoAuthenticSidecar, never SidecarUnreadable.
#[test]
fn sidecar_directory_squat_is_absence_not_a_blip() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
let path = home.join(SIGNATURE_SIDECAR_FILE);
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar
);
}
#[test]
fn sidecar_round_trips_on_disk() {
let dir = tempfile::tempdir().unwrap();
let (kp, _) = test_keypair();
let sidecar = sign(&kp, &payload());
write_sidecar(dir.path(), &sidecar).unwrap();
let SidecarRead::Present(read) = read_sidecar(dir.path()) else {
panic!("a just-written sidecar must read back Present");
};
assert_eq!(read.signed_payload, sidecar.signed_payload);
assert_eq!(read.signature, sidecar.signature);
}
#[test]
fn verification_inert_without_embedded_key() {
// The feature ships dark: no compiled-in key, no verification.
assert!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS.is_empty());
assert!(!verification_active());
}
/// Dark build: the public gate is false even with a policy on disk and no sidecar.
#[test]
fn cloud_cache_signature_invalid_is_false_when_dark() {
let dir = tempfile::tempdir().unwrap();
write_policy(dir.path(), &payload());
assert!(!verification_active());
assert!(!cloud_cache_signature_invalid(
dir.path(),
Some("team-007"),
1_000
));
}
/// No policy on disk → nothing to verify → not invalid.
#[test]
fn cloud_cache_signature_invalid_is_false_when_no_policy() {
let dir = tempfile::tempdir().unwrap();
let (_, pubkey) = test_keypair();
assert!(!cloud_cache_signature_invalid_with_keys(
dir.path(),
&keyset("v1", &pubkey),
Some("team-007"),
1_000,
));
}
/// Keyed: a policy with no/edited signature is invalid; a fully covered one is not.
#[test]
fn cloud_cache_signature_invalid_detects_missing_and_edited() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = payload();
write_policy(home, &p);
// Policy present, no sidecar → invalid.
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
// Valid sidecar + matching files → valid.
write_sidecar(home, &sign(&kp, &p)).unwrap();
assert!(!cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
// Wrong active team → invalid (substituted cache / cross-team replay).
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-evil"),
1_000
));
// Editing a present, signed file → invalid (in-place tamper).
std::fs::write(
home.join("requirements.toml"),
"[features]\nweb_fetch = true\n",
)
.unwrap();
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
}
/// The refetch trigger flags a signature-authentic but FOREIGN-bound cache as stale
/// (the cross-tenant replay the gate blocks must also rebind online).
#[test]
fn cloud_cache_signature_invalid_flags_foreign_authentic_cache() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let team = payload();
write_policy(home, &team);
write_sidecar(home, &sign(&kp, &team)).unwrap();
assert!(!cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
));
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-999"),
1_000
));
let deploy = SignedPayload {
deployment_id: Some("deploy-A".into()),
team_id: None,
..payload()
};
write_policy(home, &deploy);
write_sidecar(home, &sign(&kp, &deploy)).unwrap();
assert!(!cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("deploy-A"),
1_000
));
assert!(cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("deploy-B"),
1_000
));
}
/// A signed opt-in over an in-place-edited policy reads compromised — regardless of
/// the (forgeable) marker, which this verdict never consults.
#[test]
fn signed_cache_compromised_honors_signed_opt_in() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Opted in + bound + intact → not compromised.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Trusted
);
// In-place edit of the signed file → compromised.
std::fs::write(
home.join("requirements.toml"),
"[features]\nweb_fetch = true\n",
)
.unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised
);
}
/// A signed OPT-OUT is never enforced: even an edited policy reads `Trusted` at the
/// gate (the refetch trigger still catches the edit).
#[test]
fn signed_cache_compromised_respects_signed_opt_out() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = payload(); // fail_closed = false
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
std::fs::write(
home.join("requirements.toml"),
"[features]\nweb_fetch = true\n",
)
.unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Trusted
);
}
/// No sidecar or a forged signature reads `NoAuthenticSidecar` — never `Compromised`
/// from unverified bytes.
#[test]
fn signed_cache_compromised_none_without_authentic_sidecar() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
// No sidecar → not an authentic verdict.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar
);
// Forged signature → not an authentic verdict either (never Compromised).
let mut bad = sign(&kp, &p);
bad.signature = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
write_sidecar(home, &bad).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar
);
}
/// A deployment-signed policy bound to a different deployment id is a cross-tenant
/// replay and reads compromised; a matching id (or no recorded id yet) does not.
#[test]
fn signed_cache_compromised_rejects_foreign_deployment() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
deployment_id: Some("dep-foreign".into()),
team_id: None,
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Locally-recorded deployment is "dep-local" → foreign signed id → compromised.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("dep-local"), 1_000),
SignedVerdict::Compromised
);
// Matching deployment id → not compromised.
assert_eq!(
signed_cache_compromised_with_keys(
home,
&keyset("v1", &pubkey),
Some("dep-foreign"),
1_000
),
SignedVerdict::Trusted
);
// No locally-recorded id yet (first trusted fetch) → lenient.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), None, 1_000),
SignedVerdict::Trusted
);
}
/// A replayed TEAM-signed cache on a deployment-key machine reads foreign rather than
/// slipping past a deployment-only check.
#[test]
fn signed_cache_compromised_rejects_team_cache_on_deployment_machine() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let team = SignedPayload {
deployment_id: None,
team_id: Some("team-x".into()),
fail_closed: true,
..payload()
};
write_policy(home, &team);
write_sidecar(home, &sign(&kp, &team)).unwrap();
// Expected principal = the machine's recorded deployment id.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("dep-local"), 1_000),
SignedVerdict::Compromised,
"a team-signed cache on a deployment machine must read foreign"
);
}
/// A foreign-bound but PERMISSIVE policy still reads compromised: identity runs BEFORE
/// the opt-in short-circuit, so another tenant's lenient policy can't escape a strict one.
#[test]
fn signed_cache_compromised_rejects_foreign_permissive_policy() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
// Permissive policy bound to a FOREIGN deployment.
let dep = SignedPayload {
deployment_id: Some("dep-foreign".into()),
team_id: None,
fail_closed: false,
..payload()
};
write_policy(home, &dep);
write_sidecar(home, &sign(&kp, &dep)).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("dep-local"), 1_000),
SignedVerdict::Compromised,
"a foreign permissive deployment policy must be rejected, not short-circuited"
);
// Permissive policy bound to a FOREIGN team.
let other_team = SignedPayload {
deployment_id: None,
team_id: Some("team-evil".into()),
fail_closed: false,
..payload()
};
write_policy(home, &other_team);
write_sidecar(home, &sign(&kp, &other_team)).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Compromised,
"a foreign permissive team policy must be rejected"
);
// OUR OWN permissive policy is fine (not compromised).
let ours = SignedPayload {
deployment_id: None,
team_id: Some("team-007".into()),
fail_closed: false,
..payload()
};
write_policy(home, &ours);
write_sidecar(home, &sign(&kp, &ours)).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::Trusted,
"our own permissive policy is not compromised"
);
}
/// Dark build: the public entry reads Inactive even with an authentic, opted-in,
/// tampered cache on disk — the marker path then decides.
#[test]
fn signed_cache_compromised_is_inactive_when_dark() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, _) = test_keypair();
let p = SignedPayload {
fail_closed: true,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
std::fs::write(
home.join("requirements.toml"),
"[features]\nweb_fetch = true\n",
)
.unwrap();
assert!(!verification_active());
assert_eq!(
signed_cache_compromised(home, Some("team-007"), 1_000),
SignedVerdict::Inactive
);
}
/// Anti-rollback TTL: an authentic opted-in sidecar reads compromised past its signed
/// `expires_at` even with intact content and a matching principal; inside the window it holds.
#[test]
fn signed_cache_compromised_expired_reads_compromised() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
fail_closed: true,
expires_at: 1_000,
..payload()
};
write_policy(home, &p);
write_sidecar(home, &sign(&kp, &p)).unwrap();
// Past expiry → compromised, despite intact content + matching principal.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_001),
SignedVerdict::Compromised,
"an expired authentic sidecar must read compromised (anti-rollback TTL)"
);
// Just inside the window → honored.
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 999),
SignedVerdict::Trusted
);
}
/// Rewriting the untrusted outer `sidecar.key_id` can't redirect verification —
/// only the SIGNED payload's `key_id` selects the verifying key.
#[test]
fn untrusted_sidecar_key_id_does_not_affect_verification() {
let (kp, pubkey) = test_keypair();
let mut sidecar = sign(&kp, &payload());
sidecar.key_id = "attacker-controlled-key".into();
let out = verify_signed_payload(
&sidecar.signed_payload,
&sidecar.signature,
&keyset("v1", &pubkey),
)
.expect("the untrusted outer key_id must not affect verification");
assert_eq!(out.key_id, "v1", "only the signed key_id is authoritative");
}
/// A SIGNED `key_id` outside the trusted set is rejected, and the sidecar reads
/// inauthentic on both the gate and refetch paths.
#[test]
fn unknown_signed_key_id_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
key_id: "v9".into(),
fail_closed: true,
..payload()
};
let sidecar = sign(&kp, &p);
assert_eq!(
verify_signed_payload(
&sidecar.signed_payload,
&sidecar.signature,
&keyset("v1", &pubkey)
),
Err(SigError::UnknownKeyId)
);
write_policy(home, &p);
write_sidecar(home, &sidecar).unwrap();
assert_eq!(
signed_cache_compromised_with_keys(home, &keyset("v1", &pubkey), Some("team-007"), 1_000),
SignedVerdict::NoAuthenticSidecar,
"an unknown key id is not an authentic verdict"
);
assert!(
cloud_cache_signature_invalid_with_keys(
home,
&keyset("v1", &pubkey),
Some("team-007"),
1_000
),
"an unknown key id must trigger a refetch"
);
}
/// Rotation: a {v1, v2} client verifies a payload signed with either key; a payload
/// CLAIMING a trusted id but signed with a different key still fails.
#[test]
fn rotation_selects_the_trusted_key_by_signed_key_id() {
let (kp1, pubkey1) = test_keypair();
let (kp2, pubkey2) = test_keypair();
let both: Vec<(&str, &[u8])> = vec![("v1", &pubkey1), ("v2", &pubkey2)];
let v1 = sign(&kp1, &payload());
let v2_payload = SignedPayload {
key_id: "v2".into(),
..payload()
};
let v2 = sign(&kp2, &v2_payload);
assert!(verify_signed_payload(&v1.signed_payload, &v1.signature, &both).is_ok());
let out = verify_signed_payload(&v2.signed_payload, &v2.signature, &both)
.expect("the v2-signed payload must verify against the rotated set");
assert_eq!(out.key_id, "v2");
// A client that has dropped v1 (only-v2 set) still verifies the v2 envelope
// and rejects the v1 one.
let only_v2: Vec<(&str, &[u8])> = vec![("v2", &pubkey2)];
assert!(verify_signed_payload(&v2.signed_payload, &v2.signature, &only_v2).is_ok());
assert_eq!(
verify_signed_payload(&v1.signed_payload, &v1.signature, &only_v2),
Err(SigError::UnknownKeyId)
);
// Claiming v1 while signed with kp2 picks the v1 key — and fails to verify.
let imposter = SignatureEnvelope {
signed_payload: v1.signed_payload.clone(),
signature: v2.signature.clone(),
key_id: "v1".into(),
};
assert_eq!(
verify_signed_payload(&imposter.signed_payload, &imposter.signature, &both),
Err(SigError::SignatureMismatch)
);
}

View file

@ -0,0 +1,467 @@
//! Requirements layers and fail-closed enforcement.
use std::path::{Path, PathBuf};
use crate::env_bool;
use crate::loader::{apply_version_overrides_with_registered, load_toml_file};
use crate::paths::{system_config_dir, user_grok_home};
use crate::version_overrides::{VersionOverrideError, apply_version_overrides};
use prod_mc_cli_chat_proxy_types::FAIL_CLOSED_KEY;
/// The canonical opt-in key + string parse live in the shared types crate, next to
/// the signed payload that carries the flag, so the server-side signer and this
/// client parse the same semantics.
pub use prod_mc_cli_chat_proxy_types::fail_closed_flag_from_str;
/// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as
/// [`fail_closed_flag_from_str`]. Env tightening (file vs `GROK_MANAGED_CONFIG_FAIL_CLOSED`)
/// is layered on top by [`resolve_fail_closed_mode`], not here.
fn fail_closed_flag(requirements: &toml::Value) -> bool {
requirements
.get(FAIL_CLOSED_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Env override for [`FAIL_CLOSED_KEY`]. Named for prefix-alignment
/// with `GROK_MANAGED_CONFIG_URL`; only applies to `requirements.toml`.
pub(crate) const FAIL_CLOSED_ENV: &str = "GROK_MANAGED_CONFIG_FAIL_CLOSED";
/// Where a requirements layer came from: a file on disk, or the macOS MDM
/// managed-preferences layer (admin-forced, no file). The typed split keeps a
/// caller from `exists()`/reading a layer that has no path.
#[derive(Debug, Clone)]
pub enum RequirementsSource {
File(PathBuf),
Mdm,
}
impl RequirementsSource {
/// Display/provenance label — a file path string, or the synthetic MDM source
/// id (`ai.x.grok:…`). For diagnostics and matching only; the MDM layer has no
/// file, so this is a label (`Cow<str>`), never a `Path` to open.
pub fn label(&self) -> std::borrow::Cow<'_, str> {
match self {
Self::File(p) => p.to_string_lossy(),
Self::Mdm => std::borrow::Cow::Borrowed(crate::macos_managed::MDM_REQUIREMENTS_SOURCE),
}
}
}
/// One requirements layer: the parsed TOML and where it came from.
#[derive(Debug, Clone)]
pub struct RequirementsLayer {
pub value: toml::Value,
pub source: RequirementsSource,
/// `true` = root-owned system layer. Security decisions must trust this flag,
/// not re-derive from the source (`GROK_HOME`-influenced, could carry `..`).
pub is_system: bool,
}
/// All loaded requirements layers in apply order (user first, system last).
/// Use when you need per-layer source attribution; otherwise use
/// [`load_merged_requirements`].
pub fn requirements_layers() -> Vec<RequirementsLayer> {
let mut out = Vec::new();
if let Some(user_path) = user_grok_home().map(|g| g.join("requirements.toml"))
&& let Some(value) = load_requirements_layer(&user_path)
{
out.push(RequirementsLayer {
value,
source: RequirementsSource::File(user_path),
is_system: false,
});
}
if let Some(dir) = system_config_dir() {
let sys_path = dir.join("requirements.toml");
if let Some(value) = load_requirements_layer(&sys_path) {
out.push(RequirementsLayer {
value,
source: RequirementsSource::File(sys_path),
is_system: true,
});
}
}
// macOS MDM: OS-protected admin layer (forced values only). Pushed last so it
// wins the deep-merge over the system file and cloud cache; `is_system` so
// security decisions trust it like the root-owned layer.
if let Some(value) = mdm_requirements_value() {
out.push(RequirementsLayer {
value,
source: RequirementsSource::Mdm,
is_system: true,
});
}
out
}
/// User + system requirements deep-merged, system wins on conflict.
/// Use for read-only consumers so user pins can't bypass system policy.
pub fn load_merged_requirements() -> Option<toml::Value> {
let mut iter = requirements_layers().into_iter();
let mut merged = iter.next()?.value;
for layer in iter {
crate::loader::deep_merge_toml(&mut merged, &layer.value);
}
Some(merged)
}
pub(crate) fn load_requirements() -> Option<toml::Value> {
load_user_requirements(user_grok_home().as_deref())
}
/// User requirements layer from `<home>/requirements.toml`, or `None` with no
/// resolvable user home (rather than reading a cwd-relative `.grok`).
fn load_user_requirements(home: Option<&Path>) -> Option<toml::Value> {
load_requirements_layer(&home?.join("requirements.toml"))
}
pub(crate) fn load_system_requirements() -> Option<toml::Value> {
let dir = system_config_dir()?;
load_requirements_layer(&dir.join("requirements.toml"))
}
/// Soft-fails on errors; fail-closed enforcement lives in
/// [`validate_requirements`].
pub(crate) fn load_requirements_layer(path: &Path) -> Option<toml::Value> {
let v = match load_toml_file(path) {
Ok(v) if v.as_table().is_some_and(|t| !t.is_empty()) => v,
_ => return None,
};
normalize_requirements_value(v, &path.display().to_string())
}
/// Strip `fail_closed` and apply `[[version_overrides]]` for a parsed
/// requirements layer (file or MDM), so every source is normalized identically.
/// `None` (skip the layer) when version_overrides are invalid for this build.
pub(crate) fn normalize_requirements_value(
mut v: toml::Value,
source: &str,
) -> Option<toml::Value> {
if let Some(table) = v.as_table_mut() {
table.remove(FAIL_CLOSED_KEY);
}
if let Err(e) = apply_version_overrides_with_registered(&mut v) {
tracing::error!(
source = %source,
error = %e,
"requirements rejected: invalid version_overrides; admin policy NOT applied"
);
return None;
}
Some(v)
}
/// The MDM requirements layer (read + normalized), or `None`. Shared so the
/// enforced view and the effective-config view agree.
pub(crate) fn mdm_requirements_value() -> Option<toml::Value> {
normalize_requirements_value(
crate::macos_managed::managed_preferences_requirements()?,
crate::macos_managed::MDM_REQUIREMENTS_SOURCE,
)
}
/// Errors from validating requirements layers at startup.
#[derive(Debug, thiserror::Error)]
pub enum RequirementsError {
#[error(
"requirements at {} has invalid version_overrides under fail_closed: {source}",
path.display()
)]
InvalidVersionOverrides {
path: PathBuf,
#[source]
source: VersionOverrideError,
},
}
/// `Ok(())` unless the layer opts into fail_closed AND has invalid
/// `[[version_overrides]]` for the registered CLI version.
///
/// Re-reads the file independently from [`load_requirements_layer`]:
/// at startup both run, costing one extra small read per layer. Sharing
/// the parse would couple loader+validator APIs for negligible gain.
pub(crate) fn validate_requirements_layer(path: &Path) -> Result<(), RequirementsError> {
let Ok(v) = load_toml_file(path) else {
return Ok(());
};
validate_requirements_value(v, &RequirementsSource::File(path.to_path_buf()))
}
/// Fail-closed `[[version_overrides]]` validation for a parsed requirements layer
/// (file or MDM). Reads `fail_closed` before applying overrides so a broken patch
/// can't disable enforcement mid-load. `source` is the provenance label in the error.
fn validate_requirements_value(
mut v: toml::Value,
source: &RequirementsSource,
) -> Result<(), RequirementsError> {
if v.as_table().is_none_or(|t| t.is_empty()) {
return Ok(());
}
let fail_closed = resolve_fail_closed_mode(&v);
let Ok(version) = xai_grok_version::installed_semver() else {
return Ok(());
};
if let Err(e) = apply_version_overrides(&mut v, &version)
&& fail_closed
{
return Err(RequirementsError::InvalidVersionOverrides {
path: PathBuf::from(source.label().as_ref()),
source: e,
});
}
Ok(())
}
/// Validates all requirements layers (user + system files, and macOS MDM). Call
/// once at startup from the binary's `main()`; exit on `Err`.
pub fn validate_requirements() -> Result<(), RequirementsError> {
validate_user_requirements(user_grok_home().as_deref())?;
if let Some(dir) = system_config_dir() {
validate_requirements_layer(&dir.join("requirements.toml"))?;
}
// MDM uses the raw value (fail_closed intact) so it's enforced like the files.
if let Some(mdm) = crate::macos_managed::managed_preferences_requirements() {
validate_requirements_value(mdm, &RequirementsSource::Mdm)?;
}
Ok(())
}
/// Validate the user requirements layer if a user home resolves; otherwise a
/// no-op (no cwd-relative `.grok/requirements.toml` is read or enforced).
fn validate_user_requirements(home: Option<&Path>) -> Result<(), RequirementsError> {
match home {
Some(g) => validate_requirements_layer(&g.join("requirements.toml")),
None => Ok(()),
}
}
/// `fail_closed` for [`validate_requirements`]'s version check: the admin file flag is authoritative; the env can only TIGHTEN it (force-on), never loosen.
fn resolve_fail_closed_mode(requirements: &toml::Value) -> bool {
fail_closed_flag(requirements) || env_bool(FAIL_CLOSED_ENV) == Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
/// Even with `fail_closed = true` in the file -- enforcement is
/// `validate_requirements`, not the loader.
#[test]
fn load_requirements_layer_soft_fails_on_invalid_version_overrides() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-vo-soft-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("requirements.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
fail_closed = true
[[version_overrides]]
minimum_version = "not-a-version"
[version_overrides.features]
telemetry = true
"#
)
.unwrap();
assert!(load_requirements_layer(&path).is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn validate_requirements_layer_errs_on_fail_closed_violation() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-vo-validate-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("requirements.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
fail_closed = true
[[version_overrides]]
minimum_version = "not-a-version"
"#
)
.unwrap();
let err = validate_requirements_layer(&path).unwrap_err();
assert!(matches!(
err,
RequirementsError::InvalidVersionOverrides { .. }
));
let _ = std::fs::remove_file(&path);
}
#[test]
fn validate_requirements_layer_ok_without_fail_closed() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-vo-soft2-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("requirements.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[[version_overrides]]
minimum_version = "not-a-version"
"#
)
.unwrap();
assert!(validate_requirements_layer(&path).is_ok());
let _ = std::fs::remove_file(&path);
}
#[test]
fn fail_closed_env_can_tighten_but_not_loosen() {
// SAFETY: process-global env mutation, restored before return.
let off: toml::Value = toml::from_str("fail_closed = false\n").unwrap();
let on: toml::Value = toml::from_str("fail_closed = true\n").unwrap();
let prior = std::env::var(FAIL_CLOSED_ENV).ok();
// env=1 force-enables even when the file is off (tighten is allowed).
unsafe { std::env::set_var(FAIL_CLOSED_ENV, "1") };
assert!(resolve_fail_closed_mode(&off));
assert!(resolve_fail_closed_mode(&on));
// env=0 must NOT disable an admin's fail_closed=true (no local bypass).
unsafe { std::env::set_var(FAIL_CLOSED_ENV, "0") };
assert!(
resolve_fail_closed_mode(&on),
"a local env must not loosen admin fail_closed"
);
assert!(!resolve_fail_closed_mode(&off));
// Unset → the admin file flag governs.
unsafe { std::env::remove_var(FAIL_CLOSED_ENV) };
assert!(resolve_fail_closed_mode(&on));
assert!(!resolve_fail_closed_mode(&off));
unsafe {
match prior {
Some(p) => std::env::set_var(FAIL_CLOSED_ENV, p),
None => std::env::remove_var(FAIL_CLOSED_ENV),
}
}
}
#[test]
fn fail_closed_flag_from_str_reads_the_opt_in() {
assert!(fail_closed_flag_from_str("fail_closed = true\n"));
assert!(!fail_closed_flag_from_str("fail_closed = false\n"));
// Missing key, a non-bool value, malformed TOML, and empty all read as
// not-opted-in (best-effort false) rather than panicking.
assert!(!fail_closed_flag_from_str("[features]\ntelemetry = true\n"));
assert!(!fail_closed_flag_from_str("fail_closed = \"yes\"\n"));
assert!(!fail_closed_flag_from_str("[unclosed"));
assert!(!fail_closed_flag_from_str(""));
}
#[test]
fn fail_closed_key_is_stripped_from_returned_layer() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-vo-strip-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("requirements.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "fail_closed = true\n[features]\ntelemetry = true\n").unwrap();
let result = load_requirements_layer(&path).unwrap();
assert!(
result.get(FAIL_CLOSED_KEY).is_none(),
"fail_closed must not leak into the returned config"
);
assert_eq!(result["features"]["telemetry"].as_bool(), Some(true));
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_user_requirements_is_none_without_user_home() {
// No resolvable user home => no user requirements (no cwd-relative read).
assert!(load_user_requirements(None).is_none());
}
#[test]
fn load_user_requirements_reads_layer_when_home_present() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-req-load-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap();
writeln!(f, "[features]\ntelemetry = true\n").unwrap();
let v = load_user_requirements(Some(&dir)).expect("layer present");
assert_eq!(v["features"]["telemetry"].as_bool(), Some(true));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn validate_user_requirements_ok_without_user_home() {
// No user home => nothing to validate, no error.
assert!(validate_user_requirements(None).is_ok());
}
#[test]
fn validate_user_requirements_errs_on_fail_closed_violation() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("grok-req-validate-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap();
writeln!(
f,
r#"
fail_closed = true
[[version_overrides]]
minimum_version = "not-a-version"
"#
)
.unwrap();
let err = validate_user_requirements(Some(&dir)).unwrap_err();
assert!(matches!(
err,
RequirementsError::InvalidVersionOverrides { .. }
));
let _ = std::fs::remove_dir_all(&dir);
}
/// The macOS MDM layer enters at the value level (no file on disk):
/// `mdm_requirements_value` / `validate_requirements` hand the CFPreferences
/// value straight to these, so normalize (strip `fail_closed`, keep the
/// clamp) and enforcement (Err under `fail_closed` + a bad override) must
/// hold with no file in the loop.
#[test]
fn mdm_value_normalizes_and_enforces_like_a_file() {
let source = crate::macos_managed::MDM_REQUIREMENTS_SOURCE;
// Effective view: fail_closed stripped, the forced clamp kept.
let raw: toml::Value =
toml::from_str("fail_closed = true\n[features]\nweb_fetch = false\n").unwrap();
let normalized = normalize_requirements_value(raw, source).unwrap();
assert!(normalized.get(FAIL_CLOSED_KEY).is_none());
assert_eq!(normalized["features"]["web_fetch"].as_bool(), Some(false));
// Enforcement keeps fail_closed: a bad override under fail_closed => Err,
// the same override without fail_closed soft-fails (Ok).
let bad: toml::Value = toml::from_str(
"fail_closed = true\n[[version_overrides]]\nminimum_version = \"not-a-version\"\n",
)
.unwrap();
assert!(matches!(
validate_requirements_value(bad, &RequirementsSource::Mdm).unwrap_err(),
RequirementsError::InvalidVersionOverrides { .. }
));
let soft: toml::Value =
toml::from_str("[[version_overrides]]\nminimum_version = \"not-a-version\"\n").unwrap();
assert!(validate_requirements_value(soft, &RequirementsSource::Mdm).is_ok());
}
}

View file

@ -0,0 +1,212 @@
//! Version-aware config layering. A `[[version_overrides]]` array carries
//! semver-gated patches deep-merged in ascending `minimum_version` order.
//!
//! ```toml
//! [[version_overrides]]
//! minimum_version = "1.7.0"
//! [version_overrides.features]
//! logging = true
//!
//! [[version_overrides]]
//! minimum_version = "1.8.0"
//! maximum_version = "1.9.999"
//! [version_overrides.features.telemetry]
//! enabled = true
//! ```
use semver::Version;
use serde::Deserialize;
use crate::config_override::{PATCH_STRIP_KEYS, apply_patches, take_patch_array};
pub const VERSION_OVERRIDES_KEY: &str = "version_overrides";
#[derive(Debug, Clone, Deserialize)]
pub struct VersionOverrideMeta {
#[serde(default)]
pub minimum_version: Option<String>,
#[serde(default)]
pub maximum_version: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum VersionOverrideError {
#[error("version_overrides: failed to deserialize: {0}")]
Deserialize(#[from] toml::de::Error),
#[error("version_overrides[{index}].minimum_version = {value:?} is not valid semver: {source}")]
InvalidMinimumVersion {
index: usize,
value: String,
#[source]
source: semver::Error,
},
#[error("version_overrides[{index}].maximum_version = {value:?} is not valid semver: {source}")]
InvalidMaximumVersion {
index: usize,
value: String,
#[source]
source: semver::Error,
},
}
/// Strips `version_overrides` (always) and deep-merges each matching
/// patch in ascending `minimum_version` order.
pub fn apply_version_overrides(
config: &mut toml::Value,
version: &Version,
) -> Result<(), VersionOverrideError> {
let entries = take_patch_array::<VersionOverrideMeta>(config, VERSION_OVERRIDES_KEY)?;
// Parse all bounds upfront so an invalid entry fails before any merge.
// Missing minimum_version => Version::new(0, 0, 0) (no lower bound).
let mut parsed: Vec<(Version, Option<Version>, toml::Table)> =
Vec::with_capacity(entries.len());
for (index, entry) in entries.into_iter().enumerate() {
let min_v = match &entry.meta.minimum_version {
Some(s) => Version::parse(s.trim()).map_err(|source| {
VersionOverrideError::InvalidMinimumVersion {
index,
value: s.clone(),
source,
}
})?,
None => Version::new(0, 0, 0),
};
let max_v = match &entry.meta.maximum_version {
Some(max_str) => Some(Version::parse(max_str.trim()).map_err(|source| {
VersionOverrideError::InvalidMaximumVersion {
index,
value: max_str.clone(),
source,
}
})?),
None => None,
};
parsed.push((min_v, max_v, entry.patch));
}
// Stable sort -- ties on minimum_version keep declared order so later
// entries win.
parsed.sort_by(|a, b| a.0.cmp(&b.0));
let patches = parsed.into_iter().filter_map(|(min_v, max_v, patch)| {
if version < &min_v {
return None;
}
if let Some(ref m) = max_v
&& version > m
{
return None;
}
Some(patch)
});
apply_patches(config, patches, PATCH_STRIP_KEYS);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> toml::Value {
toml::from_str(s).expect("valid toml")
}
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
/// Helper asserts the section is stripped on every call, so the
/// "stripped even on no match" contract is covered across all 8 cases.
#[test]
fn version_match_boundaries() {
fn applies(min: Option<&str>, max: Option<&str>, cli: &str) -> bool {
let line = |k: &str, val: Option<&str>| {
val.map(|s| format!("\n {k} = \"{s}\""))
.unwrap_or_default()
};
let mut cfg = parse(&format!(
r#"
x = 0
[[version_overrides]]{}{}
x = 1
"#,
line("minimum_version", min),
line("maximum_version", max),
));
apply_version_overrides(&mut cfg, &v(cli)).unwrap();
assert!(
cfg.get(VERSION_OVERRIDES_KEY).is_none(),
"section must be stripped"
);
cfg["x"].as_integer() == Some(1)
}
assert!(applies(Some("1.7.0"), None, "1.7.0")); // min inclusive
assert!(applies(Some("1.0.0"), Some("1.7.0"), "1.7.0")); // max inclusive
assert!(!applies(Some("1.7.0"), None, "1.6.0")); // below min
assert!(!applies(Some("1.0.0"), Some("1.5.0"), "2.0.0")); // above max
assert!(applies(Some("1.7.0"), None, "99.0.0")); // unbounded above
assert!(applies(None, Some("2.0.0"), "1.5.0")); // max-only, within
assert!(!applies(None, Some("2.0.0"), "2.0.1")); // max-only, above
assert!(applies(None, None, "1.0.0")); // unbounded both = always
}
#[test]
fn later_matching_override_wins_on_same_key() {
let mut cfg = parse(
r#"
[features.telemetry]
enabled = false
[[version_overrides]]
minimum_version = "1.7.0"
[version_overrides.features.telemetry]
enabled = true
sample_rate = 0.1
[[version_overrides]]
minimum_version = "1.8.0"
[version_overrides.features.telemetry]
sample_rate = 0.5
"#,
);
apply_version_overrides(&mut cfg, &v("1.8.0")).unwrap();
let t = &cfg["features"]["telemetry"];
assert_eq!(t["enabled"].as_bool(), Some(true));
assert_eq!(t["sample_rate"].as_float(), Some(0.5));
}
#[test]
fn invalid_semver_in_bounds_is_hard_error() {
let mut cfg = parse(
r#"
[[version_overrides]]
minimum_version = "not-a-version"
x = 1
"#,
);
let err = apply_version_overrides(&mut cfg, &v("1.0.0")).unwrap_err();
assert!(matches!(
err,
VersionOverrideError::InvalidMinimumVersion { .. }
));
// Section is consumed even on error.
assert!(cfg.get(VERSION_OVERRIDES_KEY).is_none());
let mut cfg = parse(
r#"
[[version_overrides]]
minimum_version = "1.0.0"
maximum_version = "garbage"
x = 1
"#,
);
let err = apply_version_overrides(&mut cfg, &v("1.0.0")).unwrap_err();
assert!(matches!(
err,
VersionOverrideError::InvalidMaximumVersion { .. }
));
}
}