Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
38
crates/codegen/xai-grok-config/Cargo.toml
Normal file
38
crates/codegen/xai-grok-config/Cargo.toml
Normal 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
|
||||
294
crates/codegen/xai-grok-config/src/campaigns.rs
Normal file
294
crates/codegen/xai-grok-config/src/campaigns.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
142
crates/codegen/xai-grok-config/src/config_override.rs
Normal file
142
crates/codegen/xai-grok-config/src/config_override.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
43
crates/codegen/xai-grok-config/src/fs_atomic.rs
Normal file
43
crates/codegen/xai-grok-config/src/fs_atomic.rs
Normal 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
|
||||
}
|
||||
67
crates/codegen/xai-grok-config/src/lib.rs
Normal file
67
crates/codegen/xai-grok-config/src/lib.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
706
crates/codegen/xai-grok-config/src/loader.rs
Normal file
706
crates/codegen/xai-grok-config/src/loader.rs
Normal 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") },
|
||||
}
|
||||
}
|
||||
}
|
||||
196
crates/codegen/xai-grok-config/src/macos_managed.rs
Normal file
196
crates/codegen/xai-grok-config/src/macos_managed.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
464
crates/codegen/xai-grok-config/src/managed_cache.rs
Normal file
464
crates/codegen/xai-grok-config/src/managed_cache.rs
Normal 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;
|
||||
1012
crates/codegen/xai-grok-config/src/managed_cache/tests.rs
Normal file
1012
crates/codegen/xai-grok-config/src/managed_cache/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
327
crates/codegen/xai-grok-config/src/paths.rs
Normal file
327
crates/codegen/xai-grok-config/src/paths.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
682
crates/codegen/xai-grok-config/src/shell.rs
Normal file
682
crates/codegen/xai-grok-config/src/shell.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
413
crates/codegen/xai-grok-config/src/signed_policy.rs
Normal file
413
crates/codegen/xai-grok-config/src/signed_policy.rs
Normal 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;
|
||||
958
crates/codegen/xai-grok-config/src/signed_policy/tests.rs
Normal file
958
crates/codegen/xai-grok-config/src/signed_policy/tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
467
crates/codegen/xai-grok-config/src/validation.rs
Normal file
467
crates/codegen/xai-grok-config/src/validation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
212
crates/codegen/xai-grok-config/src/version_overrides.rs
Normal file
212
crates/codegen/xai-grok-config/src/version_overrides.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue