Synced from monorepo
Synced from monorepo Changes: - Release a shell session's resources in one drop - Make the tools blocking-wait cap client-configurable and self-describing - Recognize API "exceeds budget" errors as context overflow - Retry /btw on model overload - Carry running background tasks and subagents across compaction - Require round-trip time for SDK liveness checks - Background-subagent completion reminders with a selectable delivery surface - Make a PTY shell reap itself until it reaches the registry - Recover the OS error code from a TLS-phase connection reset - Consume the attached-client signal and report why idle is withheld - Treat `.grok/sandbox.toml` edits as protected so auto mode prompts before writing - Surface history/search in the Ctrl+. cheatsheet and keep it working in history view - Delete sessions from the dashboard and welcome list - Release a session's activity record when the session ends - Stop charging auth-retry budget for fail-closed 401s; reset it across suspends - Scope skills watches on project vendor roots - Make [stop] cancel in-flight compaction - Make the leader soak measure the leader, not its harness Source-Revision: 8d69c91f02bcacf01e98d5aebbf2f92547c45738
This commit is contained in:
parent
dd04f397b1
commit
a422116582
165 changed files with 15161 additions and 1969 deletions
|
|
@ -5123,7 +5123,11 @@ mod tests {
|
|||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
for path in ["/etc/hosts", "/home/user/.grok/hooks/evil.json"] {
|
||||
for path in [
|
||||
"/etc/hosts",
|
||||
"/home/user/.grok/hooks/evil.json",
|
||||
"/home/user/.grok/sandbox.toml",
|
||||
] {
|
||||
let mut auto = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
auto.prompt_policy = PromptPolicy::Auto;
|
||||
let allow =
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ pub enum ProtectedEditReason {
|
|||
StartupFile,
|
||||
Etc,
|
||||
GrokConfig,
|
||||
GrokSandbox,
|
||||
ClaudeSettings,
|
||||
CursorHooks,
|
||||
/// Fail-closed / unclassified sensitive path; no user copy yet.
|
||||
|
|
@ -343,6 +344,7 @@ impl ProtectedEditReason {
|
|||
Self::StartupFile => "startup_file",
|
||||
Self::Etc => "etc",
|
||||
Self::GrokConfig => "grok_config",
|
||||
Self::GrokSandbox => "grok_sandbox",
|
||||
Self::ClaudeSettings => "claude_settings",
|
||||
Self::CursorHooks => "cursor_hooks",
|
||||
Self::Sensitive => "sensitive",
|
||||
|
|
@ -369,6 +371,9 @@ impl ProtectedEditReason {
|
|||
Self::GrokConfig => Some(
|
||||
"Note: This edit contains changes to Grok config, which can alter permissions, tools, and other behavior in later sessions.",
|
||||
),
|
||||
Self::GrokSandbox => Some(
|
||||
"Note: This edit contains changes to the Grok sandbox config, which can loosen filesystem and network restrictions on commands.",
|
||||
),
|
||||
Self::ClaudeSettings => Some(
|
||||
"Note: This edit contains changes to Claude-compatible settings, which can install hooks or change permission mode without a separate execution approval.",
|
||||
),
|
||||
|
|
@ -471,8 +476,8 @@ fn protected_edit_reason(path: &Path) -> Option<ProtectedEditReason> {
|
|||
if STARTUP_FILES.contains(&file) {
|
||||
return Some(ProtectedEditReason::StartupFile);
|
||||
}
|
||||
if string_components.ends_with(&[".grok", "config.toml"]) {
|
||||
return Some(ProtectedEditReason::GrokConfig);
|
||||
if let Some(reason) = protected_grok_config_file(path, &string_components) {
|
||||
return Some(reason);
|
||||
}
|
||||
if path == Path::new("/etc") || path.starts_with(Path::new("/etc")) {
|
||||
return Some(ProtectedEditReason::Etc);
|
||||
|
|
@ -480,6 +485,52 @@ fn protected_edit_reason(path: &Path) -> Option<ProtectedEditReason> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Grok config files that alter permissions (`config.toml`, the
|
||||
/// `managed_config.toml` defaults tier, the user `requirements.toml` layer) or
|
||||
/// sandbox restrictions (`sandbox.toml`) in the running and later sessions; a
|
||||
/// silent edit would let the agent loosen its own guardrails. Matched directly
|
||||
/// inside any `.grok` dir (user-global default and workspace overlays) and
|
||||
/// directly under a custom `$GROK_HOME`, which the component match cannot see.
|
||||
fn protected_grok_config_file(path: &Path, components: &[&str]) -> Option<ProtectedEditReason> {
|
||||
protected_grok_config_file_with_home(
|
||||
path,
|
||||
components,
|
||||
xai_grok_config::user_grok_home().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn protected_grok_config_file_with_home(
|
||||
path: &Path,
|
||||
components: &[&str],
|
||||
user_grok_home: Option<&Path>,
|
||||
) -> Option<ProtectedEditReason> {
|
||||
let reason = match components.last().copied() {
|
||||
Some(
|
||||
xai_grok_config::USER_CONFIG_FILENAME
|
||||
| xai_grok_config::MANAGED_CONFIG_FILENAME
|
||||
| xai_grok_config::REQUIREMENTS_FILENAME,
|
||||
) => ProtectedEditReason::GrokConfig,
|
||||
Some("sandbox.toml") => ProtectedEditReason::GrokSandbox,
|
||||
_ => return None,
|
||||
};
|
||||
let in_dot_grok = components.len() >= 2 && components[components.len() - 2] == ".grok";
|
||||
let in_grok_home = || grok_home_matches(user_grok_home, |home| path.parent() == Some(home));
|
||||
(in_dot_grok || in_grok_home()).then_some(reason)
|
||||
}
|
||||
|
||||
/// True when `pred` holds for the user grok home in either its lexical or
|
||||
/// physically-resolved form. Both forms are checked because callers hold a
|
||||
/// lexical and a resolved candidate path, and the home itself may sit behind a
|
||||
/// symlink. The comparison is byte-exact (no case folding), like every other
|
||||
/// resolved-path check in this module.
|
||||
fn grok_home_matches(home: Option<&Path>, pred: impl Fn(&Path) -> bool) -> bool {
|
||||
home.is_some_and(|home| {
|
||||
let lexical = xai_grok_paths::normalize_lexically(home);
|
||||
pred(&lexical)
|
||||
|| resolve_following_symlinks(&lexical, 0).is_some_and(|resolved| pred(&resolved))
|
||||
})
|
||||
}
|
||||
|
||||
fn path_is_under_user_grok_hook_root(path: &Path, grok_home: &Path) -> bool {
|
||||
path.starts_with(grok_home.join("hooks")) || path == grok_home.join("hooks-paths")
|
||||
}
|
||||
|
|
@ -487,12 +538,8 @@ fn path_is_under_user_grok_hook_root(path: &Path, grok_home: &Path) -> bool {
|
|||
fn protected_grok_hook_root(path: &Path, components: &[&str]) -> bool {
|
||||
components.windows(2).any(|pair| pair == [".grok", "hooks"])
|
||||
|| components.ends_with(&[".grok", "hooks-paths"])
|
||||
|| xai_grok_config::user_grok_home().is_some_and(|grok_home| {
|
||||
let lexical_home = xai_grok_paths::normalize_lexically(&grok_home);
|
||||
path_is_under_user_grok_hook_root(path, &lexical_home)
|
||||
|| resolve_following_symlinks(&lexical_home, 0).is_some_and(|resolved_home| {
|
||||
path_is_under_user_grok_hook_root(path, &resolved_home)
|
||||
})
|
||||
|| grok_home_matches(xai_grok_config::user_grok_home().as_deref(), |home| {
|
||||
path_is_under_user_grok_hook_root(path, home)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1369,6 +1416,8 @@ mod tests {
|
|||
"/etc",
|
||||
"/etc/grok-test",
|
||||
"/work/subdir/../.git/hooks/pre-commit",
|
||||
"/home/user/.grok/sandbox.toml",
|
||||
"/work/project/.grok/sandbox.toml",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_protection(Path::new(path)).is_some(),
|
||||
|
|
@ -1378,6 +1427,9 @@ mod tests {
|
|||
for path in [
|
||||
"/work/src/main.rs",
|
||||
"/work/project/.grok/config.toml/backup",
|
||||
"/work/project/sandbox.toml",
|
||||
"/work/project/requirements.toml",
|
||||
"/work/project/managed_config.toml",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_protection(Path::new(path)).is_none(),
|
||||
|
|
@ -1428,6 +1480,22 @@ mod tests {
|
|||
"/home/user/.grok/config.toml",
|
||||
ProtectedEditReason::GrokConfig,
|
||||
),
|
||||
(
|
||||
"/home/user/.grok/sandbox.toml",
|
||||
ProtectedEditReason::GrokSandbox,
|
||||
),
|
||||
(
|
||||
"/work/project/.grok/sandbox.toml",
|
||||
ProtectedEditReason::GrokSandbox,
|
||||
),
|
||||
(
|
||||
"/home/user/.grok/managed_config.toml",
|
||||
ProtectedEditReason::GrokConfig,
|
||||
),
|
||||
(
|
||||
"/home/user/.grok/requirements.toml",
|
||||
ProtectedEditReason::GrokConfig,
|
||||
),
|
||||
(
|
||||
"/home/user/.claude/settings.json",
|
||||
ProtectedEditReason::ClaudeSettings,
|
||||
|
|
@ -1551,6 +1619,85 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A custom `$GROK_HOME` has no `.grok` path component, so the live
|
||||
/// `config.toml` / `sandbox.toml` must be caught by the home-prefix branch.
|
||||
#[test]
|
||||
fn grok_config_files_under_custom_grok_home_are_protected() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let home_path = home.path();
|
||||
for (file, reason) in [
|
||||
("config.toml", ProtectedEditReason::GrokConfig),
|
||||
("managed_config.toml", ProtectedEditReason::GrokConfig),
|
||||
("requirements.toml", ProtectedEditReason::GrokConfig),
|
||||
("sandbox.toml", ProtectedEditReason::GrokSandbox),
|
||||
] {
|
||||
let path = home_path.join(file);
|
||||
let components = [file];
|
||||
assert_eq!(
|
||||
protected_grok_config_file_with_home(&path, &components, Some(home_path)),
|
||||
Some(reason),
|
||||
"{file} directly under $GROK_HOME must be protected"
|
||||
);
|
||||
}
|
||||
// Same file names elsewhere (or with no resolvable home) stay ordinary.
|
||||
let elsewhere = home_path.join("sub").join("sandbox.toml");
|
||||
assert_eq!(
|
||||
protected_grok_config_file_with_home(
|
||||
&elsewhere,
|
||||
&["sub", "sandbox.toml"],
|
||||
Some(home_path)
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
protected_grok_config_file_with_home(
|
||||
&home_path.join("sandbox.toml"),
|
||||
&["sandbox.toml"],
|
||||
None
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The resolved-symlink arm of the grok-home match must decide: `$GROK_HOME`
|
||||
/// points at a symlink while the edit targets the physical home directory,
|
||||
/// so the lexical parent-equality arm cannot fire.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn grok_config_under_symlinked_grok_home_is_protected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let real_home = tmp.path().join("real-home");
|
||||
std::fs::create_dir(&real_home).unwrap();
|
||||
let link = tmp.path().join("home-link");
|
||||
symlink(&real_home, &link).unwrap();
|
||||
// tempdir paths can themselves contain symlinks (macOS /var -> /private/var);
|
||||
// compare against the physical home the production resolver will produce.
|
||||
let physical_home = resolve_following_symlinks(&real_home, 0).unwrap();
|
||||
assert_eq!(
|
||||
protected_grok_config_file_with_home(
|
||||
&physical_home.join("sandbox.toml"),
|
||||
&["sandbox.toml"],
|
||||
Some(&link)
|
||||
),
|
||||
Some(ProtectedEditReason::GrokSandbox)
|
||||
);
|
||||
}
|
||||
|
||||
/// `protected_edit_reason` lowercases path components before matching, so
|
||||
/// the canonical filename constants must stay lowercase or the const
|
||||
/// patterns silently stop firing.
|
||||
#[test]
|
||||
fn protected_config_filename_constants_are_lowercase() {
|
||||
for name in [
|
||||
xai_grok_config::USER_CONFIG_FILENAME,
|
||||
xai_grok_config::MANAGED_CONFIG_FILENAME,
|
||||
xai_grok_config::REQUIREMENTS_FILENAME,
|
||||
] {
|
||||
assert_eq!(name, name.to_ascii_lowercase(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_root_alias_matches_physical_destination() {
|
||||
let resolved_root = resolve_following_symlinks(Path::new("/etc"), 0).unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue