Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -1,5 +1,5 @@
|
|||
//! Atomic file writes, shared by the managed-cache marker and the signature
|
||||
//! sidecar writers.
|
||||
//! Atomic file writes, shared by the managed-cache marker, the signature
|
||||
//! sidecar, and downstream identifier caches (e.g. the telemetry agent id).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ use std::path::Path;
|
|||
/// name is unique per writer (pid + counter) and `create_new`, so concurrent
|
||||
/// writers don't collide. `mode` (unix only) is applied at temp-file creation, so
|
||||
/// the final file never exists with looser permissions.
|
||||
pub(crate) fn write_atomically(
|
||||
pub fn write_atomically(
|
||||
final_path: &Path,
|
||||
contents: &str,
|
||||
mode: Option<u32>,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
pub mod campaigns;
|
||||
pub mod config_override;
|
||||
mod fs_atomic;
|
||||
pub mod fs_atomic;
|
||||
mod loader;
|
||||
mod macos_managed;
|
||||
mod managed_cache;
|
||||
|
|
@ -40,8 +40,9 @@ pub use loader::{
|
|||
};
|
||||
pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
|
||||
pub use managed_cache::{
|
||||
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, confirmed_team_switch,
|
||||
confirmed_team_switch_at, is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor,
|
||||
bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at,
|
||||
is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
|
||||
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
|
||||
};
|
||||
|
|
@ -51,8 +52,8 @@ pub use paths::{
|
|||
grok_application_in, grok_home, sessions_cwd_dir, system_config_dir, user_grok_home,
|
||||
};
|
||||
pub use validation::{
|
||||
RequirementsError, RequirementsLayer, RequirementsSource, fail_closed_flag_from_str,
|
||||
load_merged_requirements, requirements_layers, validate_requirements,
|
||||
RequirementsError, RequirementsLayer, RequirementsSource, load_merged_requirements,
|
||||
requirements_layers, validate_requirements,
|
||||
};
|
||||
pub use version_overrides::{VersionOverrideError, apply_version_overrides};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ struct ManagedConfigCache {
|
|||
/// Served opt-in (`fail_closed = true`); `default` false so a pre-upgrade or un-opted marker never fails closed.
|
||||
#[serde(default)]
|
||||
fail_closed: bool,
|
||||
/// Local-clock high-water mark. At-rest signed checks use `max(now, floor)` so a
|
||||
/// rolled-back clock cannot un-expire a policy. Session starts and the background
|
||||
/// tick raise it; a successful fetch resets it to `now` (reconnect heals a
|
||||
/// forward-clock-inflated floor). As forgeable as the rest of the marker — defeats
|
||||
/// a passive clock change, not a file edit.
|
||||
#[serde(default)]
|
||||
rollback_floor: u64,
|
||||
/// Fields written by newer binaries, preserved when this binary rewrites only the
|
||||
/// floor. A full sync rewrites the marker from scratch.
|
||||
#[serde(flatten)]
|
||||
extra: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// What the cache is bound to (one value, so a (team, key) combo can't form). The
|
||||
|
|
@ -110,6 +121,13 @@ pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
|
|||
had_requirements,
|
||||
key_fingerprint: normalize_identity(key_fingerprint),
|
||||
fail_closed,
|
||||
// Reset (not max): reconnect must clear an inflated floor. Residual: fetch
|
||||
// verify is unclamped and managed_config_url is user-writable, so a rolled-back
|
||||
// clock plus a still-valid replayed envelope can reinstate a superseded policy
|
||||
// and reset the floor; that path does not self-heal online. A server-side
|
||||
// policy-version counter is the eventual close.
|
||||
rollback_floor: synced_at.unwrap_or(0),
|
||||
extra: Default::default(),
|
||||
};
|
||||
match serde_json::to_string(&cache) {
|
||||
Ok(json) => write_marker_atomically(home, &json),
|
||||
|
|
@ -117,6 +135,37 @@ pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Raise an existing marker's floor to the wall clock. Dark build → no-op. Caller holds
|
||||
/// the managed-config lock so this serializes with the fetch-path floor reset.
|
||||
pub fn bump_rollback_floor(home: &Path) {
|
||||
bump_rollback_floor_with_now(home, crate::signed_policy::now_unix());
|
||||
}
|
||||
|
||||
/// Test seam for [`bump_rollback_floor`] with an injected timestamp.
|
||||
#[doc(hidden)]
|
||||
pub fn bump_rollback_floor_with_now(home: &Path, now: u64) {
|
||||
if !crate::signed_policy::verification_active() {
|
||||
return;
|
||||
}
|
||||
raise_rollback_floor(home, now);
|
||||
}
|
||||
|
||||
/// `max(prior, now)` — never lowers, never creates a marker (purge must stay purged).
|
||||
fn raise_rollback_floor(home: &Path, now: u64) {
|
||||
let Some(mut cache) = read_managed_config_cache(home) else {
|
||||
return;
|
||||
};
|
||||
let raised = cache.rollback_floor.max(now);
|
||||
if raised == cache.rollback_floor {
|
||||
return;
|
||||
}
|
||||
cache.rollback_floor = raised;
|
||||
match serde_json::to_string(&cache) {
|
||||
Ok(json) => write_marker_atomically(home, &json),
|
||||
Err(e) => tracing::warn!("failed to serialize managed config cache: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic write of the marker; best-effort (failure is logged, never surfaced).
|
||||
fn write_marker_atomically(home: &Path, json: &str) {
|
||||
if let Err(e) =
|
||||
|
|
@ -300,19 +349,34 @@ fn expected_signed_principal<'a>(
|
|||
serving_team_id(identity).or_else(|| cache.and_then(|c| c.principal.as_deref()))
|
||||
}
|
||||
|
||||
/// At-rest signed checks: `max(wall clock, floor)`. Fetch-time verify stays unclamped
|
||||
/// so a fresh envelope can reset an inflated floor (see shell `verify_signed_envelope`).
|
||||
fn effective_now(cache: Option<&ManagedConfigCache>) -> u64 {
|
||||
crate::signed_policy::now_unix().max(cache.map_or(0, |c| c.rollback_floor))
|
||||
}
|
||||
|
||||
/// A signing-enabled build over a legacy unsigned / edited / forged or foreign-bound
|
||||
/// cache refetches a signed copy. Dark build or no policy on disk → false, so this is
|
||||
/// inert until a key is provisioned.
|
||||
/// 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.
|
||||
fn signed_cache_needs_refetch(
|
||||
home: &Path,
|
||||
cache: Option<&ManagedConfigCache>,
|
||||
identity: &ServingIdentity,
|
||||
) -> bool {
|
||||
crate::signed_policy::cloud_cache_signature_invalid(
|
||||
home,
|
||||
expected_signed_principal(cache, identity),
|
||||
crate::signed_policy::now_unix(),
|
||||
)
|
||||
let expected_principal = expected_signed_principal(cache, identity);
|
||||
let now = effective_now(cache);
|
||||
// Verdict match first: Trusted short-circuits the claim's read + verify.
|
||||
crate::signed_policy::cloud_cache_signature_invalid(home, expected_principal, now)
|
||||
|| (matches!(
|
||||
crate::signed_policy::signed_cache_compromised(home, expected_principal, now),
|
||||
crate::signed_policy::SignedVerdict::NoAuthenticSidecar
|
||||
| crate::signed_policy::SignedVerdict::SidecarUnreadable
|
||||
) && crate::signed_policy::managed_identity_claim_imposes(
|
||||
home,
|
||||
expected_principal,
|
||||
now,
|
||||
))
|
||||
}
|
||||
|
||||
fn is_managed_config_hard_stale_for_at(home: &Path, identity: &ServingIdentity) -> bool {
|
||||
|
|
@ -368,11 +432,10 @@ fn managed_policy_compromised_once(
|
|||
identity: &ServingIdentity,
|
||||
) -> (bool, crate::signed_policy::SignedVerdict) {
|
||||
let cache = read_managed_config_cache(home);
|
||||
let signed_verdict = crate::signed_policy::signed_cache_compromised(
|
||||
home,
|
||||
expected_signed_principal(cache.as_ref(), identity),
|
||||
crate::signed_policy::now_unix(),
|
||||
);
|
||||
let expected_principal = expected_signed_principal(cache.as_ref(), identity);
|
||||
let now = effective_now(cache.as_ref());
|
||||
let signed_verdict =
|
||||
crate::signed_policy::signed_cache_compromised(home, expected_principal, now);
|
||||
// The signature binds a deployment_id, not the local deploy key, so a Trusted verdict
|
||||
// can't attest the configured key — pass the fingerprint mismatch through so it gates
|
||||
// on every path.
|
||||
|
|
@ -381,6 +444,7 @@ fn managed_policy_compromised_once(
|
|||
.is_some_and(|c| cache_key_fingerprint_mismatch(c, identity));
|
||||
let compromised = managed_policy_compromised_decision(
|
||||
signed_verdict,
|
||||
|| crate::signed_policy::managed_identity_claim_imposes(home, expected_principal, now),
|
||||
key_fingerprint_mismatch,
|
||||
cache.as_ref(),
|
||||
home,
|
||||
|
|
@ -392,8 +456,13 @@ fn managed_policy_compromised_once(
|
|||
/// Combine the signed verdict with the best-effort marker fallback — one row per
|
||||
/// verdict; each row's reasoning lives on its [`SignedVerdict`] variant doc. Split
|
||||
/// out so the signed↔marker integration is unit-testable without a compiled-in key.
|
||||
/// `claim_imposes` ([`crate::signed_policy::managed_identity_claim_imposes`]) is
|
||||
/// consulted lazily, only on `NoAuthenticSidecar`, and outranks the forgeable-marker
|
||||
/// fallbacks there — stripping the policy sidecar (even with a forged marker) cannot
|
||||
/// downgrade a claimed fail-closed principal. A read blip stays lenient.
|
||||
fn managed_policy_compromised_decision(
|
||||
signed_verdict: crate::signed_policy::SignedVerdict,
|
||||
claim_imposes: impl FnOnce() -> bool,
|
||||
key_fingerprint_mismatch: bool,
|
||||
cache: Option<&ManagedConfigCache>,
|
||||
home: &Path,
|
||||
|
|
@ -440,12 +509,24 @@ fn managed_policy_compromised_decision(
|
|||
SignedVerdict::Compromised => true,
|
||||
// Trusted clears the gate — except the deploy-key fingerprint, which the signature can't attest.
|
||||
SignedVerdict::Trusted => key_fingerprint_mismatch && marker_compromised(),
|
||||
SignedVerdict::NoAuthenticSidecar => sidecar_required_but_missing() || marker_compromised(),
|
||||
SignedVerdict::NoAuthenticSidecar => {
|
||||
let refused = claim_imposes();
|
||||
if refused {
|
||||
tracing::warn!(
|
||||
"managed policy fail-closed gate: refusing session — the signed is-managed \
|
||||
claim requires an authentic policy sidecar and none is present"
|
||||
);
|
||||
}
|
||||
refused || sidecar_required_but_missing() || marker_compromised()
|
||||
}
|
||||
SignedVerdict::SidecarUnreadable => marker_compromised(),
|
||||
SignedVerdict::Inactive => marker_compromised(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Same-machine marker: more than a few minutes of future skew is not genuine.
|
||||
const MAX_FUTURE_SYNCED_AT_SKEW: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Stale when never synced, past the threshold, identity differs, a served artifact is now missing,
|
||||
/// or (keyed builds) the signed cache no longer verifies. No home → nothing to refresh into → not
|
||||
/// stale. Reads the marker once.
|
||||
|
|
@ -465,13 +546,15 @@ fn managed_config_stale_at(home: Option<&Path>, identity: &ServingIdentity) -> b
|
|||
return true;
|
||||
}
|
||||
match cache.synced_at {
|
||||
// `duration_since` errs when `synced_at` is in the future (clock skew);
|
||||
// treat that as freshly synced rather than stale.
|
||||
Some(secs) => {
|
||||
let synced_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(synced_at)
|
||||
.is_ok_and(|age| age > managed_config_stale_threshold())
|
||||
// Against `effective_now` (max of wall clock and floor) so repeated small
|
||||
// rollbacks / a halted clock cannot keep age under the threshold forever.
|
||||
// u64 seconds avoid SystemTime overflow panics for out-of-range timestamps.
|
||||
let now = effective_now(Some(&cache));
|
||||
let age = now.saturating_sub(secs);
|
||||
let skew = secs.saturating_sub(now);
|
||||
age > managed_config_stale_threshold().as_secs()
|
||||
|| skew > MAX_FUTURE_SYNCED_AT_SKEW.as_secs()
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
|
|
|
|||
100
crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs
Normal file
100
crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
//! The is-managed claim in the gate decision and the staleness refetch
|
||||
//! (sidecar-removal downgrade closure).
|
||||
|
||||
use super::super::*;
|
||||
use super::team;
|
||||
|
||||
/// Headline: a stripped policy sidecar + imposing claim refuses even over a fully
|
||||
/// forged permissive marker; without the claim that state is the pre-fix downgrade.
|
||||
#[test]
|
||||
fn claim_refuses_stripped_sidecar_even_with_forged_marker() {
|
||||
use crate::signed_policy::SignedVerdict;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
// The forged-marker shape: permissive, nothing served, matching principal.
|
||||
let forged = ManagedConfigCache {
|
||||
principal: Some("team-007".into()),
|
||||
fail_closed: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| true,
|
||||
false,
|
||||
Some(&forged),
|
||||
home,
|
||||
&team("team-007")
|
||||
),
|
||||
"an imposing claim outranks the forged marker when the policy sidecar is gone"
|
||||
);
|
||||
assert!(
|
||||
!managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| false,
|
||||
false,
|
||||
Some(&forged),
|
||||
home,
|
||||
&team("team-007")
|
||||
),
|
||||
"without the claim this exact state is the (documented) marker downgrade"
|
||||
);
|
||||
}
|
||||
|
||||
/// A policy-sidecar read blip stays lenient: the claim is not consulted on
|
||||
/// `SidecarUnreadable` (rationale on the variant doc).
|
||||
#[test]
|
||||
fn claim_not_consulted_on_sidecar_read_blip() {
|
||||
use crate::signed_policy::SignedVerdict;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
|
||||
let served = ManagedConfigCache {
|
||||
principal: Some("team-007".into()),
|
||||
had_requirements: true,
|
||||
fail_closed: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!managed_policy_compromised_decision(
|
||||
SignedVerdict::SidecarUnreadable,
|
||||
|| true,
|
||||
false,
|
||||
Some(&served),
|
||||
home,
|
||||
&team("team-007")
|
||||
),
|
||||
"a transient sidecar read blip must not refuse, claim or no claim"
|
||||
);
|
||||
}
|
||||
|
||||
/// Dark build: a claim file on disk changes neither the gate nor staleness.
|
||||
#[test]
|
||||
fn claim_paths_are_inert_in_dark_build() {
|
||||
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),
|
||||
"{\"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"
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ fn signed_verdict_overrides_marker_both_ways() {
|
|||
// Signed says NOT compromised → proceed, overriding the marker's tamper signal.
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::Trusted,
|
||||
|| false,
|
||||
false,
|
||||
Some(&cache),
|
||||
home,
|
||||
|
|
@ -39,6 +40,7 @@ fn signed_verdict_overrides_marker_both_ways() {
|
|||
};
|
||||
assert!(managed_policy_compromised_decision(
|
||||
SignedVerdict::Compromised,
|
||||
|| false,
|
||||
false,
|
||||
Some(&intact),
|
||||
home,
|
||||
|
|
@ -65,6 +67,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() {
|
|||
// opted-in cache.
|
||||
assert!(managed_policy_compromised_decision(
|
||||
SignedVerdict::Trusted,
|
||||
|| false,
|
||||
true, // deploy-key fingerprint mismatch
|
||||
Some(&opted_in),
|
||||
home,
|
||||
|
|
@ -73,6 +76,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() {
|
|||
// A matching fingerprint trusts the signed verdict as before.
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::Trusted,
|
||||
|| false,
|
||||
false,
|
||||
Some(&opted_in),
|
||||
home,
|
||||
|
|
@ -87,6 +91,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() {
|
|||
};
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::Trusted,
|
||||
|| false,
|
||||
true,
|
||||
Some(&opted_out),
|
||||
home,
|
||||
|
|
@ -96,6 +101,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() {
|
|||
// this opted-OUT marker.
|
||||
assert!(managed_policy_compromised_decision(
|
||||
SignedVerdict::Compromised,
|
||||
|| false,
|
||||
true,
|
||||
Some(&opted_out),
|
||||
home,
|
||||
|
|
@ -122,6 +128,7 @@ fn unreadable_sidecar_falls_back_to_marker() {
|
|||
assert!(
|
||||
!managed_policy_compromised_decision(
|
||||
SignedVerdict::SidecarUnreadable,
|
||||
|| false,
|
||||
false,
|
||||
Some(&served_fail_closed),
|
||||
home,
|
||||
|
|
@ -133,6 +140,7 @@ fn unreadable_sidecar_falls_back_to_marker() {
|
|||
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
||||
assert!(managed_policy_compromised_decision(
|
||||
SignedVerdict::SidecarUnreadable,
|
||||
|| false,
|
||||
false,
|
||||
Some(&served_fail_closed),
|
||||
home,
|
||||
|
|
@ -159,6 +167,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() {
|
|||
assert!(
|
||||
managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| false,
|
||||
false,
|
||||
Some(&served_fail_closed),
|
||||
home,
|
||||
|
|
@ -174,6 +183,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() {
|
|||
};
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| false,
|
||||
false,
|
||||
Some(&served_nothing),
|
||||
home,
|
||||
|
|
@ -188,6 +198,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() {
|
|||
};
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| false,
|
||||
false,
|
||||
Some(&opted_out),
|
||||
home,
|
||||
|
|
@ -196,6 +207,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() {
|
|||
// No marker at all → nothing to enforce.
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
|| false,
|
||||
false,
|
||||
None,
|
||||
home,
|
||||
|
|
@ -219,6 +231,7 @@ fn inactive_verdict_falls_through_to_marker() {
|
|||
};
|
||||
assert!(managed_policy_compromised_decision(
|
||||
SignedVerdict::Inactive,
|
||||
|| false,
|
||||
false,
|
||||
Some(&missing),
|
||||
home,
|
||||
|
|
@ -233,6 +246,7 @@ fn inactive_verdict_falls_through_to_marker() {
|
|||
};
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::Inactive,
|
||||
|| false,
|
||||
false,
|
||||
Some(&optout),
|
||||
home,
|
||||
|
|
@ -241,6 +255,7 @@ fn inactive_verdict_falls_through_to_marker() {
|
|||
// No marker at all → nothing to enforce.
|
||||
assert!(!managed_policy_compromised_decision(
|
||||
SignedVerdict::Inactive,
|
||||
|| false,
|
||||
false,
|
||||
None,
|
||||
home,
|
||||
|
|
@ -1147,3 +1162,161 @@ fn cache_identity_mismatch_ignores_whitespace_only_diffs() {
|
|||
let empty = ManagedConfigCache::default();
|
||||
assert!(cache_identity_mismatch(&empty, &team("team-a")));
|
||||
}
|
||||
|
||||
/// Tick raises an existing floor, never lowers it, and never creates a marker.
|
||||
#[test]
|
||||
fn rollback_floor_ticks_up_never_down_and_never_creates_a_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor);
|
||||
|
||||
raise_rollback_floor(home, 5_000);
|
||||
assert!(
|
||||
read_managed_config_cache(home).is_none(),
|
||||
"the tick must not create a marker"
|
||||
);
|
||||
|
||||
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 base = floor(home);
|
||||
assert!(
|
||||
base >= 1_700_000_000,
|
||||
"a fetch seeds the floor at the wall clock"
|
||||
);
|
||||
|
||||
raise_rollback_floor(home, base + 1_000);
|
||||
assert_eq!(floor(home), base + 1_000);
|
||||
raise_rollback_floor(home, base);
|
||||
assert_eq!(floor(home), base + 1_000, "the tick never lowers the floor");
|
||||
}
|
||||
|
||||
/// The floor RMW preserves marker fields this binary doesn't know (mixed-version homes).
|
||||
#[test]
|
||||
fn floor_bump_preserves_unknown_marker_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
std::fs::write(
|
||||
home.join(MANAGED_CONFIG_CACHE_FILE),
|
||||
r#"{"synced_at":1700000000,"rollback_floor":1700000000,"from_the_future":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
raise_rollback_floor(home, 1_700_000_100);
|
||||
let marker = std::fs::read_to_string(home.join(MANAGED_CONFIG_CACHE_FILE)).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(v["rollback_floor"].as_u64(), Some(1_700_000_100));
|
||||
assert_eq!(
|
||||
v["from_the_future"],
|
||||
serde_json::Value::Bool(true),
|
||||
"the RMW must not strip fields a newer binary wrote: {marker}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Successful fetch resets (never maxes) an inflated floor to the wall clock.
|
||||
#[test]
|
||||
fn fetch_resets_an_inflated_rollback_floor() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
std::fs::write(
|
||||
home.join(MANAGED_CONFIG_CACHE_FILE),
|
||||
r#"{"rollback_floor":9999999999}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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 = read_managed_config_cache(home).map_or(0, |c| c.rollback_floor);
|
||||
assert!(
|
||||
(1_700_000_000..9_999_999_999).contains(&floor),
|
||||
"the fetch must reset the inflated floor to the wall clock, got {floor}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Dark build: public tick is a no-op over an existing marker.
|
||||
#[test]
|
||||
fn bump_rollback_floor_is_inert_when_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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Far-future `synced_at` is stale; modest forward skew stays fresh.
|
||||
#[test]
|
||||
fn managed_config_stale_for_far_future_sync() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
// ~year 3000: beyond the skew allowance.
|
||||
std::fs::write(
|
||||
home.join(MANAGED_CONFIG_CACHE_FILE),
|
||||
"{\"synced_at\":32503680000}",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
managed_config_stale_at(Some(home), &ServingIdentity::None),
|
||||
"a far-future synced_at must not freeze the refetch timer"
|
||||
);
|
||||
|
||||
// Past `SystemTime`'s range: must read stale, not panic (would kill the sync task).
|
||||
std::fs::write(
|
||||
home.join(MANAGED_CONFIG_CACHE_FILE),
|
||||
format!("{{\"synced_at\":{}}}", u64::MAX),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
managed_config_stale_at(Some(home), &ServingIdentity::None),
|
||||
"an out-of-range synced_at reads stale"
|
||||
);
|
||||
|
||||
let in_a_minute = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 60;
|
||||
std::fs::write(
|
||||
home.join(MANAGED_CONFIG_CACHE_FILE),
|
||||
format!("{{\"synced_at\":{in_a_minute}}}"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!managed_config_stale_at(Some(home), &ServingIdentity::None),
|
||||
"a minute of genuine clock skew still reads fresh"
|
||||
);
|
||||
}
|
||||
|
||||
// The is-managed claim gate tests live in a sibling child module (this file is
|
||||
// past the 1k-line mark); same private access via the #[path] include below.
|
||||
#[path = "claim_tests.rs"]
|
||||
mod claim_tests;
|
||||
|
|
|
|||
|
|
@ -8,10 +8,15 @@
|
|||
//! Inert until a public key is provisioned: with no embedded keys the cache
|
||||
//! marker stays the (best-effort) authority.
|
||||
use base64::Engine;
|
||||
pub use prod_mc_cli_chat_proxy_types::{SignatureEnvelope, SignedPayload, now_unix};
|
||||
pub use prod_mc_cli_chat_proxy_types::{
|
||||
MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP, ManagedIdentityClaim, SignatureEnvelope,
|
||||
SignedPayload, now_unix,
|
||||
};
|
||||
/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one
|
||||
/// entry only during a rotation. Empty ships dark (see [`verification_active`]).
|
||||
/// Compile-time, not an env flag: the local attacker controls their env.
|
||||
/// 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])] = &[];
|
||||
const _: () = {
|
||||
let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS;
|
||||
|
|
@ -57,6 +62,9 @@ fn with_embedded_keys<R>(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R {
|
|||
}
|
||||
/// 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")]
|
||||
|
|
@ -65,6 +73,8 @@ pub enum SigError {
|
|||
SignatureMismatch,
|
||||
#[error("signed payload is not valid JSON")]
|
||||
BadPayload,
|
||||
#[error("signed payload carries the wrong message type")]
|
||||
WrongType,
|
||||
#[error("signed payload names a key_id outside the trusted set")]
|
||||
UnknownKeyId,
|
||||
#[error("signed policy is bound to a different principal")]
|
||||
|
|
@ -92,7 +102,8 @@ pub fn embedded_key_id_trusted(key_id: &str) -> bool {
|
|||
/// Verify `signature_b64` over `signed_payload` against `trusted_keys`, returning the
|
||||
/// parsed payload. The verifying key is selected by the SIGNED payload's `key_id` —
|
||||
/// safe to read pre-verification because selection can only land within the trusted
|
||||
/// set (a forged id either misses or picks a key the signature won't match). Pure:
|
||||
/// set (a forged id either misses or picks a key the signature won't match). Requires
|
||||
/// the [`MANAGED_POLICY_TYP`] tag (a claim must never verify as a policy). Pure:
|
||||
/// callers supply the keys so tests can use throwaway keypairs.
|
||||
pub fn verify_signed_payload(
|
||||
signed_payload: &str,
|
||||
|
|
@ -101,17 +112,43 @@ pub fn verify_signed_payload(
|
|||
) -> Result<SignedPayload, SigError> {
|
||||
let payload: SignedPayload =
|
||||
serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?;
|
||||
verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &payload.key_id)?;
|
||||
if payload.typ != MANAGED_POLICY_TYP {
|
||||
return Err(SigError::WrongType);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
/// [`verify_signed_payload`]'s mirror for claims (requires [`MANAGED_IDENTITY_TYP`]).
|
||||
pub fn verify_managed_identity_claim(
|
||||
signed_payload: &str,
|
||||
signature_b64: &str,
|
||||
trusted_keys: &[(&str, &[u8])],
|
||||
) -> Result<ManagedIdentityClaim, SigError> {
|
||||
let claim: ManagedIdentityClaim =
|
||||
serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?;
|
||||
verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &claim.key_id)?;
|
||||
if claim.typ != MANAGED_IDENTITY_TYP {
|
||||
return Err(SigError::WrongType);
|
||||
}
|
||||
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,
|
||||
signature_b64: &str,
|
||||
trusted_keys: &[(&str, &[u8])],
|
||||
key_id: &str,
|
||||
) -> Result<(), SigError> {
|
||||
let (_, public_key) = trusted_keys
|
||||
.iter()
|
||||
.find(|(id, _)| *id == payload.key_id)
|
||||
.find(|(id, _)| *id == key_id)
|
||||
.ok_or(SigError::UnknownKeyId)?;
|
||||
let sig = base64::engine::general_purpose::STANDARD
|
||||
.decode(signature_b64.trim())
|
||||
.map_err(|_| SigError::BadSignatureEncoding)?;
|
||||
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
|
||||
.verify(signed_payload.as_bytes(), &sig)
|
||||
.map_err(|_| SigError::SignatureMismatch)?;
|
||||
Ok(payload)
|
||||
.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
|
||||
|
|
@ -159,6 +196,26 @@ 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,
|
||||
now_unix: u64,
|
||||
) -> 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,
|
||||
trusted_keys: &[(&str, &[u8])],
|
||||
now_unix: u64,
|
||||
) -> Result<ManagedIdentityClaim, SigError> {
|
||||
let claim =
|
||||
verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys)?;
|
||||
if now_unix > claim.expires_at {
|
||||
return Err(SigError::Expired);
|
||||
}
|
||||
Ok(claim)
|
||||
}
|
||||
/// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs.
|
||||
fn verify_fetched_with_keys(
|
||||
sidecar: &SignatureEnvelope,
|
||||
|
|
@ -233,11 +290,13 @@ enum SidecarRead {
|
|||
Unreadable,
|
||||
}
|
||||
fn read_sidecar(home: &std::path::Path) -> SidecarRead {
|
||||
let path = sidecar_path(home);
|
||||
if non_regular_file_at(&path) {
|
||||
read_envelope_at(&sidecar_path(home))
|
||||
}
|
||||
fn read_envelope_at(path: &std::path::Path) -> SidecarRead {
|
||||
if non_regular_file_at(path) {
|
||||
return SidecarRead::Absent;
|
||||
}
|
||||
let json = match std::fs::read_to_string(&path) {
|
||||
let json = match std::fs::read_to_string(path) {
|
||||
Ok(json) => json,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SidecarRead::Absent,
|
||||
Err(_) => return SidecarRead::Unreadable,
|
||||
|
|
@ -251,9 +310,60 @@ fn read_sidecar(home: &std::path::Path) -> SidecarRead {
|
|||
/// 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,
|
||||
sidecar: &SignatureEnvelope,
|
||||
) -> 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(&sidecar_path(home), &json, Some(0o600))
|
||||
crate::fs_atomic::write_atomically(path, &json, Some(0o600))
|
||||
}
|
||||
/// 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
|
||||
/// now-fail_closed marker), unknown principal (a planted claim must not brick a
|
||||
/// signed-out victim), foreign, expired, forged, or absent.
|
||||
pub fn managed_identity_claim_imposes(
|
||||
home: &std::path::Path,
|
||||
expected_principal: Option<&str>,
|
||||
now_unix: u64,
|
||||
) -> bool {
|
||||
if !verification_active() {
|
||||
return false;
|
||||
}
|
||||
with_embedded_keys(|keys| {
|
||||
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,
|
||||
trusted_keys: &[(&str, &[u8])],
|
||||
expected_principal: Option<&str>,
|
||||
now_unix: u64,
|
||||
) -> bool {
|
||||
let Some(expected) = expected_principal else {
|
||||
return false;
|
||||
};
|
||||
let SidecarRead::Present(sidecar) = read_envelope_at(&managed_identity_sidecar_path(home))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claim) =
|
||||
verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
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.
|
||||
|
|
@ -367,6 +477,8 @@ pub enum SignedVerdict {
|
|||
/// The sidecar exists but a transient IO error (EACCES-style, never plain absence
|
||||
/// or a squatting non-file) blocked the read. Not tamper evidence: the gate falls
|
||||
/// back to the marker decision, and the refetch trigger fires to rewrite it.
|
||||
/// The claim is deliberately NOT consulted here: a genuine blip must not
|
||||
/// refuse, and a chmod-capable attacker could delete the claim anyway.
|
||||
SidecarUnreadable,
|
||||
/// Authentic sidecar; the policy is valid for this principal (or never opted into
|
||||
/// fail-closed enforcement).
|
||||
|
|
|
|||
228
crates/codegen/xai-grok-config/src/signed_policy/claim_tests.rs
Normal file
228
crates/codegen/xai-grok-config/src/signed_policy/claim_tests.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! The server-signed is-managed claim: verifiers, domain separation, and the
|
||||
//! impose/defer signal (sidecar-removal downgrade closure).
|
||||
|
||||
use super::super::*;
|
||||
use super::{keyset, payload, sign, test_keypair};
|
||||
|
||||
fn claim(principal: &str, fail_closed: bool, expires_at: u64) -> ManagedIdentityClaim {
|
||||
ManagedIdentityClaim {
|
||||
typ: MANAGED_IDENTITY_TYP.into(),
|
||||
principal: principal.into(),
|
||||
fail_closed,
|
||||
expires_at,
|
||||
key_id: "v1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_claim(
|
||||
kp: &ring::signature::Ed25519KeyPair,
|
||||
claim: &ManagedIdentityClaim,
|
||||
) -> SignatureEnvelope {
|
||||
let signed_payload = serde_json::to_string(claim).unwrap();
|
||||
let sig = kp.sign(signed_payload.as_bytes());
|
||||
SignatureEnvelope {
|
||||
signed_payload,
|
||||
signature: base64::engine::general_purpose::STANDARD.encode(sig.as_ref()),
|
||||
key_id: claim.key_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_claim(home: &std::path::Path, sidecar: &SignatureEnvelope) {
|
||||
write_managed_identity_sidecar(home, sidecar).unwrap();
|
||||
}
|
||||
|
||||
/// The required `typ` closes signature confusion: neither message type substitutes
|
||||
/// for the other, even genuinely signed by the same key.
|
||||
#[test]
|
||||
fn domain_separation_rejects_cross_type_substitution() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
|
||||
// An authentic identity claim must NOT verify as a policy payload.
|
||||
let claim_sidecar = sign_claim(&kp, &claim("team-007", true, 4_000_000_000));
|
||||
assert_eq!(
|
||||
verify_signed_payload(
|
||||
&claim_sidecar.signed_payload,
|
||||
&claim_sidecar.signature,
|
||||
&keys
|
||||
),
|
||||
Err(SigError::WrongType),
|
||||
"an identity claim must be rejected by the policy verifier"
|
||||
);
|
||||
|
||||
// End-to-end: the authentic claim copied over the policy sidecar (policy files
|
||||
// deleted) must read NoAuthenticSidecar, never Trusted — the pre-fix exploit
|
||||
// started such a fail_closed principal unmanaged.
|
||||
std::fs::write(
|
||||
sidecar_path(home),
|
||||
serde_json::to_string(&claim_sidecar).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
signed_cache_compromised_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
SignedVerdict::NoAuthenticSidecar,
|
||||
"a substituted claim is not an authentic policy verdict"
|
||||
);
|
||||
|
||||
// Reverse: a policy envelope must not verify as a claim (its shape has no
|
||||
// `principal`, so it fails at parse).
|
||||
let policy_sidecar = sign(&kp, &payload());
|
||||
assert_eq!(
|
||||
verify_managed_identity_claim(
|
||||
&policy_sidecar.signed_payload,
|
||||
&policy_sidecar.signature,
|
||||
&keys
|
||||
),
|
||||
Err(SigError::BadPayload),
|
||||
"a policy envelope must be rejected by the claim verifier"
|
||||
);
|
||||
|
||||
// And a claim-shaped blob carrying the POLICY tag trips the typ guard itself.
|
||||
let mut wrong_typ = claim("team-007", true, 4_000_000_000);
|
||||
wrong_typ.typ = MANAGED_POLICY_TYP.into();
|
||||
let bad = sign_claim(&kp, &wrong_typ);
|
||||
assert_eq!(
|
||||
verify_managed_identity_claim(&bad.signed_payload, &bad.signature, &keys),
|
||||
Err(SigError::WrongType)
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim imposes ONLY when authentic + bound + fail_closed; permissive,
|
||||
/// foreign, unknown-principal, forged, and absent claims are all silent.
|
||||
#[test]
|
||||
fn claim_imposes_only_for_bound_fail_closed_claim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
|
||||
// Absent → silent.
|
||||
assert!(!managed_identity_claim_imposes_with_keys(
|
||||
home,
|
||||
&keys,
|
||||
Some("team-007"),
|
||||
1_000
|
||||
));
|
||||
|
||||
write_claim(
|
||||
home,
|
||||
&sign_claim(&kp, &claim("team-007", true, 4_000_000_000)),
|
||||
);
|
||||
assert!(
|
||||
managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
"an authentic bound fail_closed claim imposes"
|
||||
);
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-evil"), 1_000),
|
||||
"a claim for another principal must not bind us"
|
||||
);
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, None, 1_000),
|
||||
"an unbindable claim must not gate startup"
|
||||
);
|
||||
|
||||
write_claim(
|
||||
home,
|
||||
&sign_claim(&kp, &claim("team-007", false, 4_000_000_000)),
|
||||
);
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
"a permissive claim defers to the marker"
|
||||
);
|
||||
|
||||
let mut forged = sign_claim(&kp, &claim("team-007", true, 4_000_000_000));
|
||||
forged.signature = base64::engine::general_purpose::STANDARD.encode([0u8; 64]);
|
||||
write_claim(home, &forged);
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
"a forged claim imposes nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// An expired claim is silent (callers pass the floor-clamped now, so a rolled-back
|
||||
/// clock cannot un-expire it).
|
||||
#[test]
|
||||
fn expired_claim_is_silent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
write_claim(home, &sign_claim(&kp, &claim("team-007", true, 2_000)));
|
||||
|
||||
assert!(managed_identity_claim_imposes_with_keys(
|
||||
home,
|
||||
&keys,
|
||||
Some("team-007"),
|
||||
1_000
|
||||
));
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 3_000),
|
||||
"past expiry → silent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch-time claim verification enforces expiry (the persist gate).
|
||||
#[test]
|
||||
fn verify_fetched_claim_rejects_expired() {
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
let sidecar = sign_claim(&kp, &claim("team-007", true, 2_000));
|
||||
assert!(verify_fetched_claim_with_keys(&sidecar, &keys, 1_000).is_ok());
|
||||
assert_eq!(
|
||||
verify_fetched_claim_with_keys(&sidecar, &keys, 3_000),
|
||||
Err(SigError::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
/// Corrupt claim bytes read as Absent (not Present): impose is silent, never
|
||||
/// refuses on garbage — same read_envelope_at path as the policy sidecar.
|
||||
#[test]
|
||||
fn corrupt_claim_file_is_silent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
std::fs::write(managed_identity_sidecar_path(home), "{not-json").unwrap();
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
"unparseable claim bytes must not impose"
|
||||
);
|
||||
// A half-shaped envelope (missing signature fields) is the same Absent path.
|
||||
std::fs::write(managed_identity_sidecar_path(home), "{\"key_id\":\"v1\"}").unwrap();
|
||||
assert!(!managed_identity_claim_imposes_with_keys(
|
||||
home,
|
||||
&keys,
|
||||
Some("team-007"),
|
||||
1_000
|
||||
));
|
||||
// Sanity: a real claim at the same path still imposes.
|
||||
write_claim(
|
||||
home,
|
||||
&sign_claim(&kp, &claim("team-007", true, 4_000_000_000)),
|
||||
);
|
||||
assert!(managed_identity_claim_imposes_with_keys(
|
||||
home,
|
||||
&keys,
|
||||
Some("team-007"),
|
||||
1_000
|
||||
));
|
||||
}
|
||||
|
||||
/// A directory squatting the claim slot is Absent (non-regular), never an
|
||||
/// imposing claim — and never the lenient Unreadable blip (that is EACCES on a
|
||||
/// regular file only).
|
||||
#[test]
|
||||
fn directory_squatting_claim_slot_is_silent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let (_kp, pubkey) = test_keypair();
|
||||
let keys = keyset("v1", &pubkey);
|
||||
let path = managed_identity_sidecar_path(home);
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
assert!(
|
||||
!managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000),
|
||||
"a directory at the claim path must not impose"
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ fn sign(kp: &ring::signature::Ed25519KeyPair, payload: &SignedPayload) -> Signat
|
|||
|
||||
fn payload() -> SignedPayload {
|
||||
SignedPayload {
|
||||
typ: MANAGED_POLICY_TYP.into(),
|
||||
version: 1,
|
||||
deployment_id: None,
|
||||
team_id: Some("team-007".into()),
|
||||
|
|
@ -54,6 +55,7 @@ fn write_policy(home: &std::path::Path, p: &SignedPayload) {
|
|||
fn server_wire_format_is_client_verifiable() {
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let signed_payload = serde_json::json!({
|
||||
"typ": "grok.managed_policy.v1",
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": "team-007",
|
||||
"managed_config": "[cli]\n",
|
||||
|
|
@ -82,6 +84,7 @@ fn server_wire_format_is_client_verifiable() {
|
|||
fn missing_fail_closed_defaults_false() {
|
||||
let (kp, pubkey) = test_keypair();
|
||||
let signed_payload = serde_json::json!({
|
||||
"typ": "grok.managed_policy.v1",
|
||||
"team_id": "team-007",
|
||||
"expires_at": 4_000_000_000u64,
|
||||
"key_id": "v1",
|
||||
|
|
@ -956,3 +959,8 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() {
|
|||
Err(SigError::SignatureMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
// The is-managed claim tests live in a sibling child module (this file is at the
|
||||
// 1k-line mark); same private access via the #[path] include below.
|
||||
#[path = "claim_tests.rs"]
|
||||
mod claim_tests;
|
||||
|
|
|
|||
|
|
@ -8,19 +8,21 @@ use crate::paths::{system_config_dir, user_grok_home};
|
|||
use crate::version_overrides::{VersionOverrideError, apply_version_overrides};
|
||||
|
||||
use prod_mc_cli_chat_proxy_types::FAIL_CLOSED_KEY;
|
||||
/// The canonical opt-in key + string parse live in the shared types crate, next to
|
||||
/// the signed payload that carries the flag, so the server-side signer and this
|
||||
/// client parse the same semantics.
|
||||
pub use prod_mc_cli_chat_proxy_types::fail_closed_flag_from_str;
|
||||
|
||||
/// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as
|
||||
/// [`fail_closed_flag_from_str`]. Env tightening (file vs `GROK_MANAGED_CONFIG_FAIL_CLOSED`)
|
||||
/// is layered on top by [`resolve_fail_closed_mode`], not here.
|
||||
/// `fail_closed` from a requirements table; non-bool → warn once and treat as false.
|
||||
fn fail_closed_flag(requirements: &toml::Value) -> bool {
|
||||
requirements
|
||||
.get(FAIL_CLOSED_KEY)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
use prod_mc_cli_chat_proxy_types::{FailClosedFlag, fail_closed_flag_status_from_value};
|
||||
let status = fail_closed_flag_status_from_value(requirements);
|
||||
if matches!(status, FailClosedFlag::Invalid) {
|
||||
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
|
||||
WARN_ONCE.call_once(|| {
|
||||
tracing::warn!(
|
||||
"requirements fail_closed is present but not a boolean \
|
||||
(e.g. fail_closed = \"true\"); treating as false - use fail_closed = true"
|
||||
);
|
||||
});
|
||||
}
|
||||
status.is_enabled()
|
||||
}
|
||||
|
||||
/// Env override for [`FAIL_CLOSED_KEY`]. Named for prefix-alignment
|
||||
|
|
@ -352,15 +354,13 @@ minimum_version = "not-a-version"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_flag_from_str_reads_the_opt_in() {
|
||||
assert!(fail_closed_flag_from_str("fail_closed = true\n"));
|
||||
assert!(!fail_closed_flag_from_str("fail_closed = false\n"));
|
||||
// Missing key, a non-bool value, malformed TOML, and empty all read as
|
||||
// not-opted-in (best-effort false) rather than panicking.
|
||||
assert!(!fail_closed_flag_from_str("[features]\ntelemetry = true\n"));
|
||||
assert!(!fail_closed_flag_from_str("fail_closed = \"yes\"\n"));
|
||||
assert!(!fail_closed_flag_from_str("[unclosed"));
|
||||
assert!(!fail_closed_flag_from_str(""));
|
||||
fn fail_closed_flag_reads_the_opt_in() {
|
||||
let flag = |s: &str| fail_closed_flag(&toml::from_str::<toml::Value>(s).unwrap());
|
||||
assert!(flag("fail_closed = true\n"));
|
||||
assert!(!flag("fail_closed = false\n"));
|
||||
assert!(!flag("[features]\ntelemetry = true\n"));
|
||||
assert!(!flag("fail_closed = \"yes\"\n"));
|
||||
assert!(!flag(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue