Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -44,12 +44,14 @@ pub use global_hook_sources::{
validate_direct_hook_json_file, validated_hook_json_files_for_sources,
};
pub use loader::{
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
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,
CampaignsState, ConfigLayers, HookConfigLayer, HookProvenance, MANAGED_CONFIG_FILENAME,
ManagedConfigLayer, REQUIREMENTS_FILENAME, USER_CONFIG_FILENAME,
apply_version_overrides_with_registered, campaigns_application_disabled, campaigns_state_path,
deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, hook_config_layers,
hook_config_layers_at, 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::{

View file

@ -9,14 +9,12 @@ 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> {
/// Read and parse a TOML file WITHOUT `$VAR` expansion (empty table if absent).
/// Shared core of [`load_toml_file`] and the hook-layer read.
fn read_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)
}
Ok(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
@ -36,6 +34,13 @@ pub fn load_toml_file(path: &Path) -> std::io::Result<toml::Value> {
}
}
/// 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> {
let mut v = read_toml_file(path)?;
expand_env_vars_in_toml(&mut v);
Ok(v)
}
/// 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
@ -81,9 +86,12 @@ pub fn load_config_file(path: &Path) -> std::io::Result<toml::Value> {
}
pub fn load_from_disk() -> std::io::Result<toml::Value> {
load_user_config_layer(user_grok_home().as_deref(), "config.toml")
load_user_config_layer(user_grok_home().as_deref(), USER_CONFIG_FILENAME)
}
/// User config filename (`$GROK_HOME/config.toml`), shared by the loaders here.
pub const USER_CONFIG_FILENAME: &str = "config.toml";
/// Managed config filename, shared by the loaders in this module.
pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml";
@ -158,6 +166,228 @@ pub fn managed_config_layers_at(
layers
}
/// A hook's origin (held by `xai_grok_hooks::HookSpec::layer`). Defined here, not
/// in `xai-grok-hooks`, since the dep direction is `xai-grok-hooks -> xai-grok-config`;
/// this crate sets the config tiers, `File`/`Plugin` are set downstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookProvenance {
/// `/etc/grok/managed_config.toml`.
SystemManaged,
/// `$GROK_HOME/managed_config.toml` (server-synced).
Managed,
/// `requirements.toml` (user or system tier).
Requirements,
/// `$GROK_HOME/config.toml`.
User,
/// A JSON hook file (the hooks directory, a vendor settings file, or a
/// configured hooks path).
File,
/// A plugin-contributed hook.
Plugin,
/// A tier this build doesn't recognize (e.g. a newer peer's provenance over
/// the wire). Forward-tolerant so an unknown value degrades to a
/// conservative origin instead of failing the whole `HookRegistry` decode.
#[serde(other)]
Unknown,
}
/// Defaults to `File` so pre-provenance wire records decode as the most
/// conservative origin.
impl Default for HookProvenance {
fn default() -> Self {
Self::File
}
}
impl HookProvenance {
/// The snake_case wire string (matches the derived serde representation).
pub fn as_str(self) -> &'static str {
match self {
Self::SystemManaged => "system_managed",
Self::Managed => "managed",
Self::Requirements => "requirements",
Self::User => "user",
Self::File => "file",
Self::Plugin => "plugin",
Self::Unknown => "unknown",
}
}
}
impl std::str::FromStr for HookProvenance {
type Err = std::convert::Infallible;
/// Inverse of [`HookProvenance::as_str`]. Unrecognized strings map to
/// [`HookProvenance::Unknown`] (forward-tolerant), so this never fails.
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"system_managed" => Self::SystemManaged,
"managed" => Self::Managed,
"requirements" => Self::Requirements,
"user" => Self::User,
"file" => Self::File,
"plugin" => Self::Plugin,
_ => Self::Unknown,
})
}
}
/// One config layer's `hooks` subtree (read without `$VAR` expansion) plus its
/// provenance.
#[derive(Debug, Clone)]
pub struct HookConfigLayer {
provenance: HookProvenance,
source_name: String,
path: std::path::PathBuf,
hooks: toml::Value,
}
impl HookConfigLayer {
/// Construct a layer directly (in-memory config and tests); the synthesized
/// `path` mirrors `source_name`. The normal path is [`hook_config_layers`].
pub fn new(
provenance: HookProvenance,
source_name: impl Into<String>,
hooks: toml::Value,
) -> Self {
let source_name = source_name.into();
let path = std::path::PathBuf::from(&source_name);
Self {
provenance,
source_name,
path,
hooks,
}
}
pub fn provenance(&self) -> HookProvenance {
self.provenance
}
/// A stable label for this layer (e.g. `"managed"`, `"requirements/user"`),
/// used to prefix hook names for display and dedup.
pub fn source_name(&self) -> &str {
&self.source_name
}
/// The layer's backing file, so parse errors can cite a real path.
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// The raw `hooks` table, unexpanded so a literal `${VAR}` reaches the runner.
pub fn hooks(&self) -> &toml::Value {
&self.hooks
}
}
/// All config-layer `hooks` blocks, highest authority first (matching
/// [`effective_config_base`]). Read WITHOUT env-expansion and never merged (hooks
/// combine additively downstream); absent/unparsable layers are skipped with a
/// warning so one bad layer can't drop the others. macOS MDM is excluded (not a
/// TOML file; MDM hooks belong to the enforcement work).
pub fn hook_config_layers() -> Vec<HookConfigLayer> {
hook_config_layers_at(system_config_dir().as_deref(), user_grok_home().as_deref())
}
/// [`hook_config_layers`] with explicit directories, for tests.
pub fn hook_config_layers_at(
system_dir: Option<&Path>,
user_home: Option<&Path>,
) -> Vec<HookConfigLayer> {
/// One candidate config-hook layer: which directory + filename to read, and
/// the provenance/label to stamp on hooks found there.
struct LayerSpec<'a> {
dir: Option<&'a Path>,
filename: &'a str,
provenance: HookProvenance,
source_name: &'a str,
}
// Highest config authority first, matching `effective_config_base` precedence
// (requirements > user > managed > system_managed; user overrides managed in
// this model). Order only affects which label a byte-identical duplicate keeps
// under first-wins dedup; every distinct hook runs regardless.
let specs = [
LayerSpec {
dir: system_dir,
filename: REQUIREMENTS_FILENAME,
provenance: HookProvenance::Requirements,
source_name: "requirements/system",
},
LayerSpec {
dir: user_home,
filename: REQUIREMENTS_FILENAME,
provenance: HookProvenance::Requirements,
source_name: "requirements/user",
},
LayerSpec {
dir: user_home,
filename: USER_CONFIG_FILENAME,
provenance: HookProvenance::User,
source_name: "user",
},
LayerSpec {
dir: user_home,
filename: MANAGED_CONFIG_FILENAME,
provenance: HookProvenance::Managed,
source_name: "managed",
},
LayerSpec {
dir: system_dir,
filename: MANAGED_CONFIG_FILENAME,
provenance: HookProvenance::SystemManaged,
source_name: "system_managed",
},
];
let mut layers = Vec::new();
for LayerSpec {
dir,
filename,
provenance,
source_name,
} in specs
{
let Some(path) = dir.map(|d| d.join(filename)) else {
continue;
};
if !path.is_file() {
continue;
}
// No `$VAR` expansion: a literal `${VAR}` must reach the hook runner, which
// does the single expansion (expanding here would double-expand).
let mut value = match read_toml_file(&path) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping config layer whose hooks could not be read");
continue;
}
};
// Apply `[[version_overrides]]` (parity with `load_config_file`); deep-merge
// only, no `$VAR` expansion, so the raw-read invariant holds.
if let Err(e) = apply_version_overrides_with_registered(&mut value) {
tracing::warn!(path = %path.display(), error = %e, "skipping config layer whose version_overrides failed to apply");
continue;
}
let Some(hooks) = value.get("hooks") else {
continue;
};
if !hooks.is_table() {
tracing::warn!(path = %path.display(), "ignoring non-table `hooks` value in config layer");
continue;
}
layers.push(HookConfigLayer {
provenance,
source_name: source_name.to_string(),
path: path.clone(),
hooks: hooks.clone(),
});
}
layers
}
/// Layers lowest→highest priority. `[[campaigns]]` taken off each layer at load.
#[derive(Clone)]
pub struct ConfigLayers {
@ -465,6 +695,58 @@ pub fn expand_env_vars_in_string(input: &str) -> String {
mod tests {
use super::*;
fn write(dir: &Path, name: &str, contents: &str) {
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn hook_config_layers_reads_each_layer_unmerged_with_provenance() {
let sys = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
write(
home.path(),
"config.toml",
"[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"${HOME}/u.sh\"\n",
);
write(
home.path(),
MANAGED_CONFIG_FILENAME,
"[[hooks.PreToolUse]]\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"/m.sh\"\n",
);
write(
sys.path(),
REQUIREMENTS_FILENAME,
"[[hooks.PostToolUse]]\n[[hooks.PostToolUse.hooks]]\ntype = \"command\"\ncommand = \"/r.sh\"\n",
);
let layers = hook_config_layers_at(Some(sys.path()), Some(home.path()));
// Highest authority first, each layer keeping its own provenance.
let names: Vec<_> = layers.iter().map(|l| l.source_name().to_string()).collect();
assert_eq!(names, vec!["requirements/system", "user", "managed"]);
assert_eq!(layers[1].provenance(), HookProvenance::User);
// Unmerged, and `${HOME}` stays literal (the runner expands, not the loader).
let cmd = layers[1].hooks()["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert_eq!(cmd, "${HOME}/u.sh");
}
#[test]
fn hook_config_layers_bad_user_layer_does_not_drop_managed() {
// A broken user config.toml must not drop the admin managed layer.
let home = tempfile::tempdir().unwrap();
write(home.path(), "config.toml", "this is = = not valid toml");
write(
home.path(),
MANAGED_CONFIG_FILENAME,
"[[hooks.PreToolUse]]\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"/m.sh\"\n",
);
let layers = hook_config_layers_at(None, Some(home.path()));
let names: Vec<_> = layers.iter().map(|l| l.source_name().to_string()).collect();
assert_eq!(names, vec!["managed"]);
}
#[test]
fn full_layer_precedence_requirements_over_config_over_managed() {
let system_managed: toml::Value =

View file

@ -382,7 +382,7 @@ fn effective_now(cache: Option<&ManagedConfigCache>) -> u64 {
/// A signing-enabled build over a legacy unsigned / edited / forged or foreign-bound
/// cache refetches a signed copy; likewise when an imposing claim has no policy
/// sidecar satisfying it — the states the gate refuses on, so refusal always comes
/// with a pending self-heal. Dark build or no policy on disk → false.
/// with a pending self-heal. Keyless build or no policy on disk → false.
fn signed_cache_needs_refetch(
home: &Path,
cache: Option<&ManagedConfigCache>,

View file

@ -68,10 +68,10 @@ fn claim_not_consulted_on_sidecar_read_blip() {
);
}
/// Dark build: a claim file on disk changes neither the gate nor staleness.
/// Armed: garbage claim alone (no fail-closed) does not trip gate or force refetch.
#[test]
fn claim_paths_are_inert_in_dark_build() {
assert!(!crate::signed_policy::verification_active());
fn garbage_claim_without_fail_closed_is_not_imposing() {
assert!(crate::signed_policy::verification_active());
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
@ -91,10 +91,43 @@ fn claim_paths_are_inert_in_dark_build() {
.unwrap();
assert!(
!managed_policy_compromised_for_at(home, &team("team-a")),
"dark build: a claim file must not make the gate fail closed"
"garbage claim without fail-closed must not make the gate fail closed"
);
assert!(
!is_managed_config_hard_stale_for_at(home, &team("team-a")),
"dark build: a claim file must not force a refetch"
"garbage claim without fail-closed must not force a refetch"
);
}
/// Keyless: claim file does not affect gate or staleness.
#[test]
fn claim_paths_are_inert_in_dark_build() {
crate::signed_policy::test_seam::with_dark(|| {
assert!(!crate::signed_policy::verification_active());
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some("team-a"),
had_managed_config: false,
had_requirements: false,
key_fingerprint: None,
fail_closed: false,
},
);
std::fs::write(
home.join(crate::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE),
r#"{"signed_payload":"{}","signature":"","key_id":""}"#,
)
.unwrap();
assert!(
!managed_policy_compromised_for_at(home, &team("team-a")),
"dark build: a claim file must not make the gate fail closed"
);
assert!(
!is_managed_config_hard_stale_for_at(home, &team("team-a")),
"dark build: a claim file must not force a refetch"
);
});
}

View file

@ -356,7 +356,7 @@ fn managed_config_stale_at_is_true_for_old_sync() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A served-then-deleted artifact reads stale regardless of the timer.
/// Served-then-deleted is stale; armed also treats unsigned-on-disk as stale.
#[test]
fn managed_config_stale_when_served_artifact_deleted() {
let dir = std::env::temp_dir().join(format!("grok-stale-artgone-{}", std::process::id()));
@ -372,8 +372,13 @@ fn managed_config_stale_when_served_artifact_deleted() {
},
);
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
assert!(!managed_config_stale_at(Some(&dir), &team("team-1")));
let cache = read_managed_config_cache(&dir).unwrap();
// present → usable; deleted → tamper
assert!(!cache_unusable_for(&cache, &dir, &team("team-1")));
// armed: unsigned policy still hard-stale
assert!(managed_config_stale_at(Some(&dir), &team("team-1")));
std::fs::remove_file(dir.join("requirements.toml")).unwrap();
assert!(cache_unusable_for(&cache, &dir, &team("team-1")));
assert!(managed_config_stale_at(Some(&dir), &team("team-1")));
let _ = std::fs::remove_dir_all(&dir);
}
@ -441,7 +446,7 @@ fn managed_config_legacy_marker_is_conservative() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Hard-staleness: missing artifact or identity mismatch → true; a fresh same-identity cache → false.
/// Hard-stale: missing artifact or identity mismatch; fresh same-identity is usable.
#[test]
fn hard_stale_only_on_missing_or_identity() {
let dir = std::env::temp_dir().join(format!("grok-hardstale-{}", std::process::id()));
@ -457,12 +462,16 @@ fn hard_stale_only_on_missing_or_identity() {
},
);
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
// Same identity, artifact present → not hard-stale (even past the timer).
assert!(!is_managed_config_hard_stale_for_at(&dir, &team("team-a")));
// Different identity → hard-stale.
assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-b")));
// Served artifact deleted → hard-stale.
let cache = read_managed_config_cache(&dir).unwrap();
// same identity + present → usable
assert!(!cache_unusable_for(&cache, &dir, &team("team-a")));
// different identity → unusable
assert!(cache_unusable_for(&cache, &dir, &team("team-b")));
// deleted artifact → unusable
std::fs::remove_file(dir.join("requirements.toml")).unwrap();
assert!(cache_unusable_for(&cache, &dir, &team("team-a")));
// armed: unsigned still hard-stale
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-a")));
let _ = std::fs::remove_dir_all(&dir);
}
@ -494,7 +503,7 @@ fn corrupt_marker_reads_as_no_marker_and_allows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A deploy-key switch is detected offline as an identity mismatch (`cache_unusable_for`) and refetched online.
/// Deploy-key switch → offline identity mismatch (refetch online).
#[test]
fn deployment_key_switch_is_stale_and_tampered_offline() {
let dir = std::env::temp_dir().join(format!("grok-dk-switch-{}", std::process::id()));
@ -511,18 +520,21 @@ fn deployment_key_switch_is_stale_and_tampered_offline() {
},
);
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
let cache = read_managed_config_cache(&dir).unwrap();
// Same key, artifacts intact → not stale, not tampered.
assert!(!is_managed_config_hard_stale_for_at(&dir, &dkey("fp-a")));
assert!(!managed_config_stale_at(Some(&dir), &dkey("fp-a")));
// same key → usable
assert!(!cache_unusable_for(&cache, &dir, &dkey("fp-a")));
assert!(!cache_key_fingerprint_mismatch(&cache, &dkey("fp-a")));
// Different key (fp-b) → identity mismatch: hard-stale and tampered.
// different key → unusable
assert!(cache_unusable_for(&cache, &dir, &dkey("fp-b")));
assert!(cache_key_fingerprint_mismatch(&cache, &dkey("fp-b")));
assert!(is_managed_config_hard_stale_for_at(&dir, &dkey("fp-b")));
assert!(managed_config_stale_at(Some(&dir), &dkey("fp-b")));
let _ = std::fs::remove_dir_all(&dir);
}
/// A pre-upgrade marker (no `key_fingerprint`) must not fire when a key is now configured — it self-upgrades next sync.
/// Pre-upgrade marker (no fingerprint) must not fire key dimension.
#[test]
fn pre_upgrade_marker_without_fingerprint_does_not_fire_on_key() {
let dir = std::env::temp_dir().join(format!("grok-dk-preupgrade-{}", std::process::id()));
@ -538,16 +550,14 @@ fn pre_upgrade_marker_without_fingerprint_does_not_fire_on_key() {
)
.unwrap();
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
// A key is configured now but the marker has none → no key mismatch, no spurious refetch.
assert!(!is_managed_config_hard_stale_for_at(
&dir,
&dkey("fp-current")
));
assert!(!managed_config_stale_at(Some(&dir), &dkey("fp-current")));
// key now configured, marker has none → no mismatch
let cache = read_managed_config_cache(&dir).unwrap();
assert!(!cache_key_fingerprint_mismatch(&cache, &dkey("fp-current")));
assert!(!cache_unusable_for(&cache, &dir, &dkey("fp-current")));
let _ = std::fs::remove_dir_all(&dir);
}
/// The team path keys on `principal` (team id), records no fingerprint, and never fires a key mismatch.
/// Team path: principal only; never a key mismatch.
#[test]
fn team_path_keys_on_principal_not_key_fingerprint() {
let dir = std::env::temp_dir().join(format!("grok-team-nofp-{}", std::process::id()));
@ -563,9 +573,12 @@ fn team_path_keys_on_principal_not_key_fingerprint() {
},
);
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
let cache = read_managed_config_cache(&dir).unwrap();
// Team path: identity carries no fingerprint → never a key mismatch.
assert!(!is_managed_config_hard_stale_for_at(&dir, &team("team-a")));
assert!(!cache_key_fingerprint_mismatch(&cache, &team("team-a")));
assert!(!cache_unusable_for(&cache, &dir, &team("team-a")));
// A team switch is still detected via principal (unchanged behavior).
assert!(cache_unusable_for(&cache, &dir, &team("team-b")));
assert!(is_managed_config_hard_stale_for_at(&dir, &team("team-b")));
// No key fingerprint is recorded on the team path.
let marker = std::fs::read_to_string(dir.join(MANAGED_CONFIG_CACHE_FILE)).unwrap();
@ -736,7 +749,7 @@ fn blank_principal_is_never_a_confirmed_switch() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Compromised only when opted in AND tampered; opted-out / never-synced / config-less / intact is never flagged.
/// Armed: fail-closed + served policy requires authentic sidecar.
#[test]
fn compromised_only_when_opted_in_and_deleted_or_substituted() {
let dir = std::env::temp_dir().join(format!("grok-compromised-{}", std::process::id()));
@ -746,7 +759,7 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() {
let _ = std::fs::remove_file(dir.join(MANAGED_CONFIG_CACHE_FILE));
assert!(!managed_policy_compromised_for_at(&dir, &team("team-a")));
// Opted-in + present + same identity → not compromised.
// opted-in, no sidecar → refuse when armed
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
mark_managed_config_synced_at(
&dir,
@ -758,7 +771,7 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() {
fail_closed: true,
},
);
assert!(!managed_policy_compromised_for_at(&dir, &team("team-a")));
assert!(managed_policy_compromised_for_at(&dir, &team("team-a")));
// Served-then-deleted (admin opted in) → compromised.
std::fs::remove_file(dir.join("requirements.toml")).unwrap();
@ -798,15 +811,16 @@ fn compromised_only_when_opted_in_and_deleted_or_substituted() {
let _ = std::fs::remove_dir_all(&dir);
}
/// The OTHER served artifact — `managed_config.toml`, not just `requirements.toml` —
/// deleted under a fail_closed marker is compromised.
/// fail_closed + deleted managed_config.toml is compromised.
#[test]
fn compromised_on_managed_config_deletion_when_fail_closed() {
use crate::signed_policy::SignedVerdict;
let dir = std::env::temp_dir().join(format!("grok-compromised-mc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("managed_config.toml"), "[cli]\n").unwrap();
let home = dir.as_path();
std::fs::create_dir_all(home).unwrap();
std::fs::write(home.join("managed_config.toml"), "[cli]\n").unwrap();
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("team-a"),
had_managed_config: true,
@ -815,24 +829,43 @@ fn compromised_on_managed_config_deletion_when_fail_closed() {
fail_closed: true,
},
);
// Present → not compromised.
assert!(!managed_policy_compromised_for_at(&dir, &team("team-a")));
let cache = read_managed_config_cache(home);
// present → not compromised (marker)
assert!(!managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-a")
));
// Served-then-deleted managed_config.toml → compromised by the missing artifact.
std::fs::remove_file(dir.join("managed_config.toml")).unwrap();
assert!(managed_policy_compromised_for_at(&dir, &team("team-a")));
std::fs::remove_file(home.join("managed_config.toml")).unwrap();
assert!(managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-a")
));
// armed public gate refuses sidecar-less fail-closed
assert!(managed_policy_compromised_for_at(home, &team("team-a")));
let _ = std::fs::remove_dir_all(&dir);
}
/// Deployment-key path: an opted-in marker is compromised on an offline key switch (the fingerprint is the only offline identity).
/// Offline deploy-key switch on opted-in marker is compromised.
#[test]
fn compromised_on_deployment_key_switch_when_fail_closed() {
use crate::signed_policy::SignedVerdict;
let dir = std::env::temp_dir().join(format!("grok-compromised-dk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let home = dir.as_path();
std::fs::create_dir_all(home).unwrap();
// Provisioned with key A (fp-a), opted into fail_closed, artifact present.
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("dep-A"),
had_managed_config: false,
@ -841,16 +874,32 @@ fn compromised_on_deployment_key_switch_when_fail_closed() {
fail_closed: true,
},
);
let cache = read_managed_config_cache(home);
// Same key, offline → not compromised (ALLOW).
assert!(!managed_policy_compromised_for_at(&dir, &dkey("fp-a")));
// same key offline → allow (marker)
assert!(!managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&dkey("fp-a")
));
// different key offline → refuse
assert!(managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
true,
cache.as_ref(),
home,
&dkey("fp-b")
));
// armed public gate agrees
assert!(managed_policy_compromised_for_at(home, &dkey("fp-b")));
// Different key (fp-b) → compromised (REFUSE): offline deploy-key switch.
assert!(managed_policy_compromised_for_at(&dir, &dkey("fp-b")));
// Not opted in (fail_closed=false): a key switch is NOT failed closed.
// fail_closed=false: key switch not refused
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("dep-A"),
had_managed_config: false,
@ -859,22 +908,23 @@ fn compromised_on_deployment_key_switch_when_fail_closed() {
fail_closed: false,
},
);
assert!(!managed_policy_compromised_for_at(&dir, &dkey("fp-b")));
assert!(!managed_policy_compromised_for_at(home, &dkey("fp-b")));
let _ = std::fs::remove_dir_all(&dir);
}
/// The gate refuses ONLY on tamper for the CURRENT principal (artifact missing or changed key fingerprint),
/// never a pure identity mismatch; staleness still treats that mismatch as a refetch trigger (asserted alongside).
/// Marker refuses only current-principal tamper, not pure identity mismatch.
#[test]
fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() {
use crate::signed_policy::SignedVerdict;
let dir = std::env::temp_dir().join(format!("grok-gate-fix1-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let home = dir.as_path();
std::fs::create_dir_all(home).unwrap();
// (1) Principal A (fail_closed), artifact intact; serving team-b = pure identity mismatch → ALLOWED.
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("dep-A"),
had_managed_config: false,
@ -883,19 +933,27 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() {
fail_closed: true,
},
);
let cache = read_managed_config_cache(home);
assert!(
!managed_policy_compromised_for_at(&dir, &team("team-b")),
"a foreign/stale principal's fail_closed must NOT refuse the current session"
!managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-b")
),
"a foreign/stale principal's fail_closed must NOT refuse on the marker path"
);
// ...but still stale for B → the refetch path rebinds online.
assert!(
is_managed_config_hard_stale_for_at(&dir, &team("team-b")),
is_managed_config_hard_stale_for_at(home, &team("team-b")),
"a pure identity mismatch must still trigger a refetch (rebind)"
);
// (2) Same principal, served artifact now missing → still REFUSED offline.
// (2) same principal, artifact missing → refuse offline
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("team-b"),
had_managed_config: false,
@ -904,16 +962,25 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() {
fail_closed: true,
},
);
std::fs::remove_file(dir.join("requirements.toml")).unwrap();
std::fs::remove_file(home.join("requirements.toml")).unwrap();
let cache = read_managed_config_cache(home);
assert!(
managed_policy_compromised_for_at(&dir, &team("team-b")),
managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-b")
),
"same-principal served-then-deleted artifact must fail closed offline"
);
assert!(managed_policy_compromised_for_at(home, &team("team-b")));
// (3) Deploy-key fingerprint mismatch for the current key → still REFUSED.
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("dep-A"),
had_managed_config: false,
@ -922,24 +989,35 @@ fn gate_excludes_pure_identity_mismatch_but_keeps_artifact_and_key_tamper() {
fail_closed: true,
},
);
let cache = read_managed_config_cache(home);
assert!(
managed_policy_compromised_for_at(&dir, &dkey("fp-b")),
managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
true,
cache.as_ref(),
home,
&dkey("fp-b")
),
"a changed deployment-key fingerprint must fail closed offline"
);
assert!(managed_policy_compromised_for_at(home, &dkey("fp-b")));
let _ = std::fs::remove_dir_all(&dir);
}
/// Opt-in comes from the served response, not disk, so a no-write sync can't disarm the gate.
/// Opt-in from response, not disk; no-write sync cannot disarm.
#[test]
fn mark_keeps_fail_closed_armed_without_on_disk_file() {
use crate::signed_policy::SignedVerdict;
let dir = std::env::temp_dir().join(format!("grok-mark-disarm-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let home = dir.as_path();
std::fs::create_dir_all(home).unwrap();
// Opted-in policy served + present → not compromised (intact).
std::fs::write(dir.join("requirements.toml"), "[features]\n").unwrap();
// opted-in + present → allow (marker)
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("team-1"),
had_managed_config: false,
@ -948,15 +1026,31 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() {
fail_closed: true,
},
);
assert!(!managed_policy_compromised_for_at(&dir, &team("team-1")));
let cache = read_managed_config_cache(home);
assert!(!managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-1")
));
// Tamper: delete the served file → compromised.
std::fs::remove_file(dir.join("requirements.toml")).unwrap();
assert!(managed_policy_compromised_for_at(&dir, &team("team-1")));
// delete served file → compromised
std::fs::remove_file(home.join("requirements.toml")).unwrap();
assert!(managed_policy_compromised_decision(
SignedVerdict::Inactive,
|| false,
false,
cache.as_ref(),
home,
&team("team-1")
));
assert!(managed_policy_compromised_for_at(home, &team("team-1")));
// A no-write sync (file still absent) stays armed: opt-in is from the response.
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("team-1"),
had_managed_config: false,
@ -966,13 +1060,13 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() {
},
);
assert!(
managed_policy_compromised_for_at(&dir, &team("team-1")),
managed_policy_compromised_for_at(home, &team("team-1")),
"a no-write sync must not disarm the fail-closed gate"
);
// An opt-OUT (fail_closed=false) still takes effect.
// fail_closed=false still takes effect
mark_managed_config_synced_at(
&dir,
home,
SyncMarker {
principal: Some("team-1"),
had_managed_config: false,
@ -981,7 +1075,7 @@ fn mark_keeps_fail_closed_armed_without_on_disk_file() {
fail_closed: false,
},
);
assert!(!managed_policy_compromised_for_at(&dir, &team("team-1")));
assert!(!managed_policy_compromised_for_at(home, &team("team-1")));
let _ = std::fs::remove_dir_all(&dir);
}
@ -1247,9 +1341,37 @@ fn fetch_resets_an_inflated_rollback_floor() {
);
}
/// Dark build: public tick is a no-op over an existing marker.
/// Keyless: public tick is a no-op.
#[test]
fn bump_rollback_floor_is_inert_when_dark() {
crate::signed_policy::test_seam::with_dark(|| {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some("team-a"),
had_managed_config: false,
had_requirements: false,
key_fingerprint: None,
fail_closed: false,
},
);
let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor);
let base = floor(home);
assert!(!crate::signed_policy::verification_active());
bump_rollback_floor_with_now(home, base + 10_000);
assert_eq!(
floor(home),
base,
"dark build: the tick must not move the floor"
);
});
}
/// Armed: public tick raises the floor.
#[test]
fn bump_rollback_floor_raises_when_verification_active() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
@ -1264,12 +1386,13 @@ fn bump_rollback_floor_is_inert_when_dark() {
);
let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor);
let base = floor(home);
assert!(!crate::signed_policy::verification_active());
bump_rollback_floor_with_now(home, base + 10_000);
assert!(crate::signed_policy::verification_active());
let raised = base + 10_000;
bump_rollback_floor_with_now(home, raised);
assert_eq!(
floor(home),
base,
"dark build: the tick must not move the floor"
raised,
"armed build: the tick must raise the floor"
);
}

View file

@ -1,23 +1,39 @@
//! 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.
//! Server signs policy + principal + expiry; client verifies against a compiled-in
//! key set (by signed `key_id`), binds principal, and checks on-disk bytes match.
//! This build is armed (prod `v1` key); keyless (`&[]`) keeps the cache marker as authority.
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine;
// Shared wire types with the deployment-config server: a field rename breaks compile on both sides.
pub use prod_mc_cli_chat_proxy_types::{
MANAGED_CONFIG_NONCE_ECHO_HEADER, MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP,
ManagedIdentityClaim, SignatureEnvelope, SignedPayload, is_server_nonce_shape, 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.
/// Provisioning order: keyed clients reject `typ`-less envelopes, so the
/// typ-emitting server must be fully rolled out before any client embeds a key.
pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[];
/// Compiled-in trusted keys `(key_id, raw 32 bytes)`. Prod `v1`. Empty = dark (no verification).
/// The private signing key never lives in this crate or in client env flags.
///
/// - base64: `BxP2cxaRIzlhxUvqmlz9e/dIBeWX58P4whEW0sFrdzI=`
/// - SHA-256: `fb4dcc77c757465b953265146d495166527fcc1c2b365352f8d20c3d8f6de620`
///
/// Ship only after the server is emitting valid envelopes for this key id.
pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[(
"v1",
&[
7, 19, 246, 115, 22, 145, 35, 57, 97, 197, 75, 234, 154, 92, 253, 123, 247, 72, 5, 229,
151, 231, 195, 248, 194, 17, 22, 210, 193, 107, 119, 50,
],
)];
/// SHA-256 of raw `v1` pubkey (hex); test pin against silent typos.
pub const EMBEDDED_V1_PUBKEY_SHA256_HEX: &str =
"fb4dcc77c757465b953265146d495166527fcc1c2b365352f8d20c3d8f6de620";
// Compile-time sanity for the key set.
const _: () = {
let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS;
let mut i = 0;
@ -41,6 +57,7 @@ const _: () = {
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() {
@ -55,16 +72,95 @@ const fn const_str_eq(a: &str, b: &str) -> bool {
}
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)
/// Debug-only key override for tests (`test` or `test-signing-seam`); never release.
#[cfg(all(test, debug_assertions))]
pub mod test_seam {
use std::cell::RefCell;
use std::sync::RwLock;
/// Owned key list: `(key_id, raw 32-byte pubkey)`.
type OwnedKeys = Vec<(String, Vec<u8>)>;
/// `None` = compiled-in keys; `Some([])` = dark; `Some(non-empty)` = override.
type KeyOverride = Option<OwnedKeys>;
// Process override.
pub(super) static GLOBAL_OVERRIDE: RwLock<KeyOverride> = RwLock::new(None);
// Thread-local override (unit tests; avoids racing armed global).
// Outer `Option`: unset vs set on this thread. Inner is [`KeyOverride`].
thread_local! {
static LOCAL_OVERRIDE: RefCell<Option<KeyOverride>> = const { RefCell::new(None) };
}
fn to_owned_keys(keys: Option<&[(&str, &[u8])]>) -> KeyOverride {
keys.map(|ks| {
ks.iter()
.map(|(id, key)| ((*id).to_owned(), key.to_vec()))
.collect()
})
}
/// Process keys: `None` clear, `Some(&[])` dark, else these keys.
pub fn set_embedded_keys(keys: Option<&[(&str, &[u8])]>) {
*GLOBAL_OVERRIDE.write().unwrap_or_else(|e| e.into_inner()) = to_owned_keys(keys);
}
/// Dark keys on this thread for `f` only.
pub fn with_dark<R>(f: impl FnOnce() -> R) -> R {
LOCAL_OVERRIDE.with(|cell| {
let prev = cell.replace(Some(Some(Vec::new())));
struct Restore(Option<KeyOverride>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
LOCAL_OVERRIDE.with(|cell| {
*cell.borrow_mut() = prev;
});
}
}
let _restore = Restore(prev);
f()
})
}
pub(super) fn with_override<R>(f: impl FnOnce(Option<&[(String, Vec<u8>)]>) -> R) -> R {
if let Some(local) = LOCAL_OVERRIDE.with(|c| c.borrow().clone()) {
f(local.as_deref())
} else {
let global = GLOBAL_OVERRIDE.read().unwrap_or_else(|e| e.into_inner());
f(global.as_deref())
}
}
}
fn with_embedded_keys<R>(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R {
#[cfg(all(test, debug_assertions))]
{
test_seam::with_override(|overridden| match overridden {
Some(keys) => {
let view: Vec<(&str, &[u8])> = keys
.iter()
.map(|(id, key)| (id.as_str(), key.as_slice()))
.collect();
f(&view)
}
None => f(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS),
})
}
#[cfg(not(all(test, debug_assertions)))]
{
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";
/// The is-managed claim's own sidecar (see
/// [`prod_mc_cli_chat_proxy_types::ManagedIdentityClaim`]).
pub const MANAGED_IDENTITY_SIDECAR_FILE: &str = "managed_identity.sig.json";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SigError {
#[error("signature is not valid base64")]
@ -88,17 +184,56 @@ pub enum SigError {
#[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`]).
/// Remote kill-switch; set only from authenticated remote settings.
static REMOTE_VERIFICATION_DISARMED: AtomicBool = AtomicBool::new(false);
/// True when keys are embedded and the remote kill-switch has not disarmed.
pub fn verification_active() -> bool {
if REMOTE_VERIFICATION_DISARMED.load(Ordering::Relaxed) {
return false;
}
with_embedded_keys(|keys| !keys.is_empty())
}
/// Apply remote `managed_config_signature_verification`.
///
/// - `Some(false)` disarms only when `settings_origin_trusted` is true **or** no
/// keys are embedded (dark: disarm is a no-op for enforcement). An untrusted
/// origin (env-overridden proxy) cannot disarm a keyed client — that would make
/// the kill-switch an env toggle.
/// - `None` / `Some(true)` re-arm always (stronger / default).
///
/// Call only when settings were successfully fetched. Logs on state change.
pub fn apply_remote_managed_config_signature_verification(
setting: Option<bool>,
settings_origin_trusted: bool,
) {
let want_disarm = setting == Some(false);
let keys_embedded = with_embedded_keys(|keys| !keys.is_empty());
if want_disarm && keys_embedded && !settings_origin_trusted {
tracing::warn!(
"ignoring managed_config_signature_verification=false from untrusted settings origin"
);
return;
}
let disarm = want_disarm;
let prev = REMOTE_VERIFICATION_DISARMED.swap(disarm, Ordering::Relaxed);
if prev != disarm {
tracing::warn!(
disarmed = disarm,
"managed-config signature verification kill-switch changed"
);
}
}
/// 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
@ -118,6 +253,7 @@ pub fn verify_signed_payload(
}
Ok(payload)
}
/// [`verify_signed_payload`]'s mirror for claims (requires [`MANAGED_IDENTITY_TYP`]).
pub fn verify_managed_identity_claim(
signed_payload: &str,
@ -132,6 +268,7 @@ pub fn verify_managed_identity_claim(
}
Ok(claim)
}
/// Shared Ed25519 check: select the trusted key named by the signed bytes' `key_id`, verify.
fn verify_signature_with_keys(
signed_payload: &str,
@ -150,6 +287,7 @@ fn verify_signature_with_keys(
.verify(signed_payload.as_bytes(), &sig)
.map_err(|_| SigError::SignatureMismatch)
}
/// 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`
@ -173,6 +311,7 @@ pub fn check_fetch_identity(
}
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
@ -187,6 +326,7 @@ fn signed_principal_matches(payload: &SignedPayload, expected_principal: Option<
(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(
@ -196,6 +336,7 @@ pub fn verify_fetched(
) -> Result<SignedPayload, SigError> {
with_embedded_keys(|keys| verify_fetched_with_keys(sidecar, keys, active_team_id, now_unix))
}
/// Fetch-time claim verification (signature + expiry; binding is the caller's rule).
pub fn verify_fetched_claim(
sidecar: &SignatureEnvelope,
@ -203,6 +344,7 @@ pub fn verify_fetched_claim(
) -> Result<ManagedIdentityClaim, SigError> {
with_embedded_keys(|keys| verify_fetched_claim_with_keys(sidecar, keys, now_unix))
}
/// Key-injected core of [`verify_fetched_claim`] so tests can supply throwaway keys.
fn verify_fetched_claim_with_keys(
sidecar: &SignatureEnvelope,
@ -216,6 +358,7 @@ fn verify_fetched_claim_with_keys(
}
Ok(claim)
}
/// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs.
fn verify_fetched_with_keys(
sidecar: &SignatureEnvelope,
@ -227,6 +370,7 @@ fn verify_fetched_with_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.
@ -234,6 +378,7 @@ fn verify_fetched_with_keys(
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
@ -275,9 +420,11 @@ pub fn check_on_disk_matches(
}
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 {
@ -289,9 +436,11 @@ enum SidecarRead {
/// gate must not refuse on it, but the refetch trigger fires to self-heal.
Unreadable,
}
fn read_sidecar(home: &std::path::Path) -> SidecarRead {
read_envelope_at(&sidecar_path(home))
}
fn read_envelope_at(path: &std::path::Path) -> SidecarRead {
if non_regular_file_at(path) {
return SidecarRead::Absent;
@ -306,15 +455,18 @@ fn read_envelope_at(path: &std::path::Path) -> SidecarRead {
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<()> {
write_envelope_at(&sidecar_path(home), sidecar)
}
pub(crate) fn managed_identity_sidecar_path(home: &std::path::Path) -> std::path::PathBuf {
home.join(MANAGED_IDENTITY_SIDECAR_FILE)
}
/// [`write_sidecar`] for the claim (0600 for uniformity; the claim has no secret).
pub fn write_managed_identity_sidecar(
home: &std::path::Path,
@ -322,11 +474,13 @@ pub fn write_managed_identity_sidecar(
) -> std::io::Result<()> {
write_envelope_at(&managed_identity_sidecar_path(home), sidecar)
}
fn write_envelope_at(path: &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(path, &json, Some(0o600))
}
/// Persisted envelope nonce for [`MANAGED_CONFIG_NONCE_ECHO_HEADER`] (unverified;
/// telemetry only, never a trust input). Both guards fail open by skipping the
/// echo: only the server mint shape (header-safe, so a corrupt sidecar can't brick
@ -341,6 +495,7 @@ pub fn stored_envelope_nonce(
return None;
};
let payload: SignedPayload = serde_json::from_str(&sidecar.signed_payload).ok()?;
// Effective principal mirrors the server's bookkeeping: deployment over team.
let issued_to = payload
.deployment_id
.as_deref()
@ -348,6 +503,7 @@ pub fn stored_envelope_nonce(
(issued_to == Some(fetch_principal) && is_server_nonce_shape(&payload.nonce))
.then_some(payload.nonce)
}
/// Whether an authentic claim IMPOSES fail-closed enforcement: verified, bound to
/// the KNOWN `expected_principal`, in-date vs the caller-clamped `now_unix`, and
/// `fail_closed`. Anything else imposes nothing: permissive (must not override a
@ -365,6 +521,7 @@ pub fn managed_identity_claim_imposes(
managed_identity_claim_imposes_with_keys(home, keys, expected_principal, now_unix)
})
}
/// Key-injected core of [`managed_identity_claim_imposes`] so tests can supply throwaway keys.
fn managed_identity_claim_imposes_with_keys(
home: &std::path::Path,
@ -386,9 +543,10 @@ fn managed_identity_claim_imposes_with_keys(
};
claim.principal == expected && now_unix <= claim.expires_at && claim.fail_closed
}
/// 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).
/// Keyless build or no policy on disk → false.
pub fn cloud_cache_signature_invalid(
home: &std::path::Path,
expected_principal: Option<&str>,
@ -401,6 +559,7 @@ pub fn cloud_cache_signature_invalid(
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,
@ -415,10 +574,13 @@ fn cloud_cache_signature_invalid_with_keys(
}
use SignedCacheEvaluation as Eval;
match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
// ANY deviation refetches — including read blips (self-heal what the gate stays
// lenient on) and a foreign-but-authentic cache (which would otherwise never rebind).
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 {
@ -430,6 +592,7 @@ enum DiskStatus {
/// 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.
@ -441,6 +604,7 @@ struct SignedCacheFacts {
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
@ -451,6 +615,7 @@ enum SignedCacheEvaluation {
SidecarUnreadable,
Facts(SignedCacheFacts),
}
/// Read the sidecar, verify it against `trusted_keys`, reduce to a [`SignedCacheEvaluation`].
fn evaluate_signed_cache(
home: &std::path::Path,
@ -479,6 +644,7 @@ fn evaluate_signed_cache(
},
})
}
/// Verdict of the signed-sidecar check for the load-time gate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignedVerdict {
@ -508,6 +674,7 @@ pub enum SignedVerdict {
/// 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);
@ -524,6 +691,7 @@ pub fn signed_cache_compromised(
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,
@ -535,12 +703,18 @@ fn signed_cache_compromised_with_keys(
match evaluate_signed_cache(home, trusted_keys, expected_principal, now_unix) {
Eval::NoAuthenticSidecar => SignedVerdict::NoAuthenticSidecar,
Eval::SidecarUnreadable => SignedVerdict::SidecarUnreadable,
// Identity precedes the fail_closed short-circuit: a foreign-bound but
// permissive policy can't be replayed to escape a strict one offline.
Eval::Facts(f) if !f.identity_ok => SignedVerdict::Compromised,
Eval::Facts(f) if !f.fail_closed => SignedVerdict::Trusted,
// Opted-in and bound to us: expired or tampered-on-disk refuses; an
// Unreadable blip does not.
Eval::Facts(f) if f.expired || f.disk == DiskStatus::Mismatch => SignedVerdict::Compromised,
Eval::Facts(_) => SignedVerdict::Trusted,
}
}
// Tests in a sibling file (they dwarf the module) but a child module, for private access.
#[cfg(test)]
#[path = "signed_policy/tests.rs"]
mod tests;

View file

@ -490,23 +490,76 @@ fn sidecar_round_trips_on_disk() {
}
#[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());
fn verification_armed_with_embedded_key() {
// Armed: prod v1 key compiled in.
assert!(verification_active());
assert_eq!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS.len(), 1);
assert_eq!(EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS[0].0, "v1");
assert!(embedded_key_id_trusted("v1"));
assert!(!embedded_key_id_trusted("v0"));
// Fingerprint pin against silent typos.
let digest = ring::digest::digest(
&ring::digest::SHA256,
EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS[0].1,
);
let hex: String = digest.as_ref().iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex, EMBEDDED_V1_PUBKEY_SHA256_HEX,
"embedded v1 pubkey bytes must match the documented SHA-256 fingerprint"
);
}
/// Dark build: the public gate is false even with a policy on disk and no sidecar.
/// Empty seam → verification off (incident-disarm shape).
#[test]
fn cloud_cache_signature_invalid_is_false_when_dark() {
fn with_dark_forces_keyless_verification_inactive() {
test_seam::with_dark(|| {
assert!(
!verification_active(),
"Some(&[]) must force the keyless build for rollback tests"
);
assert!(!embedded_key_id_trusted("v1"));
});
// restored
assert!(verification_active());
}
/// Armed: flags missing/untrusted sidecar; nothing on disk → not invalid.
#[test]
fn cloud_cache_signature_invalid_when_armed() {
let dir = tempfile::tempdir().unwrap();
write_policy(dir.path(), &payload());
assert!(!verification_active());
assert!(verification_active());
assert!(!cloud_cache_signature_invalid(
dir.path(),
Some("team-007"),
1_000
));
write_policy(dir.path(), &payload());
assert!(cloud_cache_signature_invalid(
dir.path(),
Some("team-007"),
1_000
));
let (kp, _) = test_keypair();
write_sidecar(dir.path(), &sign(&kp, &payload())).unwrap();
assert!(cloud_cache_signature_invalid(
dir.path(),
Some("team-007"),
1_000
));
}
/// Keyless: public gate inert with unsigned policy on disk.
#[test]
fn cloud_cache_signature_invalid_inert_when_dark() {
test_seam::with_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),
"dark build must not flag unsigned on-disk policy"
);
});
}
/// No policy on disk → nothing to verify → not invalid.
@ -813,10 +866,24 @@ fn signed_cache_compromised_rejects_foreign_permissive_policy() {
);
}
/// Dark build: the public entry reads Inactive even with an authentic, opted-in,
/// tampered cache on disk — the marker path then decides.
/// Keyless: public entry → Inactive.
#[test]
fn signed_cache_compromised_is_inactive_when_dark() {
fn signed_cache_compromised_inactive_when_dark() {
test_seam::with_dark(|| {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
write_policy(home, &payload());
assert!(!verification_active());
assert_eq!(
signed_cache_compromised(home, Some("team-007"), 1_000),
SignedVerdict::Inactive
);
});
}
/// Armed: foreign key → NoAuthenticSidecar (never Inactive).
#[test]
fn signed_cache_compromised_is_no_authentic_sidecar_when_armed() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let (kp, _) = test_keypair();
@ -826,15 +893,10 @@ fn signed_cache_compromised_is_inactive_when_dark() {
};
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!(verification_active());
assert_eq!(
signed_cache_compromised(home, Some("team-007"), 1_000),
SignedVerdict::Inactive
SignedVerdict::NoAuthenticSidecar
);
}
@ -967,3 +1029,61 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() {
// 1k-line mark); same private access via the #[path] include below.
#[path = "claim_tests.rs"]
mod claim_tests;
/// Serialize tests that mutate the process-global kill-switch / key seam.
fn with_remote_disarm_lock<R>(f: impl FnOnce() -> R) -> R {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
#[test]
fn remote_kill_switch_dark_embed_stays_inactive() {
with_remote_disarm_lock(|| {
// Forced dark: inactive regardless of kill-switch (prod embed is keyed).
test_seam::with_dark(|| {
apply_remote_managed_config_signature_verification(Some(true), true);
assert!(!verification_active());
apply_remote_managed_config_signature_verification(Some(false), true);
assert!(!verification_active());
apply_remote_managed_config_signature_verification(None, true);
assert!(!verification_active());
});
});
}
/// With keys embedded (prod pin), disarm flips verification off and re-arm restores it.
/// Untrusted origin cannot disarm.
#[test]
fn remote_kill_switch_with_keys_disarms_and_rearms() {
with_remote_disarm_lock(|| {
apply_remote_managed_config_signature_verification(Some(true), true);
assert!(
verification_active(),
"keys embedded + armed must be verification_active"
);
apply_remote_managed_config_signature_verification(Some(false), true);
assert!(
!verification_active(),
"trusted Some(false) must disarm keyed verification"
);
apply_remote_managed_config_signature_verification(Some(true), true);
assert!(
verification_active(),
"Some(true) must re-arm keyed verification"
);
apply_remote_managed_config_signature_verification(Some(false), false);
assert!(
verification_active(),
"untrusted Some(false) must not disarm when keys are embedded"
);
apply_remote_managed_config_signature_verification(None, true);
assert!(verification_active());
});
}