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:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue