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:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -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>,

View file

@ -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};

View file

@ -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,
}

View 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"
);
}

View file

@ -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;

View file

@ -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).

View 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"
);
}

View file

@ -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;

View file

@ -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]