Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -13,6 +13,7 @@ pub mod path_suggestions;
|
|||
pub(crate) mod query_tools;
|
||||
pub mod remap;
|
||||
pub mod serde_base64;
|
||||
pub mod shell_env_policy;
|
||||
pub mod spawn;
|
||||
pub mod truncate;
|
||||
pub mod unicode_confusables;
|
||||
|
|
@ -26,6 +27,10 @@ pub use fs::{UnicodePathMatch, canonicalize_with_timeout, try_resolve_unicode_fi
|
|||
pub use grok_home::{grok_application, grok_home};
|
||||
pub use path_suggestions::format_not_found_error;
|
||||
pub use remap::{remap_json_keys, remap_schema_properties, reverse_map};
|
||||
pub use shell_env_policy::{
|
||||
EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit,
|
||||
apply_shell_environment_policy,
|
||||
};
|
||||
pub use spawn::{
|
||||
ProcessGroup, ProcessScope, detach_command, global_process_scope, new_process_group,
|
||||
};
|
||||
|
|
|
|||
237
crates/codegen/xai-grok-tools/src/util/shell_env_policy.rs
Normal file
237
crates/codegen/xai-grok-tools/src/util/shell_env_policy.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! Controls which environment variables agent subprocesses (bash tool,
|
||||
//! terminals) inherit. Default is a no-op (inherit everything); enforced at the
|
||||
//! shell spawn sites on macOS, Linux, and Windows.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
/// Case-insensitive environment-variable-name glob (`*`, `?`).
|
||||
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
|
||||
|
||||
fn deserialize_patterns<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<EnvironmentVariablePattern>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let globs = Vec::<String>::deserialize(deserializer)?;
|
||||
Ok(globs
|
||||
.iter()
|
||||
.map(|s| EnvironmentVariablePattern::new_case_insensitive(s))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellEnvironmentPolicyInherit {
|
||||
/// Core platform variables only (PATH, HOME, SHELL, ...).
|
||||
Core,
|
||||
#[default]
|
||||
All,
|
||||
None,
|
||||
}
|
||||
|
||||
/// How to build the environment for agent subprocesses. Applied in order: start
|
||||
/// from `inherit`; if `ignore_default_excludes` is false, drop the secret
|
||||
/// patterns `*KEY*`/`*SECRET*`/`*TOKEN*`; drop `exclude`; insert `set`; if
|
||||
/// `include_only` is non-empty, keep only those. Patterns are case-insensitive
|
||||
/// globs (`*`, `?`).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ShellEnvironmentPolicy {
|
||||
pub inherit: ShellEnvironmentPolicyInherit,
|
||||
/// Skip the built-in secret excludes (default `true`).
|
||||
pub ignore_default_excludes: bool,
|
||||
#[serde(deserialize_with = "deserialize_patterns")]
|
||||
pub exclude: Vec<EnvironmentVariablePattern>,
|
||||
/// Values inserted into the base environment before `include_only` filtering
|
||||
/// (an unmatched name is then dropped). These seed the base; request env
|
||||
/// layered at spawn can still override them.
|
||||
pub set: HashMap<String, String>,
|
||||
#[serde(deserialize_with = "deserialize_patterns")]
|
||||
pub include_only: Vec<EnvironmentVariablePattern>,
|
||||
}
|
||||
|
||||
impl Default for ShellEnvironmentPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inherit: ShellEnvironmentPolicyInherit::All,
|
||||
ignore_default_excludes: true,
|
||||
exclude: Vec::new(),
|
||||
set: HashMap::new(),
|
||||
include_only: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellEnvironmentPolicy {
|
||||
/// True when the policy leaves the inherited environment untouched.
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.inherit == ShellEnvironmentPolicyInherit::All
|
||||
&& self.ignore_default_excludes
|
||||
&& self.exclude.is_empty()
|
||||
&& self.set.is_empty()
|
||||
&& self.include_only.is_empty()
|
||||
}
|
||||
|
||||
/// True if `name` matches a built-in secret exclude and those are enabled.
|
||||
fn matches_default_exclude(&self, name: &str) -> bool {
|
||||
!self.ignore_default_excludes && DEFAULT_SECRET_EXCLUDES.iter().any(|p| p.matches(name))
|
||||
}
|
||||
|
||||
fn matches_exclude(&self, name: &str) -> bool {
|
||||
self.exclude.iter().any(|p| p.matches(name))
|
||||
}
|
||||
|
||||
/// True if `include_only` is empty (all admitted) or `name` matches it.
|
||||
fn matches_include_only(&self, name: &str) -> bool {
|
||||
self.include_only.is_empty() || self.include_only.iter().any(|p| p.matches(name))
|
||||
}
|
||||
|
||||
/// Whether `name` survives the name filters (default excludes, `exclude`,
|
||||
/// `include_only`), ignoring `inherit`/`set`. Used to filter variables layered
|
||||
/// in after the policy base, e.g. login-shell capture. Shares its matchers
|
||||
/// with [`create_env_from_vars`] so the two cannot drift.
|
||||
pub fn allows(&self, name: &str) -> bool {
|
||||
!self.matches_default_exclude(name)
|
||||
&& !self.matches_exclude(name)
|
||||
&& self.matches_include_only(name)
|
||||
}
|
||||
|
||||
/// Like [`allows`](Self::allows) but also honors `inherit`: `none` admits
|
||||
/// nothing, `core` admits only core names, `all` defers to `allows`.
|
||||
pub fn allows_with_inherit(&self, name: &str) -> bool {
|
||||
match self.inherit {
|
||||
ShellEnvironmentPolicyInherit::None => return false,
|
||||
ShellEnvironmentPolicyInherit::Core => {
|
||||
if !CORE_ENV_VARS
|
||||
.iter()
|
||||
.any(|core| core.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ShellEnvironmentPolicyInherit::All => {}
|
||||
}
|
||||
self.allows(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in secret excludes applied when `ignore_default_excludes` is false.
|
||||
/// Shared by the base-env build and the login-capture filter so they can't drift.
|
||||
static DEFAULT_SECRET_EXCLUDES: LazyLock<[EnvironmentVariablePattern; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
|
||||
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
|
||||
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
|
||||
]
|
||||
});
|
||||
|
||||
/// "Core" variables retained under [`ShellEnvironmentPolicyInherit::Core`].
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const CORE_ENV_VARS: &[&str] = &[
|
||||
"PATH", "SHELL", "TMPDIR", "TEMP", "TMP", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME",
|
||||
"USER",
|
||||
];
|
||||
#[cfg(target_os = "windows")]
|
||||
const CORE_ENV_VARS: &[&str] = &[
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SHELL",
|
||||
"COMSPEC",
|
||||
"SYSTEMROOT",
|
||||
"SYSTEMDRIVE",
|
||||
"USERNAME",
|
||||
"USERDOMAIN",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMW6432",
|
||||
"PROGRAMDATA",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"POWERSHELL",
|
||||
"PWSH",
|
||||
];
|
||||
|
||||
/// Build the child environment from `policy` and the process env. Uses `vars_os`
|
||||
/// and skips non-UTF-8 entries so a hostile variable cannot panic at spawn time.
|
||||
pub(crate) fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> {
|
||||
let vars = std::env::vars_os()
|
||||
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)));
|
||||
create_env_from_vars(vars, policy)
|
||||
}
|
||||
|
||||
pub(crate) fn create_env_from_vars<I>(
|
||||
vars: I,
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
) -> HashMap<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = (String, String)>,
|
||||
{
|
||||
let mut env: HashMap<String, String> = match policy.inherit {
|
||||
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
|
||||
ShellEnvironmentPolicyInherit::None => HashMap::new(),
|
||||
ShellEnvironmentPolicyInherit::Core => vars
|
||||
.into_iter()
|
||||
.filter(|(k, _)| {
|
||||
CORE_ENV_VARS
|
||||
.iter()
|
||||
.any(|allowed| allowed.eq_ignore_ascii_case(k))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Order matters: default excludes, then `exclude`, then `set`, then
|
||||
// `include_only`. `set` lands before `include_only` so an unmatched set name
|
||||
// is still dropped. The matchers are shared with `allows`.
|
||||
env.retain(|k, _| !policy.matches_default_exclude(k));
|
||||
env.retain(|k, _| !policy.matches_exclude(k));
|
||||
for (k, v) in &policy.set {
|
||||
env.insert(k.clone(), v.clone());
|
||||
}
|
||||
env.retain(|k, _| policy.matches_include_only(k));
|
||||
|
||||
// Windows resolves executables via PATHEXT; keep it present even under a
|
||||
// restrictive policy so commands stay runnable.
|
||||
if cfg!(target_os = "windows") && !env.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
|
||||
env.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
/// Clear the command's inherited env and install the policy-derived base env.
|
||||
/// `active` must already be noop-filtered; `None` leaves the command untouched.
|
||||
/// The one base-env code path, shared by the public entry point and the spawn
|
||||
/// sites.
|
||||
pub(crate) fn install_policy_base_env(
|
||||
cmd: &mut tokio::process::Command,
|
||||
active: Option<&ShellEnvironmentPolicy>,
|
||||
) {
|
||||
if let Some(policy) = active {
|
||||
cmd.env_clear();
|
||||
cmd.envs(create_env(policy));
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the policy-derived base env on `cmd` (clearing inherited env first);
|
||||
/// a `None` or no-op policy leaves it untouched. Call before any other
|
||||
/// `.env`/`.envs`.
|
||||
pub fn apply_shell_environment_policy(
|
||||
cmd: &mut tokio::process::Command,
|
||||
policy: Option<&ShellEnvironmentPolicy>,
|
||||
) {
|
||||
install_policy_base_env(cmd, policy.filter(|p| !p.is_noop()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shell_env_policy_tests.rs"]
|
||||
mod tests;
|
||||
166
crates/codegen/xai-grok-tools/src/util/shell_env_policy_tests.rs
Normal file
166
crates/codegen/xai-grok-tools/src/util/shell_env_policy_tests.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use super::{
|
||||
EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit,
|
||||
apply_shell_environment_policy, create_env_from_vars,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn patterns(globs: &[&str]) -> Vec<EnvironmentVariablePattern> {
|
||||
globs
|
||||
.iter()
|
||||
.map(|g| EnvironmentVariablePattern::new_case_insensitive(g))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_policy_reshapes_command_env() {
|
||||
let mut set = HashMap::new();
|
||||
set.insert("MY_FLAG".to_string(), "1".to_string());
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::None,
|
||||
set,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new("true");
|
||||
apply_shell_environment_policy(&mut cmd, Some(&policy));
|
||||
let envs: HashMap<String, String> = cmd
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
|
||||
.collect();
|
||||
assert_eq!(envs.get("MY_FLAG").map(String::as_str), Some("1"));
|
||||
// inherit=None cleared the env, so no inherited PATH leaks through.
|
||||
assert!(!envs.contains_key("PATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_noop_or_absent_policy_leaves_command_untouched() {
|
||||
let mut cmd = tokio::process::Command::new("true");
|
||||
apply_shell_environment_policy(&mut cmd, None);
|
||||
apply_shell_environment_policy(&mut cmd, Some(&ShellEnvironmentPolicy::default()));
|
||||
// No env_clear and no sets: the command carries no explicit env entries.
|
||||
assert_eq!(cmd.as_std().get_envs().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_excludes_drop_secrets_when_enabled() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
ignore_default_excludes: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!policy.is_noop());
|
||||
let env = create_env_from_vars(
|
||||
vars(&[
|
||||
("PATH", "/bin"),
|
||||
("MY_API_KEY", "x"),
|
||||
("MY_SECRET", "y"),
|
||||
("GH_TOKEN", "z"),
|
||||
]),
|
||||
&policy,
|
||||
);
|
||||
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
|
||||
assert!(!env.contains_key("MY_API_KEY"));
|
||||
assert!(!env.contains_key("MY_SECRET"));
|
||||
assert!(!env.contains_key("GH_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_none_starts_empty_then_set_applies() {
|
||||
let mut set = HashMap::new();
|
||||
set.insert("PATH".to_string(), "/usr/bin".to_string());
|
||||
set.insert("MY_FLAG".to_string(), "1".to_string());
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::None,
|
||||
set,
|
||||
..Default::default()
|
||||
};
|
||||
let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("HOME", "/root")]), &policy);
|
||||
assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
|
||||
assert_eq!(env.get("MY_FLAG").map(String::as_str), Some("1"));
|
||||
assert!(!env.contains_key("HOME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_core_keeps_only_core_vars() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::Core,
|
||||
..Default::default()
|
||||
};
|
||||
let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("RANDOM_VAR", "v")]), &policy);
|
||||
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
|
||||
assert!(!env.contains_key("RANDOM_VAR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_and_include_only_filter() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
exclude: patterns(&["AWS_*"]),
|
||||
include_only: patterns(&["PATH", "HOME"]),
|
||||
..Default::default()
|
||||
};
|
||||
let env = create_env_from_vars(
|
||||
vars(&[
|
||||
("PATH", "/bin"),
|
||||
("HOME", "/root"),
|
||||
("AWS_SECRET", "s"),
|
||||
("OTHER", "o"),
|
||||
]),
|
||||
&policy,
|
||||
);
|
||||
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
|
||||
assert_eq!(env.get("HOME").map(String::as_str), Some("/root"));
|
||||
assert!(!env.contains_key("AWS_SECRET"));
|
||||
assert!(!env.contains_key("OTHER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_filters_by_name_case_insensitively() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
exclude: patterns(&["aws_*"]), // lowercase pattern, uppercase var
|
||||
include_only: patterns(&["PATH", "HOME"]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(policy.allows("PATH"));
|
||||
assert!(!policy.allows("AWS_SECRET")); // excluded (case-insensitive)
|
||||
assert!(!policy.allows("OTHER")); // not in include_only
|
||||
|
||||
let scrub = ShellEnvironmentPolicy {
|
||||
ignore_default_excludes: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!scrub.allows("my_api_key")); // `*KEY*` matches case-insensitively
|
||||
assert!(ShellEnvironmentPolicy::default().allows("MY_API_KEY")); // default allows all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_with_inherit_honors_inherit() {
|
||||
// inherit = none admits nothing.
|
||||
let none = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!none.allows_with_inherit("PATH"));
|
||||
assert!(!none.allows_with_inherit("FOO"));
|
||||
|
||||
// inherit = core admits only core names.
|
||||
let core = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::Core,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(core.allows_with_inherit("PATH"));
|
||||
assert!(!core.allows_with_inherit("RANDOM_VAR"));
|
||||
|
||||
// inherit = all defers to `allows` (exclude still applies).
|
||||
let all = ShellEnvironmentPolicy {
|
||||
exclude: patterns(&["AWS_*"]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(all.allows_with_inherit("RANDOM_VAR"));
|
||||
assert!(!all.allows_with_inherit("AWS_SECRET"));
|
||||
}
|
||||
Loading…
Reference in a new issue