Synced from monorepo
Changes: - grok-shell: request workspaces:read/write OAuth2 scopes - security: fix SSRF bypass via HTTP redirect in hook runner - fix(grok-build): enterprise STT WSS URL + API-key voice bearer - Harden identity-change purge and sync-marker invariants - sandbox + workspace-server: delete the legacy ready-file arm - Show billing URL when browser cannot open - fix(pager): show folder-trust UI in minimal mode - fix(pager): drain task_backgrounded before no-wait headless exit - grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt - Split settings_modal into directory module - Delegate VS Code SSH file links - grok-shell: release the workspace session binding when a session is removed - keep skills reachable when their name collides with a client builtin - Preserve semantic link targets
This commit is contained in:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -32,22 +32,23 @@ pub use campaigns::{
|
|||
};
|
||||
pub use loader::{
|
||||
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
|
||||
apply_version_overrides_with_registered, campaigns_application_disabled, campaigns_state_path,
|
||||
deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, load_config_file,
|
||||
load_dismissed_ids_from_home, load_effective_config_disk_only, load_from_disk,
|
||||
load_managed_config, load_system_managed_config, load_toml_file, managed_config_layers,
|
||||
managed_config_layers_at, toml_error_detail,
|
||||
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
|
||||
campaigns_state_path, deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml,
|
||||
load_config_file, load_dismissed_ids_from_home, load_effective_config_disk_only,
|
||||
load_from_disk, load_managed_config, load_system_managed_config, load_toml_file,
|
||||
managed_config_layers, managed_config_layers_at, toml_error_detail,
|
||||
};
|
||||
pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
|
||||
pub use managed_cache::{
|
||||
ServingIdentity, SyncMarker, is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
managed_config_identity_changed, managed_deployment_id, managed_policy_compromised_for,
|
||||
mark_managed_config_synced,
|
||||
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_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
|
||||
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
|
||||
};
|
||||
pub use paths::{
|
||||
claude_managed_settings_path, claude_managed_settings_probe_path, decode_cwd_from_dirname,
|
||||
default_grok_home, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application, grok_home,
|
||||
sessions_cwd_dir, system_config_dir, user_grok_home,
|
||||
default_grok_home, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ pub fn load_from_disk() -> std::io::Result<toml::Value> {
|
|||
/// Managed config filename, shared by the loaders in this module.
|
||||
pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml";
|
||||
|
||||
/// Requirements (cloud-cache) filename — the sibling server-synced artifact.
|
||||
pub const REQUIREMENTS_FILENAME: &str = "requirements.toml";
|
||||
|
||||
pub fn load_managed_config() -> std::io::Result<toml::Value> {
|
||||
load_user_config_layer(user_grok_home().as_deref(), MANAGED_CONFIG_FILENAME)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use std::path::Path;
|
|||
|
||||
use crate::paths::user_grok_home;
|
||||
|
||||
/// Sync marker; staleness keys on this, not mtimes.
|
||||
const MANAGED_CONFIG_CACHE_FILE: &str = "managed_config_cache.json";
|
||||
/// Sync marker; staleness keys on this, not mtimes. Public so removal code can name it
|
||||
/// apart from the policy artifacts (removed last).
|
||||
pub const MANAGED_CONFIG_CACHE_FILE: &str = "managed_config_cache.json";
|
||||
|
||||
/// The on-disk marker: unsigned, detects only deletion / identity change, not
|
||||
/// in-place edits (see the module doc).
|
||||
|
|
@ -84,10 +85,11 @@ fn managed_deployment_id_at(home: &Path, key_fingerprint: &str) -> Option<String
|
|||
if cache.key_fingerprint.as_deref() != Some(key_fingerprint) {
|
||||
return None;
|
||||
}
|
||||
cache.principal.filter(|p| !p.trim().is_empty())
|
||||
normalize_identity(cache.principal.as_deref())
|
||||
}
|
||||
|
||||
fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
|
||||
/// [`mark_managed_config_synced`] for an explicit `home` (apply-lock holder: same dir as lock).
|
||||
pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
|
||||
let SyncMarker {
|
||||
principal,
|
||||
had_managed_config,
|
||||
|
|
@ -101,11 +103,12 @@ fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
|
|||
.ok();
|
||||
let cache = ManagedConfigCache {
|
||||
synced_at,
|
||||
principal: principal.map(str::to_owned),
|
||||
// What THIS sync served, not on-disk presence — a confirmed switch already evicted any prior files.
|
||||
// Blank → None: marker must never record "unknown" as a tenant.
|
||||
principal: normalize_identity(principal),
|
||||
// What THIS sync served (not on-disk); switch already evicted priors.
|
||||
had_managed_config,
|
||||
had_requirements,
|
||||
key_fingerprint: key_fingerprint.map(str::to_owned),
|
||||
key_fingerprint: normalize_identity(key_fingerprint),
|
||||
fail_closed,
|
||||
};
|
||||
match serde_json::to_string(&cache) {
|
||||
|
|
@ -146,21 +149,10 @@ fn read_managed_config_cache(home: &Path) -> Option<ManagedConfigCache> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A confirmed identity switch vs the marker — both sides of a dimension present and differing (team id or fingerprint).
|
||||
/// Callers evict prior artifacts on true; a missing marker / `None` / pre-upgrade never counts (first sync / signed-out / legacy never evict).
|
||||
/// A blank/whitespace value on either side of either dimension (principal or key fingerprint)
|
||||
/// is "unknown", not a distinct tenant — a malformed `auth.json` parse blip must not confirm a
|
||||
/// switch and shed a real tenant's policy.
|
||||
pub fn managed_config_identity_changed(
|
||||
new_principal: Option<&str>,
|
||||
new_key_fingerprint: Option<&str>,
|
||||
) -> bool {
|
||||
user_grok_home().is_some_and(|home| {
|
||||
managed_config_identity_changed_at(&home, new_principal, new_key_fingerprint)
|
||||
})
|
||||
}
|
||||
|
||||
fn managed_config_identity_changed_at(
|
||||
/// Confirmed identity switch vs the marker (both sides of a dimension known and differing).
|
||||
/// Missing marker / blank / pre-upgrade never counts. Callers evict prior artifacts on true.
|
||||
/// Takes the apply-lock holder's `home` (same dir as the lock).
|
||||
pub fn managed_config_identity_changed_at(
|
||||
home: &Path,
|
||||
new_principal: Option<&str>,
|
||||
new_key_fingerprint: Option<&str>,
|
||||
|
|
@ -168,41 +160,78 @@ fn managed_config_identity_changed_at(
|
|||
let Some(cache) = read_managed_config_cache(home) else {
|
||||
return false;
|
||||
};
|
||||
let principal_changed = matches!(
|
||||
(cache.principal.as_deref(), new_principal),
|
||||
(Some(old), Some(new))
|
||||
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
|
||||
);
|
||||
let key_changed = matches!(
|
||||
(cache.key_fingerprint.as_deref(), new_key_fingerprint),
|
||||
(Some(old), Some(new))
|
||||
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
|
||||
);
|
||||
principal_changed || key_changed
|
||||
confirmed_switch(cache.principal.as_deref(), new_principal).is_some()
|
||||
|| confirmed_switch(cache.key_fingerprint.as_deref(), new_key_fingerprint).is_some()
|
||||
}
|
||||
|
||||
/// Present non-blank value, else `None` (blank/whitespace is "unknown", not a tenant). Untrimmed.
|
||||
fn known(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// [`known`] then trim — the one normalization for storing or deriving an identity
|
||||
/// (whitespace is not identity). Shared with the shell's identity derivation.
|
||||
pub fn normalize_identity(value: Option<&str>) -> Option<String> {
|
||||
known(value).map(|v| v.trim().to_owned())
|
||||
}
|
||||
|
||||
/// Both sides known and differing after trim (older markers may be untrimmed). Returns recorded value.
|
||||
fn confirmed_switch<'a>(recorded: Option<&'a str>, current: Option<&str>) -> Option<&'a str> {
|
||||
match (known(recorded), known(current)) {
|
||||
(Some(old), Some(new)) if old.trim() != new.trim() => Some(old),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline tenant-purge detector: confirmed team switch vs marker → evicted principal.
|
||||
/// Key-scoped markers never confirm (key owns the machine's policy, not the team).
|
||||
pub fn confirmed_team_switch(new_team_id: &str) -> Option<String> {
|
||||
user_grok_home().and_then(|home| confirmed_team_switch_at(&home, new_team_id))
|
||||
}
|
||||
|
||||
/// [`confirmed_team_switch`] for an explicit `home` (purge-lock holder: same dir as delete).
|
||||
pub fn confirmed_team_switch_at(home: &Path, new_team_id: &str) -> Option<String> {
|
||||
let cache = read_managed_config_cache(home)?;
|
||||
if known(cache.key_fingerprint.as_deref()).is_some() {
|
||||
return None;
|
||||
}
|
||||
confirmed_switch(cache.principal.as_deref(), Some(new_team_id)).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// True when an artifact the marker recorded serving is now absent. Only served artifacts count, so a config-less
|
||||
/// principal (or legacy marker) isn't misread as stale. Detects deletion, not edits.
|
||||
fn cache_missing_required_artifact(cache: &ManagedConfigCache, home: &Path) -> bool {
|
||||
(cache.had_requirements && !home.join("requirements.toml").exists())
|
||||
|| (cache.had_managed_config && !home.join("managed_config.toml").exists())
|
||||
(cache.had_requirements && !home.join(crate::loader::REQUIREMENTS_FILENAME).exists())
|
||||
|| (cache.had_managed_config && !home.join(crate::loader::MANAGED_CONFIG_FILENAME).exists())
|
||||
}
|
||||
|
||||
/// Whether the cached principal differs from the team serving now — the team dimension only.
|
||||
/// Deploy-key identity is verified by fingerprint ([`cache_key_fingerprint_mismatch`]); `None` never fires.
|
||||
/// Trim-aware (same rule as marker write): whitespace alone is not a mismatch.
|
||||
fn cache_identity_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
|
||||
match identity {
|
||||
ServingIdentity::Team(team_id) => cache.principal.as_deref() != Some(team_id.as_str()),
|
||||
ServingIdentity::Team(team_id) => match (
|
||||
known(cache.principal.as_deref()),
|
||||
known(Some(team_id.as_str())),
|
||||
) {
|
||||
// Both blank → no team to compare.
|
||||
(None, None) => false,
|
||||
// Both known → trim-compare.
|
||||
(Some(a), Some(b)) => a.trim() != b.trim(),
|
||||
// One-sided: treat as mismatch (first install / cleared principal field).
|
||||
_ => true,
|
||||
},
|
||||
ServingIdentity::DeploymentKey { .. } | ServingIdentity::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the configured deployment key differs from the cache's, by one-way fingerprint (never the raw key) —
|
||||
/// the only identity verifiable offline. A pre-upgrade marker (no fingerprint) never fires; only a *changed* key.
|
||||
/// Trim-aware; both sides must be known (unlike the team principal path).
|
||||
fn cache_key_fingerprint_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
|
||||
match identity {
|
||||
ServingIdentity::DeploymentKey { fingerprint } => {
|
||||
matches!(cache.key_fingerprint.as_deref(), Some(recorded) if recorded != fingerprint)
|
||||
confirmed_switch(cache.key_fingerprint.as_deref(), Some(fingerprint.as_str())).is_some()
|
||||
}
|
||||
ServingIdentity::Team(_) | ServingIdentity::None => false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1010,3 +1010,140 @@ fn gate_retries_once_on_a_compromised_verdict() {
|
|||
assert!(refused);
|
||||
assert_eq!(evals, 1);
|
||||
}
|
||||
|
||||
/// The offline purge detector: fires only on a marker-recorded TEAM switch, returning the
|
||||
/// evicted principal; a key-scoped marker means the key owns the machine's policy, so a
|
||||
/// team mismatch (even with live config unreadable/blipping) must never confirm.
|
||||
#[test]
|
||||
fn confirmed_team_switch_scopes_to_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
|
||||
// No marker → no switch (first run / signed-out).
|
||||
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
|
||||
|
||||
// Team marker A → B confirms and reports the evicted principal; same team doesn't.
|
||||
mark_managed_config_synced_at(
|
||||
home,
|
||||
SyncMarker {
|
||||
principal: Some("team-a"),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: None,
|
||||
fail_closed: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
confirmed_team_switch_at(home, "team-b").as_deref(),
|
||||
Some("team-a")
|
||||
);
|
||||
assert_eq!(confirmed_team_switch_at(home, "team-a"), None);
|
||||
|
||||
// Key-scoped marker (dk-synced): a differing team NEVER confirms — the regression
|
||||
// shape is a dk machine with a team user signed in and config resolution blipping.
|
||||
mark_managed_config_synced_at(
|
||||
home,
|
||||
SyncMarker {
|
||||
principal: Some("dk-deployment-1"),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: Some("fp-1"),
|
||||
fail_closed: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
|
||||
}
|
||||
|
||||
/// Blank identity values normalize to `None` at the marker WRITE, so no reader can
|
||||
/// treat "unknown" as a distinct tenant (the detectors' blank guards stay as
|
||||
/// defense in depth).
|
||||
#[test]
|
||||
fn marker_write_normalizes_blank_identities() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
mark_managed_config_synced_at(
|
||||
home,
|
||||
SyncMarker {
|
||||
principal: Some(" "),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: Some(""),
|
||||
fail_closed: true,
|
||||
},
|
||||
);
|
||||
let cache = read_managed_config_cache(home).expect("marker written");
|
||||
assert_eq!(
|
||||
cache.principal, None,
|
||||
"blank principal must not be recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.key_fingerprint, None,
|
||||
"blank fingerprint must not be recorded"
|
||||
);
|
||||
// And a blank-recorded marker can't confirm a switch.
|
||||
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
|
||||
}
|
||||
|
||||
/// Identity values are stored TRIMMED at the marker write, so a marker can never
|
||||
/// differ from a live value by surrounding whitespace alone.
|
||||
#[test]
|
||||
fn marker_write_trims_identity_values() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
mark_managed_config_synced_at(
|
||||
home,
|
||||
SyncMarker {
|
||||
principal: Some(" team-a "),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: Some(" fp-1 "),
|
||||
fail_closed: false,
|
||||
},
|
||||
);
|
||||
let cache = read_managed_config_cache(home).expect("marker written");
|
||||
assert_eq!(cache.principal.as_deref(), Some("team-a"));
|
||||
assert_eq!(cache.key_fingerprint.as_deref(), Some("fp-1"));
|
||||
}
|
||||
|
||||
/// The one home of the blank + trim rules ([`known`] + `confirmed_switch`): both sides
|
||||
/// known and differing on their trimmed forms, else `None`.
|
||||
#[test]
|
||||
fn confirmed_switch_requires_two_known_differing_sides() {
|
||||
assert_eq!(confirmed_switch(Some("a"), Some("b")), Some("a"));
|
||||
assert_eq!(confirmed_switch(Some("a"), Some("a")), None);
|
||||
assert_eq!(confirmed_switch(Some(" "), Some("b")), None);
|
||||
assert_eq!(confirmed_switch(Some("a"), Some("")), None);
|
||||
assert_eq!(confirmed_switch(None, Some("b")), None);
|
||||
assert_eq!(confirmed_switch(Some("a"), None), None);
|
||||
assert_eq!(confirmed_switch(None, None), None);
|
||||
// Whitespace is not identity: a marker written untrimmed by an older build must
|
||||
// not read as a tenant switch against the same (trimmed) value...
|
||||
assert_eq!(confirmed_switch(Some("team-a "), Some("team-a")), None);
|
||||
assert_eq!(confirmed_switch(Some("team-a"), Some("team-a ")), None);
|
||||
// ...while genuinely different trimmed values still switch (the recorded value
|
||||
// is returned verbatim for logging).
|
||||
assert_eq!(
|
||||
confirmed_switch(Some(" team-a "), Some("team-b")),
|
||||
Some(" team-a ")
|
||||
);
|
||||
}
|
||||
|
||||
/// Staleness identity compare is trim-aware (sibling of marker write normalize).
|
||||
#[test]
|
||||
fn cache_identity_mismatch_ignores_whitespace_only_diffs() {
|
||||
let cache = ManagedConfigCache {
|
||||
principal: Some("team-a".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!cache_identity_mismatch(&cache, &team(" team-a ")),
|
||||
"whitespace-only team id diff must not hard-stale"
|
||||
);
|
||||
assert!(
|
||||
cache_identity_mismatch(&cache, &team("team-b")),
|
||||
"a real team switch must still mismatch"
|
||||
);
|
||||
// One-sided known still mismatches (first install / cleared marker fields).
|
||||
let empty = ManagedConfigCache::default();
|
||||
assert!(cache_identity_mismatch(&empty, &team("team-a")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,13 @@ pub fn user_grok_home() -> Option<PathBuf> {
|
|||
|
||||
/// Canonical grok application path: `$GROK_HOME/bin/grok` (Unix) or `grok.exe` (Windows).
|
||||
pub fn grok_application() -> PathBuf {
|
||||
grok_application_in(&grok_home())
|
||||
}
|
||||
|
||||
/// [`grok_application`] under an explicit home instead of `$GROK_HOME`.
|
||||
pub fn grok_application_in(home: &std::path::Path) -> PathBuf {
|
||||
let name = if cfg!(windows) { "grok.exe" } else { "grok" };
|
||||
grok_home().join("bin").join(name)
|
||||
home.join("bin").join(name)
|
||||
}
|
||||
|
||||
/// System-wide config directory: `/etc/grok/` on Unix, `None` on Windows.
|
||||
|
|
|
|||
Loading…
Reference in a new issue