Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -103,6 +103,7 @@ pub(crate) const ALL_TOOL_KINDS: &[ToolKind] = &[
|
|||
ToolKind::UseTool,
|
||||
ToolKind::Monitor,
|
||||
ToolKind::GoalUpdate,
|
||||
ToolKind::Workflow,
|
||||
ToolKind::Other,
|
||||
];
|
||||
|
||||
|
|
@ -149,8 +150,7 @@ pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
|
|||
// Bash / shell.
|
||||
Execute => matches!(mode, M::Execute),
|
||||
|
||||
// Process control (background tasks, monitors).
|
||||
BackgroundTaskAction | WaitTasksAction | KillTaskAction | Task | Monitor => {
|
||||
BackgroundTaskAction | WaitTasksAction | KillTaskAction | Task | Monitor | Workflow => {
|
||||
matches!(mode, M::Execute)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -246,11 +246,11 @@ pub fn repo_configs_present(cwd: &Path) -> bool {
|
|||
|
||||
/// Display-only: which repo-local trust-sensitive config KINDS are present for
|
||||
/// `cwd` (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`, `roles`,
|
||||
/// `personas`), deduped in cheap→expensive marker order. Single source with
|
||||
/// [`repo_configs_present`] (which is `!repo_config_kinds(cwd).is_empty()`), so a
|
||||
/// folder that the gate fired on always has a non-empty, accurate kind list — no
|
||||
/// `[plugins].paths` / `.claude` / `.grok/agents` / subdir-launch gaps. NOT
|
||||
/// itself the trust gate.
|
||||
/// `personas`, `workflows`), deduped in cheap→expensive marker order. Single
|
||||
/// source with [`repo_configs_present`] (which is
|
||||
/// `!repo_config_kinds(cwd).is_empty()`), so a folder that the gate fired on
|
||||
/// always has a non-empty, accurate kind list — no `[plugins].paths` / `.claude`
|
||||
/// / `.grok/agents` / subdir-launch gaps. NOT itself the trust gate.
|
||||
pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
|
||||
collect_repo_config_kinds(cwd, false)
|
||||
}
|
||||
|
|
@ -393,6 +393,9 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str>
|
|||
if directory_present_or_uncertain(&grok.join("personas")) {
|
||||
hit!("personas");
|
||||
}
|
||||
if directory_present_or_uncertain(&hook_root.join(".grok").join("workflows")) {
|
||||
hit!("workflows");
|
||||
}
|
||||
// `~/.claude.json` `projects.<cwd>.mcpServers`.
|
||||
if claude_project_mcp_present(cwd) {
|
||||
hit!("mcp");
|
||||
|
|
@ -670,6 +673,16 @@ mod tests {
|
|||
assert!(!repo_configs_present(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_configs_present_detects_project_workflows_from_subdir() {
|
||||
let tmp = repo_tmp();
|
||||
std::fs::create_dir_all(tmp.path().join(".grok").join("workflows")).unwrap();
|
||||
let subdir = tmp.path().join("crates").join("inner");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
assert!(repo_configs_present(&subdir));
|
||||
assert!(repo_config_kinds(&subdir).contains(&"workflows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_configs_present_detects_claude_settings_from_subdir() {
|
||||
// A `.claude/settings.json` `env` in a SUBDIR (no other repo config),
|
||||
|
|
|
|||
|
|
@ -3851,6 +3851,9 @@ pub async fn connect_local_workspace(
|
|||
.await;
|
||||
});
|
||||
}
|
||||
tokio::task::spawn_blocking(|| {
|
||||
crate::worktree::run_auto_gc_best_effort();
|
||||
});
|
||||
let ws_handle = WorkspaceHandle::new_with_data_collection(
|
||||
ws_config,
|
||||
workspace_home,
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ async fn tasks_snapshot(toolset: &FinalizedToolset) -> TasksSnapshotResponse {
|
|||
let _ = handle.0.send(SchedulerCommand::List { reply: reply_tx });
|
||||
reply_rx
|
||||
.await
|
||||
.map(|snapshot| snapshot.tasks)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|t| ScheduledTaskSnapshotWire {
|
||||
|
|
@ -1126,7 +1127,10 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::capability::CapabilityMode;
|
||||
use crate::handle::tests::{background_capable_cfg, make_handle, start_background_sleep};
|
||||
use xai_grok_tools::implementations::grok_build::scheduler::types::ScheduledTask;
|
||||
use xai_grok_tools::implementations::grok_build::scheduler::types::{
|
||||
ScheduledTask, SchedulerState,
|
||||
};
|
||||
use xai_grok_tools::types::resources::State;
|
||||
use xai_tool_protocol::turn_hook;
|
||||
/// Helper: consume the first item from a ToolStream.
|
||||
async fn next_item(
|
||||
|
|
@ -1411,18 +1415,12 @@ mod tests {
|
|||
snap.background_tasks
|
||||
);
|
||||
{
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
if let SchedulerCommand::List { reply } = cmd {
|
||||
let mut task = ScheduledTask::new(300, "check CI".into(), true, false);
|
||||
task.id = "loop-1".into();
|
||||
let _ = reply.send(vec![task]);
|
||||
}
|
||||
}
|
||||
});
|
||||
let toolset = session.toolset();
|
||||
toolset.resources.lock().await.insert(SchedulerHandle(tx));
|
||||
let mut resources = toolset.resources.lock().await;
|
||||
let state = resources.get_or_default::<State<SchedulerState>>();
|
||||
let mut task = ScheduledTask::new(300, "check CI".into(), true, false);
|
||||
task.id = "loop-1".into();
|
||||
state.tasks.push(task);
|
||||
}
|
||||
let snap = snapshot(&handler).await;
|
||||
assert_eq!(snap.scheduled_tasks.len(), 1);
|
||||
|
|
|
|||
|
|
@ -383,6 +383,28 @@ const ROUTINE_PREFIXES: &[&str] = &[
|
|||
"set", // shell options affect only the spawned shell
|
||||
];
|
||||
|
||||
/// kubectl flags that select caller-controlled config / endpoint / auth /
|
||||
/// identity (including shorthands). Shared with
|
||||
/// `manager.rs::kubectl_has_unsafe_flag` so the two classifiers cannot drift.
|
||||
pub(crate) const KUBECTL_UNSAFE_FLAGS: &[&str] = &[
|
||||
"--kubeconfig",
|
||||
"--context",
|
||||
"--cluster",
|
||||
"--server",
|
||||
"-s",
|
||||
"--token",
|
||||
"--user",
|
||||
"--as",
|
||||
"--as-group",
|
||||
"--as-uid",
|
||||
"--as-user-extra",
|
||||
"--username",
|
||||
"--password",
|
||||
"--client-certificate",
|
||||
"--client-key",
|
||||
"--certificate-authority",
|
||||
];
|
||||
|
||||
/// Env var KEYs safe to set for a routine command: cosmetic / logging only, with
|
||||
/// no effect on which binary runs or how it resolves code. Anything else
|
||||
/// (LD_PRELOAD, DYLD_*, PATH, NODE_OPTIONS, PYTHONPATH, GIT_SSH_COMMAND, FOO, ...)
|
||||
|
|
@ -500,6 +522,24 @@ fn bash_command_is_routine(words: &[String]) -> bool {
|
|||
{
|
||||
return false;
|
||||
}
|
||||
// `rg --pre <cmd>` runs <cmd> per searched file; `--pre-glob` only filters.
|
||||
if head == "rg"
|
||||
&& inner
|
||||
.iter()
|
||||
.any(|w| w == "--pre" || w.starts_with("--pre="))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// kubectl with caller-controlled kubeconfig/endpoint/identity can run an
|
||||
// exec credential plugin; mirrors manager.rs::kubectl_has_unsafe_flag.
|
||||
if head == "kubectl"
|
||||
&& inner.iter().skip(1).any(|w| {
|
||||
let name = w.split_once('=').map_or(w.as_str(), |(name, _)| name);
|
||||
KUBECTL_UNSAFE_FLAGS.contains(&name)
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Fail-closed read-only matchers (mutating siblings must not ride a prefix).
|
||||
if head == "gh" {
|
||||
return gh_subcommand_is_read_only(inner);
|
||||
|
|
@ -1645,6 +1685,55 @@ mod tests {
|
|||
assert_eq!(v("find . -type f"), ClassifierVerdict::Allow);
|
||||
}
|
||||
|
||||
/// `rg --pre <cmd>` executes <cmd> per searched file → must not auto-allow,
|
||||
/// mirroring `manager.rs::rg_has_pre_flag`. `--pre-glob` only filters and
|
||||
/// stays routine.
|
||||
#[test]
|
||||
fn heuristic_guards_rg_pre() {
|
||||
let empty = ClassifierContext::default();
|
||||
let v = |cmd: &str| {
|
||||
HeuristicPermissionClassifier::classify_sync(
|
||||
"run_terminal_command",
|
||||
&AccessKind::Bash(cmd.into()),
|
||||
Some(cmd),
|
||||
&empty,
|
||||
)
|
||||
};
|
||||
assert_eq!(v("rg --pre ./pre.sh TODO ."), ClassifierVerdict::Block);
|
||||
assert_eq!(v("rg --pre=./pre.sh TODO ."), ClassifierVerdict::Block);
|
||||
assert_eq!(v("rg --pre-glob '*.pdf' TODO ."), ClassifierVerdict::Allow);
|
||||
assert_eq!(v("rg TODO ."), ClassifierVerdict::Allow);
|
||||
}
|
||||
|
||||
/// `kubectl` with a caller-controlled kubeconfig/endpoint/identity flag must
|
||||
/// not be routine, mirroring `manager.rs::kubectl_has_unsafe_flag`. Plain
|
||||
/// read verbs with trusted default kubeconfig stay Allow.
|
||||
#[test]
|
||||
fn heuristic_guards_kubectl_unsafe_flags() {
|
||||
let empty = ClassifierContext::default();
|
||||
let v = |cmd: &str| {
|
||||
HeuristicPermissionClassifier::classify_sync(
|
||||
"run_terminal_command",
|
||||
&AccessKind::Bash(cmd.into()),
|
||||
Some(cmd),
|
||||
&empty,
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
v("kubectl get pods --kubeconfig=/tmp/evil.yaml"),
|
||||
ClassifierVerdict::Block
|
||||
);
|
||||
assert_eq!(
|
||||
v("kubectl get pods --kubeconfig /tmp/evil.yaml"),
|
||||
ClassifierVerdict::Block
|
||||
);
|
||||
assert_eq!(
|
||||
v("kubectl get pods -s https://evil"),
|
||||
ClassifierVerdict::Block
|
||||
);
|
||||
assert_eq!(v("kubectl get pods -n prod"), ClassifierVerdict::Allow);
|
||||
}
|
||||
|
||||
/// Output redirection to a real file is dropped from the parsed word list, so
|
||||
/// the AST redirect scan must Block a Write to anything but a safe sink.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -170,39 +170,296 @@ fn is_env_assignment(tok: &str) -> bool {
|
|||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
/// Scan a leading `env`'s options/assignments → its `-C`/`--chdir` target (last
|
||||
/// wins) and the index where the inner command starts. `env` permutes options
|
||||
/// with `NAME=VALUE`, so both are scanned until the first plain word or `--`.
|
||||
fn env_scan(cmd: &[String]) -> (Option<&str>, usize) {
|
||||
let mut chdir = None;
|
||||
let mut i = 1usize;
|
||||
while let Some(tok) = cmd.get(i).map(String::as_str) {
|
||||
if tok == "--" {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if tok != "-" && tok.starts_with('-') {
|
||||
if tok == "-C" || tok == "--chdir" {
|
||||
chdir = cmd.get(i + 1).map(String::as_str);
|
||||
i += 2;
|
||||
} else if let Some(dir) = tok.strip_prefix("--chdir=") {
|
||||
chdir = Some(dir);
|
||||
i += 1;
|
||||
} else if let Some(dir) = tok.strip_prefix("-C").filter(|d| !d.is_empty()) {
|
||||
chdir = Some(dir);
|
||||
i += 1;
|
||||
} else if matches!(tok, "-u" | "--unset" | "-S" | "--split-string") {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else if is_env_assignment(tok) {
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
struct EnvScan<'a> {
|
||||
chdir: Option<&'a str>,
|
||||
/// GNU/BSD `env -S`/`--split-string` rewrites argv; never peel past it.
|
||||
has_split_string: bool,
|
||||
/// Unknown/value-taking option arity not modeled — refuse peel + Ask.
|
||||
options_uncertain: bool,
|
||||
/// High-confidence literal packed script for Bash deny recursion only.
|
||||
split_string_script: Option<String>,
|
||||
command_start: usize,
|
||||
}
|
||||
|
||||
/// Decode expansion-free word/string/concatenation *source spelling* under shell
|
||||
/// quote rules. For raw tree-sitter concatenations that still contain `'`/`"`.
|
||||
/// Already-dequoted plain argv words must not use this (env-S metasyntax like
|
||||
/// `\t` would be corrupted into shell escapes).
|
||||
pub(crate) fn decode_shell_literal_spelling(raw: &str) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let mut chars = raw.chars().peekable();
|
||||
let mut quote = None;
|
||||
while let Some(ch) = chars.next() {
|
||||
match (quote, ch) {
|
||||
(None, '\'' | '"') => quote = Some(ch),
|
||||
(Some(open), close) if open == close => quote = None,
|
||||
(None, '\\') => out.push(chars.next()?),
|
||||
(Some('"'), '\\') => match chars.next()? {
|
||||
n @ ('$' | '`' | '"' | '\\' | '\n') => out.push(n),
|
||||
n => {
|
||||
out.push('\\');
|
||||
out.push(n);
|
||||
}
|
||||
},
|
||||
// Single-quoted text is literal, including backslashes.
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
(chdir, i)
|
||||
quote.is_none().then_some(out)
|
||||
}
|
||||
|
||||
/// Option-token spelling only: shell-decode when quote chars remain (glued/equal
|
||||
/// concatenations like `-S'cmd'` / `--split-string='cmd'`). Never fold bare
|
||||
/// backslashes without quotes — those are env-S metasyntax on the payload side.
|
||||
fn decode_env_option_token(raw: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !raw.contains(['\'', '"']) {
|
||||
return std::borrow::Cow::Borrowed(raw);
|
||||
}
|
||||
match decode_shell_literal_spelling(raw) {
|
||||
Some(decoded) => std::borrow::Cow::Owned(decoded),
|
||||
// Unclosed quotes: keep raw so flag detection can still Ask.
|
||||
None => std::borrow::Cow::Borrowed(raw),
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe subset for recursing a packed `env -S` operand as a Bash script: no
|
||||
/// env-S quotes/escapes/comments/expansions that could diverge under reparse.
|
||||
/// Includes bare `\` so `\t`/`\n`/… stay non-extractable (Ask floor only).
|
||||
fn is_high_confidence_env_s_payload(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.contains('\0')
|
||||
&& !s
|
||||
.chars()
|
||||
.any(|c| matches!(c, '\'' | '"' | '\\' | '#' | '`' | '$' | '\n' | '\r'))
|
||||
}
|
||||
|
||||
/// Safe-subset check + own. Callers must pass already-literal text (separate
|
||||
/// argv word, or payload carved after option-token quote removal) — never raw
|
||||
/// shell-escape sequences meant for env-S.
|
||||
fn take_high_confidence_payload(raw: &str) -> Option<String> {
|
||||
is_high_confidence_env_s_payload(raw).then(|| raw.to_owned())
|
||||
}
|
||||
|
||||
/// Classification of one short-option token for GNU/BSD `env` (minimal table).
|
||||
enum EnvShort<'a> {
|
||||
SplitStringNext,
|
||||
SplitStringGlued(&'a str),
|
||||
/// Cluster `S` after modeled no-arg shorts: detect only.
|
||||
SplitStringDetect,
|
||||
NoArg,
|
||||
ArgNeedNext(char),
|
||||
/// Glued arg-taking short; `kind` is `u`/`C`/`P`/`a` (operand is rest of token).
|
||||
ArgGlued {
|
||||
kind: char,
|
||||
},
|
||||
Uncertain,
|
||||
}
|
||||
|
||||
/// Walk one short-option token. Arg-taking `u`/`C`/`P`/`a` absorb the rest of
|
||||
/// the token as their operand (do not treat `S` inside that operand as `-S`).
|
||||
fn classify_env_short(tok: &str) -> EnvShort<'_> {
|
||||
if !tok.starts_with('-') || tok.starts_with("--") || tok == "-" {
|
||||
return EnvShort::Uncertain;
|
||||
}
|
||||
let body = &tok[1..];
|
||||
if body.is_empty() {
|
||||
return EnvShort::Uncertain;
|
||||
}
|
||||
if let Some(rest) = body.strip_prefix('S') {
|
||||
return if rest.is_empty() {
|
||||
EnvShort::SplitStringNext
|
||||
} else {
|
||||
EnvShort::SplitStringGlued(rest)
|
||||
};
|
||||
}
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let mut idx = 0usize;
|
||||
while idx < chars.len() {
|
||||
match chars[idx] {
|
||||
'i' | 'v' | '0' => idx += 1,
|
||||
'S' => return EnvShort::SplitStringDetect,
|
||||
kind @ ('u' | 'C' | 'P' | 'a') => {
|
||||
let has_glued = body.char_indices().nth(idx + 1).is_some();
|
||||
return if has_glued {
|
||||
EnvShort::ArgGlued { kind }
|
||||
} else {
|
||||
EnvShort::ArgNeedNext(kind)
|
||||
};
|
||||
}
|
||||
_ => return EnvShort::Uncertain,
|
||||
}
|
||||
}
|
||||
EnvShort::NoArg
|
||||
}
|
||||
|
||||
/// Consume a required option operand; missing → uncertain.
|
||||
fn take_option_operand<'a>(cmd: &'a [String], i: &mut usize) -> Option<&'a str> {
|
||||
match cmd.get(*i + 1).map(String::as_str) {
|
||||
Some(v) => {
|
||||
*i += 2;
|
||||
Some(v)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a leading `env`'s options/assignments → its `-C`/`--chdir` target (last
|
||||
/// wins), whether a split-string rewrite is present, an optional high-confidence
|
||||
/// packed script, and the index where the inner command starts. `env` permutes
|
||||
/// options with `NAME=VALUE`. Minimal GNU/BSD table — unknown arity fails closed.
|
||||
fn env_scan(cmd: &[String]) -> EnvScan<'_> {
|
||||
let mut chdir = None;
|
||||
let mut has_split_string = false;
|
||||
let mut options_uncertain = false;
|
||||
let mut split_string_script = None;
|
||||
let mut i = 1usize;
|
||||
while let Some(tok_raw) = cmd.get(i).map(String::as_str) {
|
||||
if tok_raw == "--" {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
// GNU: bare `-` is a synonym for `-i` (still an option, not the command).
|
||||
if tok_raw == "-" {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !tok_raw.starts_with('-') {
|
||||
if is_env_assignment(tok_raw) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if tok_raw.starts_with("--") {
|
||||
if tok_raw == "--chdir" {
|
||||
match take_option_operand(cmd, &mut i) {
|
||||
Some(dir) => chdir = Some(dir),
|
||||
None => {
|
||||
options_uncertain = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(dir) = tok_raw.strip_prefix("--chdir=") {
|
||||
chdir = Some(dir);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// `--path` pairs with BSD-style `-P`; unmodeled longs (e.g. `--prefix`) uncertain.
|
||||
if tok_raw == "--unset" || tok_raw == "--path" || tok_raw == "--argv0" {
|
||||
if take_option_operand(cmd, &mut i).is_none() {
|
||||
options_uncertain = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if tok_raw.starts_with("--unset=")
|
||||
|| tok_raw.starts_with("--path=")
|
||||
|| tok_raw.starts_with("--argv0=")
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
tok_raw,
|
||||
"--ignore-environment" | "--null" | "--debug" | "--version" | "--help"
|
||||
) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if tok_raw == "--split-string" {
|
||||
// WHY: `-S` re-tokenizes a packed string; do not peel past it.
|
||||
has_split_string = true;
|
||||
if let Some(raw) = cmd.get(i + 1).map(String::as_str) {
|
||||
split_string_script = take_high_confidence_payload(raw);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Some(payload) = tok_raw.strip_prefix("--split-string=") {
|
||||
let decoded = decode_env_option_token(tok_raw);
|
||||
let payload = decoded
|
||||
.as_ref()
|
||||
.strip_prefix("--split-string=")
|
||||
.unwrap_or(payload);
|
||||
has_split_string = true;
|
||||
split_string_script = take_high_confidence_payload(payload);
|
||||
break;
|
||||
}
|
||||
// WHY: unknown long may take a value and hide a later `-S`.
|
||||
options_uncertain = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let tok_decoded = decode_env_option_token(tok_raw);
|
||||
let tok = tok_decoded.as_ref();
|
||||
match classify_env_short(tok) {
|
||||
EnvShort::SplitStringNext => {
|
||||
has_split_string = true;
|
||||
if let Some(raw) = cmd.get(i + 1).map(String::as_str) {
|
||||
split_string_script = take_high_confidence_payload(raw);
|
||||
}
|
||||
break;
|
||||
}
|
||||
EnvShort::SplitStringGlued(payload) => {
|
||||
has_split_string = true;
|
||||
split_string_script = take_high_confidence_payload(payload);
|
||||
break;
|
||||
}
|
||||
EnvShort::SplitStringDetect => {
|
||||
has_split_string = true;
|
||||
break;
|
||||
}
|
||||
EnvShort::NoArg => i += 1,
|
||||
EnvShort::ArgNeedNext(kind) => match take_option_operand(cmd, &mut i) {
|
||||
Some(operand) => {
|
||||
if kind == 'C' {
|
||||
chdir = Some(operand);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
options_uncertain = true;
|
||||
break;
|
||||
}
|
||||
},
|
||||
EnvShort::ArgGlued { kind } => {
|
||||
if kind == 'C' {
|
||||
chdir = Some(
|
||||
tok_raw
|
||||
.strip_prefix("-C")
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
EnvShort::Uncertain => {
|
||||
options_uncertain = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EnvScan {
|
||||
chdir,
|
||||
has_split_string,
|
||||
options_uncertain,
|
||||
split_string_script,
|
||||
command_start: i,
|
||||
}
|
||||
}
|
||||
|
||||
/// High-confidence packed `env -S`/`--split-string` operand when `words` is an
|
||||
/// env invocation stopped on a recoverable literal form. Bash deny only.
|
||||
pub(crate) fn env_split_string_script(words: &[String]) -> Option<String> {
|
||||
if words.first()?.rsplit(['/', '\\']).next()? != "env" {
|
||||
return None;
|
||||
}
|
||||
let scan = env_scan(words);
|
||||
if !scan.has_split_string {
|
||||
return None;
|
||||
}
|
||||
scan.split_string_script
|
||||
}
|
||||
|
||||
/// Strip a leading wrapper (`timeout`/`env`/`nice`/`stdbuf`/`ionice`/`chrt`) and
|
||||
|
|
@ -281,7 +538,14 @@ pub(crate) fn strip_wrapper_command(cmd: &[String]) -> Option<&[String]> {
|
|||
}
|
||||
}
|
||||
// `env [OPTIONS]/[NAME=VALUE] (interspersed) [COMMAND] [ARGS]`
|
||||
"env" => i = env_scan(cmd).1,
|
||||
"env" => {
|
||||
let scan = env_scan(cmd);
|
||||
// WHY: split-string rewrites argv; uncertain option arity can hide it.
|
||||
if scan.has_split_string || scan.options_uncertain {
|
||||
return None;
|
||||
}
|
||||
i = scan.command_start;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
let inner = cmd.get(i..)?;
|
||||
|
|
@ -302,37 +566,277 @@ pub(crate) fn is_wrapper_command(words: &[String]) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
pub(crate) const MAX_WRAPPER_DEPTH: usize = 8;
|
||||
/// Independent budget for `shell -c` recursion (not wrapper peel layers).
|
||||
pub(crate) const MAX_INLINE_SHELL_DEPTH: usize = 8;
|
||||
|
||||
pub(crate) struct CheckedWrapperPeel<'a> {
|
||||
pub(crate) words: &'a [String],
|
||||
pub(crate) has_chdir: bool,
|
||||
/// `env -S`/`--split-string` rewrite was seen; callers must Ask (floor).
|
||||
pub(crate) has_split_string: bool,
|
||||
/// Unmodeled env option arity; callers must Ask and must not peel.
|
||||
pub(crate) env_options_uncertain: bool,
|
||||
pub(crate) exhausted: bool,
|
||||
}
|
||||
|
||||
/// Peel canonical wrappers while retaining cwd, split-string, and exhaustion facts.
|
||||
pub(crate) fn unwrap_wrappers_checked(words: &[String]) -> CheckedWrapperPeel<'_> {
|
||||
let mut current = words;
|
||||
let mut has_chdir = false;
|
||||
let mut has_split_string = false;
|
||||
let mut env_options_uncertain = false;
|
||||
for _ in 0..MAX_WRAPPER_DEPTH {
|
||||
if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env") {
|
||||
let scan = env_scan(current);
|
||||
has_chdir |= scan.chdir.is_some();
|
||||
has_split_string |= scan.has_split_string;
|
||||
env_options_uncertain |= scan.options_uncertain;
|
||||
}
|
||||
match strip_wrapper_command(current) {
|
||||
Some(inner) => current = inner,
|
||||
None => {
|
||||
return CheckedWrapperPeel {
|
||||
words: current,
|
||||
has_chdir,
|
||||
has_split_string,
|
||||
env_options_uncertain,
|
||||
exhausted: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
CheckedWrapperPeel {
|
||||
words: current,
|
||||
has_chdir,
|
||||
has_split_string,
|
||||
env_options_uncertain,
|
||||
exhausted: is_wrapper_command(current) && strip_wrapper_command(current).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Repeatedly strip wrapper commands (e.g. `timeout 30 nice -n 10 rm -rf /`).
|
||||
/// Bounded to avoid pathological loops. Returns the original slice if no
|
||||
/// wrapper is present.
|
||||
pub(crate) fn unwrap_wrappers(words: &[String]) -> &[String] {
|
||||
let mut current = words;
|
||||
for _ in 0..8 {
|
||||
match strip_wrapper_command(current) {
|
||||
Some(inner) => current = inner,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
current
|
||||
unwrap_wrappers_checked(words).words
|
||||
}
|
||||
|
||||
/// Whether the command runs under an `env -C`/`--chdir` (possibly path-qualified,
|
||||
/// behind other wrappers). Only presence is reported — the caller treats such an
|
||||
/// invocation's relative operands as unpinnable rather than resolving the dir.
|
||||
pub(crate) fn wrapper_has_chdir(words: &[String]) -> bool {
|
||||
/// Result of peeling shell-transparent prefixes (`exec` / `command` / `builtin`).
|
||||
/// Not part of the canonical wrapper set — used only by security gates.
|
||||
pub(crate) enum TransparentPrefixPeel<'a> {
|
||||
Ready(&'a [String]),
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
pub(crate) const MAX_TRANSPARENT_PREFIX_DEPTH: usize = 8;
|
||||
/// Bound on alternating wrapper ↔ transparent normalize rounds.
|
||||
pub(crate) const MAX_NORMALIZE_ROUNDS: usize = MAX_WRAPPER_DEPTH + MAX_TRANSPARENT_PREFIX_DEPTH;
|
||||
|
||||
/// Peel `exec`/`command`/`builtin` after canonical wrappers. Unknown options and
|
||||
/// depth exhaustion (a ninth peelable transparent prefix remains) Ask.
|
||||
pub(crate) fn peel_transparent_prefixes(words: &[String]) -> TransparentPrefixPeel<'_> {
|
||||
let mut current = words;
|
||||
for _ in 0..8 {
|
||||
if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env")
|
||||
&& env_scan(current).0.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match strip_wrapper_command(current) {
|
||||
Some(inner) => current = inner,
|
||||
None => break,
|
||||
for _ in 0..MAX_TRANSPARENT_PREFIX_DEPTH {
|
||||
match strip_transparent_prefix(current) {
|
||||
TransparentStrip::NotPrefix => return TransparentPrefixPeel::Ready(current),
|
||||
TransparentStrip::Peeled(inner) => current = inner,
|
||||
TransparentStrip::Ambiguous => return TransparentPrefixPeel::Ambiguous,
|
||||
}
|
||||
}
|
||||
false
|
||||
// WHY: mirror wrapper exhaustion — a remaining peelable prefix is unmodeled.
|
||||
match strip_transparent_prefix(current) {
|
||||
TransparentStrip::NotPrefix => TransparentPrefixPeel::Ready(current),
|
||||
TransparentStrip::Peeled(_) | TransparentStrip::Ambiguous => {
|
||||
TransparentPrefixPeel::Ambiguous
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized argv after bounded alternation of wrapper and transparent peels.
|
||||
pub(crate) struct NormalizedCommandPeel<'a> {
|
||||
pub(crate) words: &'a [String],
|
||||
pub(crate) has_chdir: bool,
|
||||
pub(crate) has_split_string: bool,
|
||||
pub(crate) env_options_uncertain: bool,
|
||||
pub(crate) exhausted: bool,
|
||||
pub(crate) ambiguous: bool,
|
||||
}
|
||||
|
||||
/// Alternate canonical wrappers and transparent prefixes to a bounded fixed
|
||||
/// point so shapes like `command timeout command env -S …` surface the pack.
|
||||
/// Ambiguity, depth exhaustion, chdir, split-string, and uncertain env options
|
||||
/// all fail closed (callers Ask).
|
||||
pub(crate) fn normalize_command_words(words: &[String]) -> NormalizedCommandPeel<'_> {
|
||||
let mut current = words;
|
||||
let mut has_chdir = false;
|
||||
let mut has_split_string = false;
|
||||
let mut env_options_uncertain = false;
|
||||
let mut exhausted = false;
|
||||
let mut ambiguous = false;
|
||||
|
||||
for _ in 0..MAX_NORMALIZE_ROUNDS {
|
||||
let start_len = current.len();
|
||||
let start_ptr = current.as_ptr();
|
||||
|
||||
let wrapped = unwrap_wrappers_checked(current);
|
||||
has_chdir |= wrapped.has_chdir;
|
||||
has_split_string |= wrapped.has_split_string;
|
||||
env_options_uncertain |= wrapped.env_options_uncertain;
|
||||
exhausted |= wrapped.exhausted;
|
||||
current = wrapped.words;
|
||||
|
||||
// Opaque pack / uncertain env options: do not peel further.
|
||||
if has_split_string || env_options_uncertain {
|
||||
break;
|
||||
}
|
||||
|
||||
match peel_transparent_prefixes(current) {
|
||||
TransparentPrefixPeel::Ready(inner) => current = inner,
|
||||
TransparentPrefixPeel::Ambiguous => {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if current.len() == start_len && std::ptr::eq(current.as_ptr(), start_ptr) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !ambiguous && !has_split_string && !env_options_uncertain {
|
||||
// Remaining peelable head after the budget is unmodeled → fail closed.
|
||||
let still_wrapper = is_wrapper_command(current) && strip_wrapper_command(current).is_some();
|
||||
let still_transparent = !matches!(
|
||||
strip_transparent_prefix(current),
|
||||
TransparentStrip::NotPrefix
|
||||
);
|
||||
if still_wrapper || still_transparent {
|
||||
exhausted = true;
|
||||
}
|
||||
}
|
||||
|
||||
NormalizedCommandPeel {
|
||||
words: current,
|
||||
has_chdir,
|
||||
has_split_string,
|
||||
env_options_uncertain,
|
||||
exhausted,
|
||||
ambiguous,
|
||||
}
|
||||
}
|
||||
|
||||
enum TransparentStrip<'a> {
|
||||
NotPrefix,
|
||||
Peeled(&'a [String]),
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
fn strip_transparent_prefix(cmd: &[String]) -> TransparentStrip<'_> {
|
||||
let head = match cmd.first().map(String::as_str) {
|
||||
Some(h) if !h.is_empty() && h != "\0" => h.rsplit(['/', '\\']).next().unwrap_or(h),
|
||||
_ => return TransparentStrip::NotPrefix,
|
||||
};
|
||||
match head {
|
||||
"exec" => strip_exec_prefix(cmd),
|
||||
"command" => strip_command_prefix(cmd),
|
||||
"builtin" => strip_builtin_prefix(cmd),
|
||||
_ => TransparentStrip::NotPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// `exec [-cl] [-a name] [command [arguments]]`
|
||||
fn strip_exec_prefix(cmd: &[String]) -> TransparentStrip<'_> {
|
||||
let mut i = 1usize;
|
||||
while let Some(tok) = cmd.get(i).map(String::as_str) {
|
||||
if tok == "--" {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if tok == "-" || !tok.starts_with('-') {
|
||||
break;
|
||||
}
|
||||
if matches!(tok, "-c" | "-l" | "-cl" | "-lc") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if tok == "-a" {
|
||||
match cmd.get(i + 1).map(String::as_str) {
|
||||
Some(v) if v != "\0" && !v.is_empty() => i += 2,
|
||||
_ => return TransparentStrip::Ambiguous,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = tok.strip_prefix("-a").filter(|r| !r.is_empty()) {
|
||||
if rest == "\0" {
|
||||
return TransparentStrip::Ambiguous;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Combined shorts that include only c/l plus optional glued -a are rare;
|
||||
// anything else is unmodeled.
|
||||
if tok.starts_with('-')
|
||||
&& !tok.starts_with("--")
|
||||
&& tok.chars().skip(1).all(|c| matches!(c, 'c' | 'l'))
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return TransparentStrip::Ambiguous;
|
||||
}
|
||||
match cmd.get(i..) {
|
||||
Some(inner) if !inner.is_empty() => TransparentStrip::Peeled(inner),
|
||||
// Redirect-only `exec` has no command word; leave it for the AST redirect path.
|
||||
_ => TransparentStrip::NotPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// `command [-p] command [arguments]` — do not peel display forms `-v`/`-V`.
|
||||
fn strip_command_prefix(cmd: &[String]) -> TransparentStrip<'_> {
|
||||
let mut i = 1usize;
|
||||
while let Some(tok) = cmd.get(i).map(String::as_str) {
|
||||
if tok == "--" {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if tok == "-" || !tok.starts_with('-') {
|
||||
break;
|
||||
}
|
||||
if matches!(tok, "-v" | "-V" | "--help" | "--version")
|
||||
|| (tok.starts_with('-')
|
||||
&& !tok.starts_with("--")
|
||||
&& tok.chars().skip(1).any(|c| c == 'v' || c == 'V'))
|
||||
{
|
||||
// Display/query mode is not an execute peel.
|
||||
return TransparentStrip::NotPrefix;
|
||||
}
|
||||
if tok == "-p"
|
||||
|| (tok.starts_with('-')
|
||||
&& !tok.starts_with("--")
|
||||
&& tok.chars().skip(1).all(|c| c == 'p'))
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
return TransparentStrip::Ambiguous;
|
||||
}
|
||||
match cmd.get(i..) {
|
||||
Some(inner) if !inner.is_empty() => TransparentStrip::Peeled(inner),
|
||||
_ => TransparentStrip::NotPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// `builtin [shell-builtin [arguments]]` — no options modeled.
|
||||
fn strip_builtin_prefix(cmd: &[String]) -> TransparentStrip<'_> {
|
||||
match cmd.get(1).map(String::as_str) {
|
||||
None => TransparentStrip::NotPrefix,
|
||||
Some(tok) if tok.starts_with('-') && tok != "-" => TransparentStrip::Ambiguous,
|
||||
Some(_) => match cmd.get(1..) {
|
||||
Some(inner) if !inner.is_empty() => TransparentStrip::Peeled(inner),
|
||||
_ => TransparentStrip::NotPrefix,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple shell-like splitter that:
|
||||
|
|
@ -741,11 +1245,147 @@ pub fn soft_break_chunks(script: &str) -> Vec<&str> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BashCommandHighlights, heredoc_payload_byte_ranges, primary_command_from_script,
|
||||
BashCommandHighlights, all_commands_from_script, env_split_string_script,
|
||||
heredoc_payload_byte_ranges, normalize_command_words, primary_command_from_script,
|
||||
range_fully_inside, soft_break_chunks, soft_break_offsets_after_operators,
|
||||
split_physical_line_at_soft_breaks, try_parse_shell,
|
||||
split_physical_line_at_soft_breaks, try_parse_shell, unwrap_wrappers_checked,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn env_split_string_scan_contract() {
|
||||
let words = |script: &str| {
|
||||
all_commands_from_script(script)
|
||||
.expect(script)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect(script)
|
||||
.words()
|
||||
.to_vec()
|
||||
};
|
||||
for script in [
|
||||
"env -S 'rm -rf x'",
|
||||
"env --split-string 'rm -rf x'",
|
||||
"env --split-string='rm -rf x'",
|
||||
"env -S'rm -rf x'",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(peeled.has_split_string, "{script}");
|
||||
assert_eq!(
|
||||
env_split_string_script(&w).as_deref(),
|
||||
Some("rm -rf x"),
|
||||
"{script}"
|
||||
);
|
||||
assert_eq!(
|
||||
peeled.words.first().map(String::as_str),
|
||||
Some("env"),
|
||||
"{script}"
|
||||
);
|
||||
}
|
||||
for script in [
|
||||
"env -iS 'rm -rf x'",
|
||||
"env -vS 'rm -rf x'",
|
||||
"env -0S 'rm -rf x'",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(peeled.has_split_string, "{script}");
|
||||
assert!(env_split_string_script(&w).is_none(), "{script}");
|
||||
}
|
||||
let unknown_cluster = words("env -xS 'rm -rf x'");
|
||||
let peeled = unwrap_wrappers_checked(&unknown_cluster);
|
||||
assert!(peeled.env_options_uncertain);
|
||||
assert!(!peeled.has_split_string);
|
||||
assert!(env_split_string_script(&unknown_cluster).is_none());
|
||||
for script in [
|
||||
"env -uS rm -rf x",
|
||||
"env -CS rm -rf x",
|
||||
"env -PSfoo rm -rf x",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(!peeled.has_split_string, "{script}");
|
||||
assert!(env_split_string_script(&w).is_none(), "{script}");
|
||||
}
|
||||
for script in [
|
||||
"env -P /usr/bin -S 'rm -rf x'",
|
||||
"env --path /usr/bin -S 'rm -rf x'",
|
||||
"env --path=/usr/bin -S 'rm -rf x'",
|
||||
"env -a name -S 'rm -rf x'",
|
||||
"env -u NAME -S 'rm -rf x'",
|
||||
"env -C /tmp -S 'rm -rf x'",
|
||||
"env - -S 'rm -rf x'",
|
||||
"env -iv -S 'rm -rf x'",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(peeled.has_split_string, "{script}");
|
||||
assert_eq!(
|
||||
env_split_string_script(&w).as_deref(),
|
||||
Some("rm -rf x"),
|
||||
"{script}"
|
||||
);
|
||||
assert_eq!(
|
||||
peeled.words.first().map(String::as_str),
|
||||
Some("env"),
|
||||
"{script}"
|
||||
);
|
||||
}
|
||||
for script in [
|
||||
r"env -S '\trm -rf x'",
|
||||
r"env -S '\nrm -rf x'",
|
||||
"env -S 'echo $HOME'",
|
||||
"env -S 'rm #x'",
|
||||
"env -S",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(peeled.has_split_string, "{script}");
|
||||
assert!(env_split_string_script(&w).is_none(), "{script}");
|
||||
}
|
||||
let tabbed = words("env -S 'rm\t-rf x'");
|
||||
assert!(unwrap_wrappers_checked(&tabbed).has_split_string);
|
||||
assert_eq!(
|
||||
env_split_string_script(&tabbed).as_deref(),
|
||||
Some("rm\t-rf x")
|
||||
);
|
||||
let dashed = words("env -- -S 'rm -rf x'");
|
||||
let peeled = unwrap_wrappers_checked(&dashed);
|
||||
assert!(!peeled.has_split_string);
|
||||
assert!(env_split_string_script(&dashed).is_none());
|
||||
for script in [
|
||||
"env --block-signal SEGV -S 'rm -rf x'",
|
||||
"env -x foo -S 'rm -rf x'",
|
||||
"env --prefix /usr/bin -S 'rm -rf x'",
|
||||
] {
|
||||
let w = words(script);
|
||||
let peeled = unwrap_wrappers_checked(&w);
|
||||
assert!(peeled.env_options_uncertain, "{script}");
|
||||
assert!(!peeled.has_split_string, "{script}");
|
||||
}
|
||||
let ordinary = words("env FOO=1 rm -rf x");
|
||||
let peeled = unwrap_wrappers_checked(&ordinary);
|
||||
assert!(!peeled.has_split_string);
|
||||
assert_eq!(
|
||||
peeled.words.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["rm", "-rf", "x"]
|
||||
);
|
||||
let prefix = words("env -P /usr/bin rm -rf x");
|
||||
let peeled = unwrap_wrappers_checked(&prefix);
|
||||
assert!(!peeled.has_split_string && !peeled.env_options_uncertain);
|
||||
assert_eq!(
|
||||
peeled.words.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["rm", "-rf", "x"]
|
||||
);
|
||||
let deep = words("command timeout 5 command env -S 'rm -rf x'");
|
||||
let norm = normalize_command_words(&deep);
|
||||
assert!(norm.has_split_string);
|
||||
assert_eq!(
|
||||
env_split_string_script(norm.words).as_deref(),
|
||||
Some("rm -rf x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plain_commands_from_script() {
|
||||
let command = "python3 something.py";
|
||||
|
|
|
|||
826
crates/codegen/xai-grok-workspace/src/permission/exec_risk.rs
Normal file
826
crates/codegen/xai-grok-workspace/src/permission/exec_risk.rs
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
//! Bash request-level execution risk: argv flags that spawn programs, and ambient
|
||||
//! local/worktree git config. Flag floors run inline; ambient git2 uses
|
||||
//! `spawn_blocking` from the permission actor.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::permission::bash_command_splitting::{
|
||||
MAX_TRANSPARENT_PREFIX_DEPTH, MAX_WRAPPER_DEPTH, TransparentPrefixPeel,
|
||||
peel_transparent_prefixes, unwrap_wrappers_checked,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::permission::bash_command_splitting::{
|
||||
try_parse_shell, try_parse_word_only_commands_sequence,
|
||||
};
|
||||
|
||||
/// Shared peel budget for nested `command env …` chains; remaining peelable layers fail closed.
|
||||
const MAX_NORMALIZE_ROUNDS: usize = MAX_WRAPPER_DEPTH + MAX_TRANSPARENT_PREFIX_DEPTH;
|
||||
|
||||
enum NormalizedArgv<'a> {
|
||||
Ready(&'a [String]),
|
||||
FailClosed,
|
||||
}
|
||||
|
||||
/// Alternate canonical wrappers and transparent prefixes until fixed point.
|
||||
fn normalize_for_exec_risk(words: &[String]) -> NormalizedArgv<'_> {
|
||||
let mut current = words;
|
||||
for _ in 0..MAX_NORMALIZE_ROUNDS {
|
||||
let before = current;
|
||||
let checked = unwrap_wrappers_checked(current);
|
||||
if checked.exhausted || checked.has_split_string || checked.has_chdir {
|
||||
return NormalizedArgv::FailClosed;
|
||||
}
|
||||
let after_wrap = checked.words;
|
||||
let after_trans = match peel_transparent_prefixes(after_wrap) {
|
||||
TransparentPrefixPeel::Ambiguous => return NormalizedArgv::FailClosed,
|
||||
TransparentPrefixPeel::Ready(inner) => inner,
|
||||
};
|
||||
if std::ptr::eq(after_trans.as_ptr(), before.as_ptr()) && after_trans.len() == before.len()
|
||||
{
|
||||
return NormalizedArgv::Ready(after_trans);
|
||||
}
|
||||
if std::ptr::eq(after_trans.as_ptr(), after_wrap.as_ptr())
|
||||
&& after_trans.len() == after_wrap.len()
|
||||
{
|
||||
return NormalizedArgv::Ready(after_trans);
|
||||
}
|
||||
current = after_trans;
|
||||
}
|
||||
NormalizedArgv::FailClosed
|
||||
}
|
||||
|
||||
/// `min_len` is the shortest unique stem vs sibling options (e.g. sort `--co` vs `--check`).
|
||||
fn is_accepted_long_option_prefix(flag: &str, full: &str, min_len: usize) -> bool {
|
||||
flag.starts_with("--")
|
||||
&& flag.len() >= min_len
|
||||
&& full.starts_with(flag)
|
||||
&& flag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
}
|
||||
|
||||
fn normalized_program_name(words: &[String]) -> Option<String> {
|
||||
let raw = words.first()?;
|
||||
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw.as_str());
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut name = base.to_ascii_lowercase();
|
||||
if let Some(stem) = name.strip_suffix(".exe") {
|
||||
name = stem.to_owned();
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn is_git_program(words: &[String]) -> bool {
|
||||
normalized_program_name(words).as_deref() == Some("git")
|
||||
}
|
||||
|
||||
fn is_sort_program(words: &[String]) -> bool {
|
||||
normalized_program_name(words).as_deref() == Some("sort")
|
||||
}
|
||||
|
||||
fn normalized_token_basename(token: &str) -> String {
|
||||
let base = token.rsplit(['/', '\\']).next().unwrap_or(token);
|
||||
let mut name = base.to_ascii_lowercase();
|
||||
if let Some(stem) = name.strip_suffix(".exe") {
|
||||
name = stem.to_owned();
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// GNU `sort --compress-program`; stops at `--`. Min stem `--co` vs `--check`.
|
||||
fn sort_has_compress_program_flag(words: &[String]) -> bool {
|
||||
for w in words.iter().skip(1) {
|
||||
if w == "--" {
|
||||
break;
|
||||
}
|
||||
if w == "--compress-program" || w.starts_with("--compress-program=") {
|
||||
return true;
|
||||
}
|
||||
let flag = w.split_once('=').map(|(f, _)| f).unwrap_or(w.as_str());
|
||||
if is_accepted_long_option_prefix(flag, "--compress-program", 4) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_git_config_env_flag(tok: &str) -> bool {
|
||||
if tok == "--config-env" || tok.starts_with("--config-env=") {
|
||||
return true;
|
||||
}
|
||||
let flag = tok.split_once('=').map(|(f, _)| f).unwrap_or(tok);
|
||||
// Sole git global `--config*`; min stem `--co` (len 4).
|
||||
is_accepted_long_option_prefix(flag, "--config-env", 4)
|
||||
}
|
||||
|
||||
/// Presence fails closed — these retarget which config git reads.
|
||||
fn is_git_repo_retarget_flag(tok: &str) -> bool {
|
||||
if tok == "--git-dir"
|
||||
|| tok.starts_with("--git-dir=")
|
||||
|| tok == "--work-tree"
|
||||
|| tok.starts_with("--work-tree=")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let flag = tok.split_once('=').map(|(f, _)| f).unwrap_or(tok);
|
||||
// git.c globals: `--gi` unique vs `--glob-pathspecs`; `--wor` sole `--wor*`.
|
||||
is_accepted_long_option_prefix(flag, "--git-dir", 4)
|
||||
|| is_accepted_long_option_prefix(flag, "--work-tree", 4)
|
||||
}
|
||||
|
||||
fn is_attached_git_config_c(tok: &str) -> bool {
|
||||
tok.starts_with("-c") && tok.len() > 2 && !tok.starts_with("--")
|
||||
}
|
||||
|
||||
fn attached_git_c_path(tok: &str) -> Option<&str> {
|
||||
tok.strip_prefix("-C")
|
||||
.filter(|rest| !rest.is_empty() && !tok.starts_with("--"))
|
||||
}
|
||||
|
||||
fn git_global_option_takes_value(tok: &str) -> bool {
|
||||
matches!(
|
||||
tok,
|
||||
"-C" | "-c"
|
||||
| "--git-dir"
|
||||
| "--work-tree"
|
||||
| "--namespace"
|
||||
| "--super-prefix"
|
||||
| "--exec-path"
|
||||
| "--list-cmds"
|
||||
| "--attr-source"
|
||||
| "--config-env"
|
||||
) || is_accepted_long_option_prefix(tok, "--config-env", 4)
|
||||
|| is_accepted_long_option_prefix(tok, "--git-dir", 4)
|
||||
|| is_accepted_long_option_prefix(tok, "--work-tree", 4)
|
||||
|| is_accepted_long_option_prefix(tok, "--namespace", 7)
|
||||
|| is_accepted_long_option_prefix(tok, "--super-prefix", 8)
|
||||
|| is_accepted_long_option_prefix(tok, "--exec-path", 7)
|
||||
|| is_accepted_long_option_prefix(tok, "--list-cmds", 7)
|
||||
|| is_accepted_long_option_prefix(tok, "--attr-source", 8)
|
||||
}
|
||||
|
||||
/// Pre-subcommand only; missing values fail closed. Post-subcommand `git log -c` is not scanned.
|
||||
pub(crate) fn git_has_exec_risk_global(words: &[String]) -> bool {
|
||||
let mut i = 1;
|
||||
while i < words.len() {
|
||||
let tok = words[i].as_str();
|
||||
if tok == "--" {
|
||||
return false;
|
||||
}
|
||||
if !tok.starts_with('-') || tok == "-" {
|
||||
return false;
|
||||
}
|
||||
if tok == "-c"
|
||||
|| is_attached_git_config_c(tok)
|
||||
|| is_git_config_env_flag(tok)
|
||||
|| is_git_repo_retarget_flag(tok)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// `-Cpath` is cwd-only (ambient); skip so it is not treated as the subcommand.
|
||||
if attached_git_c_path(tok).is_some() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !tok.contains('=')
|
||||
&& git_global_option_takes_value(tok)
|
||||
&& words
|
||||
.get(i + 1)
|
||||
.is_some_and(|n| !n.starts_with('-') || n == "-")
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn segment_has_exec_risk_flag(words: &[String]) -> bool {
|
||||
if is_sort_program(words) {
|
||||
return sort_has_compress_program_flag(words);
|
||||
}
|
||||
if is_git_program(words) {
|
||||
return git_has_exec_risk_global(words);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct SegmentExecFacts {
|
||||
pub exec_risk: bool,
|
||||
pub has_git: bool,
|
||||
}
|
||||
|
||||
/// Normalize raw segment words, then inspect git/sort. Unmodeled peels fail closed.
|
||||
pub(crate) fn segment_exec_facts(words: &[String]) -> SegmentExecFacts {
|
||||
match normalize_for_exec_risk(words) {
|
||||
NormalizedArgv::FailClosed => SegmentExecFacts {
|
||||
exec_risk: true,
|
||||
has_git: false,
|
||||
},
|
||||
NormalizedArgv::Ready(inner) => SegmentExecFacts {
|
||||
exec_risk: segment_has_exec_risk_flag(inner),
|
||||
has_git: is_git_program(inner),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_GIT_SUBCOMMANDS: &[&str] = &[
|
||||
"status",
|
||||
"branch",
|
||||
"log",
|
||||
"diff",
|
||||
"ls-files",
|
||||
"show",
|
||||
"rev-parse",
|
||||
];
|
||||
|
||||
fn local_git_config_entry_is_exec(name: &str, value: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if name == "core.fsmonitor" {
|
||||
return git2::Config::parse_bool(value).is_err();
|
||||
}
|
||||
if name == "diff.external" {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = name.strip_prefix("diff.")
|
||||
&& (rest.ends_with(".command")
|
||||
|| rest.ends_with(".textconv")
|
||||
|| rest.ends_with(".external"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Some(alias) = name.strip_prefix("alias.")
|
||||
&& SAFE_GIT_SUBCOMMANDS.contains(&alias)
|
||||
&& value.starts_with('!')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn path_unreadable(path: &Path) -> bool {
|
||||
// Directories open on Linux, so require a readable regular file after following symlinks.
|
||||
match std::fs::File::open(path) {
|
||||
Ok(f) => match f.metadata() {
|
||||
Ok(meta) => !meta.is_file(),
|
||||
Err(_) => true,
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local/worktree only via libgit2 (include/includeIf). Fail closed on read errors.
|
||||
pub(crate) fn local_repo_config_has_exec_risk(cwd: &Path) -> bool {
|
||||
let repo = match git2::Repository::discover(cwd) {
|
||||
Ok(repo) => repo,
|
||||
Err(e)
|
||||
if e.code() == git2::ErrorCode::NotFound
|
||||
&& e.class() == git2::ErrorClass::Repository =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Err(_) => return true,
|
||||
};
|
||||
// `repo.config()` can still open global levels when local is unreadable.
|
||||
let git_dir = repo.path();
|
||||
let common = repo.commondir();
|
||||
if path_unreadable(&common.join("config"))
|
||||
|| path_unreadable(&git_dir.join("config"))
|
||||
|| path_unreadable(&git_dir.join("config.worktree"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let config = match repo.config() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return true,
|
||||
};
|
||||
let mut entries = match config.entries(None) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return true,
|
||||
};
|
||||
while let Some(entry) = entries.next() {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => return true,
|
||||
};
|
||||
match entry.level() {
|
||||
git2::ConfigLevel::Local | git2::ConfigLevel::Worktree => {}
|
||||
_ => continue,
|
||||
}
|
||||
let name = entry.name().unwrap_or("");
|
||||
let value = entry.value().unwrap_or("");
|
||||
if local_git_config_entry_is_exec(name, value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_static_path_operand(p: &str) -> bool {
|
||||
!p.is_empty()
|
||||
&& p != "-"
|
||||
&& !p.starts_with('-')
|
||||
&& !p.as_bytes().contains(&b'$')
|
||||
&& !p.as_bytes().contains(&b'`')
|
||||
&& !p.contains("$(")
|
||||
}
|
||||
|
||||
fn join_cwd(base: &Path, operand: &str) -> PathBuf {
|
||||
let p = Path::new(operand);
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
base.join(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_literal_chdir(cwd: &Path, words: &[String]) -> Option<PathBuf> {
|
||||
let mut args = words.iter().skip(1).map(String::as_str);
|
||||
let mut target = None;
|
||||
while let Some(tok) = args.next() {
|
||||
if tok == "--" {
|
||||
target = args.next();
|
||||
break;
|
||||
}
|
||||
if tok.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
if target.is_some() {
|
||||
return None;
|
||||
}
|
||||
target = Some(tok);
|
||||
}
|
||||
let target = target?;
|
||||
if !is_static_path_operand(target) {
|
||||
return None;
|
||||
}
|
||||
Some(join_cwd(cwd, target))
|
||||
}
|
||||
|
||||
/// Pre-subcommand `git -C` / `-Cpath` chains. `None` if path unmodeled or retarget global.
|
||||
fn git_effective_cwd(words: &[String], start_cwd: &Path) -> Option<PathBuf> {
|
||||
let mut cwd = start_cwd.to_path_buf();
|
||||
let mut i = 1;
|
||||
while i < words.len() {
|
||||
let tok = words[i].as_str();
|
||||
if tok == "--" || !tok.starts_with('-') || tok == "-" {
|
||||
break;
|
||||
}
|
||||
if is_git_repo_retarget_flag(tok) {
|
||||
return None;
|
||||
}
|
||||
if tok == "-C" {
|
||||
let path = words.get(i + 1).map(String::as_str)?;
|
||||
if !is_static_path_operand(path) {
|
||||
return None;
|
||||
}
|
||||
cwd = join_cwd(&cwd, path);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = attached_git_c_path(tok) {
|
||||
if !is_static_path_operand(path) {
|
||||
return None;
|
||||
}
|
||||
cwd = join_cwd(&cwd, path);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !tok.contains('=')
|
||||
&& git_global_option_takes_value(tok)
|
||||
&& words
|
||||
.get(i + 1)
|
||||
.is_some_and(|n| !n.starts_with('-') || n == "-")
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Some(cwd)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum AmbientScanPlan {
|
||||
FailClosed,
|
||||
CheckDirs(Vec<PathBuf>),
|
||||
}
|
||||
|
||||
/// Same normalization as [`segment_exec_facts`], then track cd/git cwd.
|
||||
pub(crate) fn ambient_scan_plan_from_segments(
|
||||
raw_segments: &[Vec<String>],
|
||||
session_cwd: &Path,
|
||||
) -> AmbientScanPlan {
|
||||
let mut cwd = session_cwd.to_path_buf();
|
||||
let mut git_cwds = Vec::new();
|
||||
for raw in raw_segments {
|
||||
let words = match normalize_for_exec_risk(raw) {
|
||||
NormalizedArgv::FailClosed => return AmbientScanPlan::FailClosed,
|
||||
NormalizedArgv::Ready(inner) => inner,
|
||||
};
|
||||
match normalized_program_name(words).as_deref() {
|
||||
Some("cd") | Some("pushd") => match apply_literal_chdir(&cwd, words) {
|
||||
Some(next) => cwd = next,
|
||||
None => return AmbientScanPlan::FailClosed,
|
||||
},
|
||||
Some("popd") => return AmbientScanPlan::FailClosed,
|
||||
Some("git") => match git_effective_cwd(words, &cwd) {
|
||||
Some(effective) => git_cwds.push(effective),
|
||||
None => return AmbientScanPlan::FailClosed,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if git_cwds.is_empty() {
|
||||
AmbientScanPlan::FailClosed
|
||||
} else {
|
||||
AmbientScanPlan::CheckDirs(git_cwds)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ambient_exec_risk_from_plan(plan: &AmbientScanPlan) -> bool {
|
||||
match plan {
|
||||
AmbientScanPlan::FailClosed => true,
|
||||
AmbientScanPlan::CheckDirs(dirs) => dirs.iter().any(|c| local_repo_config_has_exec_risk(c)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ambient_scan_plan_from_cmd(cmd: &str, session_cwd: &Path) -> Option<AmbientScanPlan> {
|
||||
let tree = try_parse_shell(cmd)?;
|
||||
let segments = try_parse_word_only_commands_sequence(&tree, cmd)?;
|
||||
let raw: Vec<Vec<String>> = segments.iter().map(|s| s.words().to_vec()).collect();
|
||||
Some(ambient_scan_plan_from_segments(&raw, session_cwd))
|
||||
}
|
||||
|
||||
/// Token probe for unparseable scripts. Bare `git` tokens fail closed (e.g. `echo git $(true)`).
|
||||
pub(crate) fn script_may_invoke_git(cmd: &str) -> bool {
|
||||
for token in cmd.split(|c: char| {
|
||||
c.is_whitespace() || matches!(c, '|' | '&' | ';' | '(' | ')' | '`' | '\n' | '<' | '>')
|
||||
}) {
|
||||
let trimmed = token.trim_matches(|c| matches!(c, '\'' | '"' | '`'));
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if normalized_token_basename(trimmed) == "git" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn words(cmd: &str) -> Vec<String> {
|
||||
cmd.split_whitespace().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_compress_program_flags() {
|
||||
for cmd in [
|
||||
"sort --compress-program=tools/x in",
|
||||
"sort --compress-program tools/x in",
|
||||
"sort --compress-prog=tools/x in",
|
||||
"sort --co=tools/x in",
|
||||
"/usr/bin/sort --compress-program=/tmp/pwn in",
|
||||
"SORT.EXE --compress-program=/tmp/pwn in",
|
||||
] {
|
||||
assert!(segment_has_exec_risk_flag(&words(cmd)), "{cmd}");
|
||||
}
|
||||
for cmd in [
|
||||
"sort in.csv",
|
||||
"sort --check big.csv",
|
||||
"sort -- --compress-program=foo",
|
||||
] {
|
||||
assert!(!segment_has_exec_risk_flag(&words(cmd)), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_exec_risk_globals() {
|
||||
for cmd in [
|
||||
"git -c core.fsmonitor=/tmp/pwn status",
|
||||
"git -ccore.fsmonitor=/tmp/pwn status",
|
||||
"git --config-env=core.fsmonitor=EVIL status",
|
||||
"git --config-env core.fsmonitor=EVIL status",
|
||||
"git --config-e=core.fsmonitor=EVIL status",
|
||||
"git -c status",
|
||||
"git -C /tmp -c core.fsmonitor=/tmp/pwn status",
|
||||
"git --git-dir=/evil/.git status",
|
||||
"git --git-dir /evil/.git status",
|
||||
"git --work-tree=/evil status",
|
||||
"git --work-tree /evil status",
|
||||
"git --gi=/evil/.git status",
|
||||
"git --wor=/evil status",
|
||||
"/usr/bin/git -c core.fsmonitor=/tmp/pwn status",
|
||||
"Git -c core.fsmonitor=/tmp/pwn status",
|
||||
r"C:\Git\cmd\git.exe -c core.fsmonitor=/tmp/pwn status",
|
||||
] {
|
||||
assert!(segment_has_exec_risk_flag(&words(cmd)), "{cmd}");
|
||||
}
|
||||
for cmd in [
|
||||
"git log -c",
|
||||
"git status",
|
||||
"git -C /tmp status",
|
||||
"git -C/tmp status",
|
||||
] {
|
||||
assert!(!segment_has_exec_risk_flag(&words(cmd)), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_wrapper_transparent_facts() {
|
||||
let f = segment_exec_facts(&words("command git status"));
|
||||
assert!(f.has_git && !f.exec_risk);
|
||||
let f = segment_exec_facts(&words("exec sort --compress-program=/tmp/pwn in"));
|
||||
assert!(f.exec_risk && !f.has_git);
|
||||
let f = segment_exec_facts(&words("builtin git -c core.fsmonitor=/tmp/pwn status"));
|
||||
assert!(f.exec_risk && f.has_git);
|
||||
|
||||
for cmd in [
|
||||
"command env git status",
|
||||
"exec env RUST_LOG=debug git status",
|
||||
"command timeout 1 git status",
|
||||
"timeout 1 command env git status",
|
||||
"command exec env git status",
|
||||
"/usr/bin/command env /usr/bin/git status",
|
||||
"command env command env command env git status",
|
||||
] {
|
||||
let f = segment_exec_facts(&words(cmd));
|
||||
assert!(f.has_git || f.exec_risk, "{cmd} → {f:?}");
|
||||
}
|
||||
assert!(
|
||||
segment_exec_facts(&words("command env sort --compress-program=/tmp/pwn in")).exec_risk
|
||||
);
|
||||
assert!(
|
||||
segment_exec_facts(&words(
|
||||
"command timeout 1 env git -c core.fsmonitor=/x status"
|
||||
))
|
||||
.exec_risk
|
||||
);
|
||||
assert!(segment_exec_facts(&words("command env -C /evil git status")).exec_risk);
|
||||
assert!(segment_exec_facts(&words("command --unknown git status")).exec_risk);
|
||||
assert!(segment_exec_facts(&words("command exec git status")).has_git);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_ambient_plans() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let base = root.path();
|
||||
for cmd in [
|
||||
"command env git -C sub status",
|
||||
"command timeout 1 git -C sub status",
|
||||
"timeout 1 command env git -C sub status",
|
||||
] {
|
||||
match ambient_scan_plan_from_cmd(cmd, base).unwrap() {
|
||||
AmbientScanPlan::CheckDirs(d) => assert_eq!(d, vec![base.join("sub")], "{cmd}"),
|
||||
other => panic!("{cmd}: expected CheckDirs, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(matches!(
|
||||
ambient_scan_plan_from_cmd("command env -C /evil git status", base).unwrap(),
|
||||
AmbientScanPlan::FailClosed
|
||||
));
|
||||
assert!(matches!(
|
||||
ambient_scan_plan_from_cmd("command --unknown git status", base).unwrap(),
|
||||
AmbientScanPlan::FailClosed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_and_chained_c_paths() {
|
||||
let plan = |cmd: &str, cwd: &Path| ambient_scan_plan_from_cmd(cmd, cwd).unwrap();
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let base = root.path();
|
||||
match plan("git -C sub status", base) {
|
||||
AmbientScanPlan::CheckDirs(d) => {
|
||||
assert_eq!(d, vec![base.join("sub")]);
|
||||
}
|
||||
other => panic!("expected CheckDirs, got {other:?}"),
|
||||
}
|
||||
match plan("git -C/abs/path status", base) {
|
||||
AmbientScanPlan::CheckDirs(d) => {
|
||||
assert_eq!(d, vec![PathBuf::from("/abs/path")]);
|
||||
}
|
||||
other => panic!("expected CheckDirs, got {other:?}"),
|
||||
}
|
||||
match plan("git -C a -C b status", base) {
|
||||
AmbientScanPlan::CheckDirs(d) => {
|
||||
assert_eq!(d, vec![base.join("a").join("b")]);
|
||||
}
|
||||
other => panic!("expected CheckDirs, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
plan("git --git-dir=evil/.git status", base),
|
||||
AmbientScanPlan::FailClosed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_config_fixtures() {
|
||||
for (cfg, should_flag) in [
|
||||
("[core]\n\tfsmonitor = /tmp/pwn\n", true),
|
||||
("[diff \"evil\"]\n\tcommand = /tmp/pwn\n", true),
|
||||
("[diff \"evil\"]\n\ttextconv = /tmp/pwn\n", true),
|
||||
("[alias]\n\tstatus = !/tmp/pwn\n", true),
|
||||
(
|
||||
"[core]\n\trepositoryformatversion = 0\n\tfsmonitor = true\n\
|
||||
[filter \"lfs\"]\n\tclean = git-lfs clean -- %f\n\
|
||||
\tsmudge = git-lfs smudge -- %f\n\
|
||||
\tprocess = git-lfs filter-process\n\
|
||||
[alias]\n\tst = status\n",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
std::fs::write(tmp.path().join(".git/config"), cfg).unwrap();
|
||||
assert_eq!(
|
||||
local_repo_config_has_exec_risk(tmp.path()),
|
||||
should_flag,
|
||||
"cfg={cfg:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Plain include.
|
||||
{
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".git/extra"),
|
||||
"[core]\nfsmonitor = /tmp/pwn\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".git/config"),
|
||||
"[core]\n\trepositoryformatversion = 0\n\
|
||||
[include]\n\tpath = extra\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(local_repo_config_has_exec_risk(tmp.path()));
|
||||
}
|
||||
|
||||
// includeIf.gitdir: exact absolute gitdir (no trailing slash).
|
||||
// libgit2 appends `**` when the pattern ends with `/`, and wildmatch
|
||||
// `dir/**` does not match `dir` itself — so trailing-slash patterns fail.
|
||||
// Use repo.path() as libgit2 reports it (not a re-canonicalized twin).
|
||||
{
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(tmp.path()).unwrap();
|
||||
let gitdir_pat = repo
|
||||
.path()
|
||||
.to_string_lossy()
|
||||
.trim_end_matches(['/', '\\'])
|
||||
.to_owned();
|
||||
std::fs::write(
|
||||
tmp.path().join(".git/extra-if"),
|
||||
"[core]\nfsmonitor = /tmp/pwn\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".git/config"),
|
||||
format!(
|
||||
"[core]\n\trepositoryformatversion = 0\n\
|
||||
[includeIf \"gitdir:{gitdir_pat}\"]\n\tpath = extra-if\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
local_repo_config_has_exec_risk(tmp.path()),
|
||||
"includeIf.gitdir must be honored"
|
||||
);
|
||||
}
|
||||
|
||||
// Config path is a directory (opens on Linux) → not a regular file → fail closed.
|
||||
{
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
let cfg = tmp.path().join(".git/config");
|
||||
std::fs::remove_file(&cfg).unwrap();
|
||||
std::fs::create_dir(&cfg).unwrap();
|
||||
assert!(
|
||||
local_repo_config_has_exec_risk(tmp.path()),
|
||||
"unopenable config path must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(tmp.path()).unwrap();
|
||||
let cfg = tmp.path().join(".git/config");
|
||||
let mut perms = std::fs::metadata(&cfg).unwrap().permissions();
|
||||
perms.set_mode(0o000);
|
||||
std::fs::set_permissions(&cfg, perms).unwrap();
|
||||
// Root and CAP_DAC_OVERRIDE can still open mode 000 files.
|
||||
if std::fs::File::open(&cfg).is_err() {
|
||||
assert!(
|
||||
local_repo_config_has_exec_risk(tmp.path()),
|
||||
"unreadable config must fail closed"
|
||||
);
|
||||
}
|
||||
let mut perms = std::fs::metadata(&cfg).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&cfg, perms).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_may_invoke_git_probe() {
|
||||
assert!(script_may_invoke_git("git status $(true)"));
|
||||
assert!(script_may_invoke_git("/usr/bin/git status"));
|
||||
assert!(script_may_invoke_git("cd x && git diff"));
|
||||
assert!(script_may_invoke_git(r"C:\Git\cmd\git.exe status $(true)"));
|
||||
assert!(!script_may_invoke_git("echo hello"));
|
||||
assert!(!script_may_invoke_git("mygit status"));
|
||||
// Fail-closed false positive: bare `git` token in unparseable script.
|
||||
assert!(script_may_invoke_git("echo git $(true)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_cd_and_git_c() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let clean = root.path().join("clean");
|
||||
let evil = root.path().join("evil");
|
||||
std::fs::create_dir_all(&clean).unwrap();
|
||||
std::fs::create_dir_all(&evil).unwrap();
|
||||
git2::Repository::init(&clean).unwrap();
|
||||
git2::Repository::init(&evil).unwrap();
|
||||
std::fs::write(evil.join(".git/config"), "[core]\nfsmonitor = /tmp/pwn\n").unwrap();
|
||||
|
||||
let plan = ambient_scan_plan_from_cmd("git -C evil status", &clean).unwrap();
|
||||
assert!(ambient_exec_risk_from_plan(&plan));
|
||||
|
||||
let plan = ambient_scan_plan_from_cmd("cd evil && git status", &clean).unwrap();
|
||||
assert!(ambient_exec_risk_from_plan(&plan));
|
||||
|
||||
// `$HOME` expansion is rejected by word-only parse → ambient plan is
|
||||
// unavailable (`None`). Production maps that to fail-closed via
|
||||
// `unparseable_exec_risk` → `script_may_invoke_git` (do not invent a
|
||||
// word-only plan that weakens the expansion boundary).
|
||||
let expansion = "cd \"$HOME\" && git status";
|
||||
assert!(
|
||||
ambient_scan_plan_from_cmd(expansion, &clean).is_none(),
|
||||
"expansion must stay outside word-only ambient planning"
|
||||
);
|
||||
assert!(
|
||||
script_may_invoke_git(expansion),
|
||||
"unparseable git-bearing script must fail closed"
|
||||
);
|
||||
|
||||
let plan = ambient_scan_plan_from_cmd("git status", &clean).unwrap();
|
||||
assert!(!ambient_exec_risk_from_plan(&plan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_common_and_config_worktree() {
|
||||
let main = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(main.path()).unwrap();
|
||||
let sig = git2::Signature::now("t", "t@t").unwrap();
|
||||
{
|
||||
let mut index = repo.index().unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
let head = repo.head().unwrap().peel_to_commit().unwrap();
|
||||
repo.branch("wt-branch", &head, false).unwrap();
|
||||
let wt_dir = main.path().join("wt");
|
||||
let mut opts = git2::WorktreeAddOptions::new();
|
||||
let branch = repo
|
||||
.find_branch("wt-branch", git2::BranchType::Local)
|
||||
.unwrap();
|
||||
opts.reference(Some(branch.get()));
|
||||
repo.worktree("wt", &wt_dir, Some(&opts)).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
main.path().join(".git/config"),
|
||||
"[core]\n\trepositoryformatversion = 0\n\tfsmonitor = /tmp/pwn\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(local_repo_config_has_exec_risk(&wt_dir));
|
||||
|
||||
std::fs::write(
|
||||
main.path().join(".git/config"),
|
||||
"[core]\n\trepositoryformatversion = 0\n\tfsmonitor = true\n\
|
||||
[extensions]\n\tworktreeConfig = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
main.path().join(".git/worktrees/wt/config.worktree"),
|
||||
"[core]\nfsmonitor = /tmp/pwn\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(local_repo_config_has_exec_risk(&wt_dir));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,7 @@
|
|||
pub mod auto_mode;
|
||||
pub mod bash_command_splitting;
|
||||
pub mod claude_settings;
|
||||
mod exec_risk;
|
||||
mod hub_permission;
|
||||
mod manager;
|
||||
mod policy;
|
||||
|
|
@ -43,4 +45,3 @@ pub use prompter::{
|
|||
pub use state::PermissionState;
|
||||
pub use state::cleanup_stale_permission_state;
|
||||
pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent};
|
||||
pub mod bash_command_splitting;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use crate::permission::bash_command_splitting::{all_commands_from_script, unwrap_wrappers};
|
||||
use crate::permission::bash_command_splitting::{
|
||||
MAX_INLINE_SHELL_DEPTH, all_commands_from_script, env_split_string_script,
|
||||
normalize_command_words,
|
||||
};
|
||||
use crate::permission::shell_access::combine_decisions;
|
||||
use crate::permission::types::{
|
||||
AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
|
||||
|
|
@ -70,15 +73,14 @@ impl CompiledPolicy {
|
|||
if !self.has_bash_command_restrictions {
|
||||
return None;
|
||||
}
|
||||
self.evaluate_bash_command_segments(cmd, 0)
|
||||
self.evaluate_bash_command_segments(cmd, MAX_INLINE_SHELL_DEPTH)
|
||||
}
|
||||
|
||||
fn evaluate_bash_command_segments(&self, cmd: &str, depth: usize) -> Option<Decision> {
|
||||
// Far deeper than legitimate `bash -c` nesting; fail closed rather than
|
||||
// let an over-nested script run unevaluated.
|
||||
if depth >= 8 {
|
||||
return Some(Decision::Ask);
|
||||
}
|
||||
fn evaluate_bash_command_segments(
|
||||
&self,
|
||||
cmd: &str,
|
||||
inline_depth_remaining: usize,
|
||||
) -> Option<Decision> {
|
||||
let Some(segments) = all_commands_from_script(cmd) else {
|
||||
return Some(Decision::Ask);
|
||||
};
|
||||
|
|
@ -89,19 +91,49 @@ impl CompiledPolicy {
|
|||
let mut decision = None;
|
||||
for parsed in &segments {
|
||||
let raw_words = parsed.words();
|
||||
let unwrapped = unwrap_wrappers(raw_words);
|
||||
// Rules may target the wrapper or the wrapped program, so both forms
|
||||
// are checked — but only once when nothing was peeled.
|
||||
let norm = normalize_command_words(raw_words);
|
||||
decision = combine_decisions(decision, norm.exhausted.then_some(Decision::Ask));
|
||||
decision = combine_decisions(decision, norm.ambiguous.then_some(Decision::Ask));
|
||||
decision = combine_decisions(
|
||||
decision,
|
||||
norm.env_options_uncertain.then_some(Decision::Ask),
|
||||
);
|
||||
// WHY: every split-string shape keeps an Ask floor (Reject may still win).
|
||||
decision = combine_decisions(decision, norm.has_split_string.then_some(Decision::Ask));
|
||||
let inner_words = norm.words;
|
||||
let forms = std::iter::once(raw_words)
|
||||
.chain((unwrapped.len() != raw_words.len()).then_some(unwrapped));
|
||||
.chain((inner_words.len() != raw_words.len()).then_some(inner_words));
|
||||
for words in forms {
|
||||
decision = combine_decisions(decision, escalate(&words.join(" ")));
|
||||
if let Some(inner) = shell_dash_c_script(words) {
|
||||
}
|
||||
let shell_words: Vec<ShellWord<'_>> = inner_words.iter().map(ShellWord::from).collect();
|
||||
match shell_dash_c_script(&shell_words) {
|
||||
InlineShellScript::Literal(index) if inline_depth_remaining > 0 => {
|
||||
decision = combine_decisions(
|
||||
decision,
|
||||
self.evaluate_bash_command_segments(inner, depth + 1),
|
||||
self.evaluate_bash_command_segments(
|
||||
inner_words[index].as_str(),
|
||||
inline_depth_remaining - 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
InlineShellScript::Literal(_)
|
||||
| InlineShellScript::Untrusted
|
||||
| InlineShellScript::Unrecognized => {
|
||||
decision = combine_decisions(decision, Some(Decision::Ask));
|
||||
}
|
||||
InlineShellScript::NotInline => {}
|
||||
}
|
||||
// High-confidence env -S: shared inline budget; Reject beats Ask floor.
|
||||
if let Some(script) = env_split_string_script(inner_words) {
|
||||
if inline_depth_remaining > 0 {
|
||||
decision = combine_decisions(
|
||||
decision,
|
||||
self.evaluate_bash_command_segments(&script, inline_depth_remaining - 1),
|
||||
);
|
||||
} else {
|
||||
decision = combine_decisions(decision, Some(Decision::Ask));
|
||||
}
|
||||
}
|
||||
}
|
||||
decision
|
||||
|
|
@ -164,32 +196,162 @@ impl From<PermissionConfig> for CompiledPolicy {
|
|||
}
|
||||
}
|
||||
|
||||
/// The inner script string of a `bash -c "<script>"` invocation (also `sh`,
|
||||
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
|
||||
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
|
||||
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
|
||||
pub(crate) fn shell_dash_c_script(words: &[String]) -> Option<&str> {
|
||||
let program = words.first()?.rsplit(['/', '\\']).next()?;
|
||||
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
|
||||
return None;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ShellWord<'a> {
|
||||
Literal(&'a str),
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a String> for ShellWord<'a> {
|
||||
fn from(word: &'a String) -> Self {
|
||||
Self::Literal(word.as_str())
|
||||
}
|
||||
let flag = words
|
||||
.iter()
|
||||
.skip(1)
|
||||
.position(|w| w.starts_with('-') && !w.starts_with("--") && w.contains('c'))?;
|
||||
// The script is the first operand after the `-c` cluster, not necessarily
|
||||
// the next word: more options may sit in between (`bash -c -x 'id'`), and
|
||||
// `--` / a lone `-` end option parsing with the operand following.
|
||||
let mut rest = words.get(flag + 2..)?.iter();
|
||||
while let Some(word) = rest.next() {
|
||||
if matches!(word.as_str(), "--" | "-") {
|
||||
return rest.next().map(String::as_str);
|
||||
}
|
||||
if !word.starts_with('-') {
|
||||
return Some(word.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum InlineShellScript {
|
||||
/// Not a supported-shell inline `-c` shape (and no unmodeled option ambiguity).
|
||||
NotInline,
|
||||
/// Trusted literal `-c` script at this word index.
|
||||
Literal(usize),
|
||||
/// Confirmed or potential `-c` shape whose script cannot be trusted for recursion
|
||||
/// (dynamic head/operand, missing script, ambiguous options after `-c`).
|
||||
Untrusted,
|
||||
/// Unmodeled/ambiguous options without evidence of `-c` string reinterpretation
|
||||
/// (e.g. `bash --version`). Security gates still Ask; auto-mode opaque-shell
|
||||
/// floor does not — only `Literal` / `Untrusted` re-interpret a command string.
|
||||
Unrecognized,
|
||||
}
|
||||
|
||||
impl InlineShellScript {
|
||||
/// Auto-mode opaque-shell floor: true only for (potential) `-c` reinterpretation.
|
||||
pub(crate) fn is_potential_inline(self) -> bool {
|
||||
matches!(self, Self::Literal(_) | Self::Untrusted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a supported shell's inline `-c` script without guessing option operands.
|
||||
/// An untrusted program head that still matches a `-c` shape is `Untrusted` (Ask),
|
||||
/// not a global Ask for every dynamic command. Unmodeled long options without `-c`
|
||||
/// are `Unrecognized` (security Ask, not opaque-shell).
|
||||
pub(crate) fn shell_dash_c_script(words: &[ShellWord<'_>]) -> InlineShellScript {
|
||||
let dynamic_head = match words.first() {
|
||||
Some(ShellWord::Literal(program)) => {
|
||||
let program = program.rsplit(['/', '\\']).next().unwrap_or(program);
|
||||
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
|
||||
return InlineShellScript::NotInline;
|
||||
}
|
||||
false
|
||||
}
|
||||
Some(ShellWord::Untrusted) => true,
|
||||
None => return InlineShellScript::NotInline,
|
||||
};
|
||||
|
||||
let mut i = 1usize;
|
||||
let mut saw_c = false;
|
||||
let mut unrecognized = false;
|
||||
// After `-c`, fail closed as Untrusted; before `-c`, Unrecognized (security Ask
|
||||
// without claiming string reinterpretation for the opaque-shell floor).
|
||||
let ambiguous = |saw_c: bool| {
|
||||
if saw_c {
|
||||
InlineShellScript::Untrusted
|
||||
} else {
|
||||
InlineShellScript::Unrecognized
|
||||
}
|
||||
};
|
||||
let finish_literal = |index: usize| {
|
||||
if dynamic_head {
|
||||
InlineShellScript::Untrusted
|
||||
} else {
|
||||
InlineShellScript::Literal(index)
|
||||
}
|
||||
};
|
||||
while let Some(word) = words.get(i) {
|
||||
let ShellWord::Literal(word) = word else {
|
||||
return ambiguous(saw_c);
|
||||
};
|
||||
let word = *word;
|
||||
if word == "--" || word == "-" {
|
||||
if !saw_c {
|
||||
return if unrecognized {
|
||||
InlineShellScript::Unrecognized
|
||||
} else {
|
||||
InlineShellScript::NotInline
|
||||
};
|
||||
}
|
||||
return match words.get(i + 1) {
|
||||
Some(ShellWord::Literal(_)) => finish_literal(i + 1),
|
||||
Some(ShellWord::Untrusted) | None => InlineShellScript::Untrusted,
|
||||
};
|
||||
}
|
||||
if !word.starts_with('-') && !word.starts_with('+') {
|
||||
return if saw_c {
|
||||
finish_literal(i)
|
||||
} else if unrecognized {
|
||||
InlineShellScript::Unrecognized
|
||||
} else {
|
||||
InlineShellScript::NotInline
|
||||
};
|
||||
}
|
||||
if word == "--init-file" || word == "--rcfile" {
|
||||
match words.get(i + 1) {
|
||||
Some(ShellWord::Literal(_)) => i += 2,
|
||||
Some(ShellWord::Untrusted) | None => return ambiguous(saw_c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word.starts_with("--") {
|
||||
if matches!(word, "--noprofile" | "--norc" | "--posix") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Unmodeled long option: keep scanning for a later `-c` so
|
||||
// `bash --verbose -c '…'` stays potential-inline, while bare
|
||||
// `bash --version` / `bash --help` become Unrecognized (not opaque).
|
||||
if saw_c {
|
||||
return InlineShellScript::Untrusted;
|
||||
}
|
||||
unrecognized = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(word, "-o" | "+o" | "-O" | "+O") {
|
||||
match words.get(i + 1) {
|
||||
Some(ShellWord::Literal(value)) if !value.starts_with('-') => i += 2,
|
||||
Some(ShellWord::Literal(_)) => return ambiguous(saw_c),
|
||||
Some(ShellWord::Untrusted) | None => return ambiguous(saw_c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word.starts_with("-O") && word.len() > 2 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (word.starts_with("+O") || word.starts_with("+o")) && word.len() > 2 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if word.starts_with("-o") && word.len() > 2 {
|
||||
return ambiguous(saw_c);
|
||||
}
|
||||
if word.starts_with('+') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let flags = &word[1..];
|
||||
if flags.contains('o') || flags.contains('O') {
|
||||
return ambiguous(saw_c);
|
||||
}
|
||||
saw_c |= flags.contains('c');
|
||||
i += 1;
|
||||
}
|
||||
if saw_c {
|
||||
InlineShellScript::Untrusted
|
||||
} else if unrecognized {
|
||||
InlineShellScript::Unrecognized
|
||||
} else {
|
||||
InlineShellScript::NotInline
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn tool_filter_matches(access: &AccessKind, filter: &ToolFilter) -> bool {
|
||||
|
|
@ -702,6 +864,12 @@ mod tests {
|
|||
"bash -c \"id > M.txt\"",
|
||||
"bash -c -x \"id > M.txt\"",
|
||||
"bash -c -- \"id > M.txt\"",
|
||||
"bash -c -o pipefail \"id > M.txt\"",
|
||||
"bash -c -O extglob \"id > M.txt\"",
|
||||
"bash -c -Oextglob \"id > M.txt\"",
|
||||
"exec id",
|
||||
"command id",
|
||||
"exec bash -c \"id > M.txt\"",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
|
|
@ -711,6 +879,63 @@ mod tests {
|
|||
"denied command in a non-leading position must be rejected: {cmd}"
|
||||
);
|
||||
}
|
||||
// High-confidence env -S packed denials hard-Reject; uncertain shapes Ask.
|
||||
for cmd in ["env -S 'id'", "env -S 'bash -c id'"] {
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(cmd),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"high-confidence env -S must reject denied payload: {cmd}"
|
||||
);
|
||||
}
|
||||
// Transparent-prefix depth: eight peels reach the command; a ninth Asks.
|
||||
use crate::permission::bash_command_splitting::MAX_TRANSPARENT_PREFIX_DEPTH;
|
||||
let nested_exec = |depth: usize| format!("{}id", "exec ".repeat(depth));
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&nested_exec(MAX_TRANSPARENT_PREFIX_DEPTH)),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"maximum transparent prefix depth must still reach the denied command"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&nested_exec(MAX_TRANSPARENT_PREFIX_DEPTH + 1)),
|
||||
Some(Decision::Ask)
|
||||
),
|
||||
"one extra transparent prefix must fail closed under bash command policy"
|
||||
);
|
||||
let exhausted_then_deny = format!(
|
||||
"{}; id",
|
||||
nested_exec(MAX_TRANSPARENT_PREFIX_DEPTH + 1).replace("id", "echo hi")
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&exhausted_then_deny),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"a later denied command must beat transparent exhaustion Ask"
|
||||
);
|
||||
for cmd in [
|
||||
"bash -c +O extglob id",
|
||||
"bash -c +Oextglob id",
|
||||
"bash -c +o pipefail id",
|
||||
] {
|
||||
assert!(matches!(
|
||||
policy.evaluate_bash_command_policy(cmd),
|
||||
Some(Decision::Reject(_))
|
||||
));
|
||||
}
|
||||
for cmd in ["bash -- -c id", "bash script.sh -c id"] {
|
||||
assert!(
|
||||
!matches!(
|
||||
policy.evaluate_bash_command_policy(cmd),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"non-inline shell form must not recurse into `id`: {cmd}"
|
||||
);
|
||||
}
|
||||
// Scripts that cannot be decomposed must fail closed (prompt), not allow.
|
||||
for cmd in ["OUT=$(id); echo \"$OUT\" > M.txt", "echo \"`id`\" > M.txt"] {
|
||||
assert!(
|
||||
|
|
@ -721,6 +946,15 @@ mod tests {
|
|||
"an undecomposable script must escalate, not fall through to allow: {cmd}"
|
||||
);
|
||||
}
|
||||
// Alternating normalization still reaches the denied command through pure `env` wrappers.
|
||||
let wrapped = format!("{}bash -c 'id'", "env ".repeat(9));
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&wrapped),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"bounded alternating normalize must still reach denied `id` under env wrappers"
|
||||
);
|
||||
// A clean compound with no denied segment is not escalated.
|
||||
assert!(
|
||||
policy
|
||||
|
|
@ -739,6 +973,140 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Managed Bash deny fidelity for `env -S`/`--split-string`: high-confidence
|
||||
/// packed payloads hard-Reject; every split-string shape keeps an Ask floor.
|
||||
#[test]
|
||||
fn env_split_string_bash_deny_fidelity() {
|
||||
use crate::permission::bash_command_splitting::{MAX_NORMALIZE_ROUNDS, MAX_WRAPPER_DEPTH};
|
||||
|
||||
let policy = CompiledPolicy::new(PermissionConfig::new(vec![
|
||||
bash_rule(RuleAction::Allow, "*"),
|
||||
bash_rule(RuleAction::Deny, "rm*"),
|
||||
]));
|
||||
let must_reject = |cmd: &str| {
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(cmd),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"must Reject: {cmd}"
|
||||
);
|
||||
};
|
||||
let must_ask = |cmd: &str| {
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(cmd),
|
||||
Some(Decision::Ask)
|
||||
),
|
||||
"must Ask (not fail open): {cmd}"
|
||||
);
|
||||
};
|
||||
|
||||
// High-confidence Reject (incl. wrappers, later -S after known options).
|
||||
for cmd in [
|
||||
"env -S 'rm -rf /tmp/victim'",
|
||||
"env --split-string 'rm -rf /tmp/victim'",
|
||||
"env --split-string='rm -rf /tmp/victim'",
|
||||
"env -S'rm -rf /tmp/victim'",
|
||||
"/usr/bin/env -S 'rm -rf /tmp/victim'",
|
||||
"timeout 5 env -S 'rm -rf /tmp/victim'",
|
||||
"env FOO=1 -i -S 'rm -rf /tmp/victim'",
|
||||
"command env -S 'rm -rf /tmp/victim'",
|
||||
"command timeout 5 command env -S 'rm -rf /tmp/victim'",
|
||||
"bash -c \"env -S 'rm -rf /tmp/victim'\"",
|
||||
"env -S 'env -S rm'",
|
||||
"env FOO=1 rm -rf /tmp/victim",
|
||||
"env -P /usr/bin -S 'rm -rf /tmp/victim'",
|
||||
"env --path /usr/bin -S 'rm -rf /tmp/victim'",
|
||||
"env --path=/usr/bin -S 'rm -rf /tmp/victim'",
|
||||
"env -a name -S 'rm -rf /tmp/victim'",
|
||||
"env - -S 'rm -rf /tmp/victim'",
|
||||
"env -iv -S 'rm -rf /tmp/victim'",
|
||||
"env -C /tmp -S 'rm -rf /tmp/victim'",
|
||||
"env -uS rm -rf /tmp/victim",
|
||||
"env -PSfoo rm -rf /tmp/victim",
|
||||
] {
|
||||
must_reject(cmd);
|
||||
}
|
||||
|
||||
// Ask rule on outer form still prompts (Reject does not apply).
|
||||
let ask_policy = CompiledPolicy::new(PermissionConfig::new(vec![
|
||||
bash_rule(RuleAction::Allow, "*"),
|
||||
bash_rule(RuleAction::Ask, "env*"),
|
||||
]));
|
||||
assert!(matches!(
|
||||
ask_policy.evaluate_bash_command_policy("env -S 'rm -rf /tmp/victim'"),
|
||||
Some(Decision::Ask)
|
||||
));
|
||||
|
||||
// Clusters / metasyntax / unknown options / missing operand → Ask floor.
|
||||
for cmd in [
|
||||
"env -iS 'rm -rf /tmp/victim'",
|
||||
"env -vS 'rm -rf /tmp/victim'",
|
||||
"env -0S 'rm -rf /tmp/victim'",
|
||||
"env -xS 'rm -rf /tmp/victim'",
|
||||
"env -S",
|
||||
"env --split-string",
|
||||
"env -S $CMD",
|
||||
"env -S 'echo $HOME'",
|
||||
"env -S 'rm -rf x #x'",
|
||||
r"env -S '\trm -rf /tmp/victim'",
|
||||
r"env -S '\nrm -rf /tmp/victim'",
|
||||
"env --block-signal SEGV -S 'rm -rf /tmp/victim'",
|
||||
"env -x foo -S 'rm -rf /tmp/victim'",
|
||||
"env -P",
|
||||
"env --prefix /usr/bin -S 'rm -rf /tmp/victim'",
|
||||
] {
|
||||
must_ask(cmd);
|
||||
}
|
||||
|
||||
// `--` ends options: following `-S` is command text, not split-string.
|
||||
assert!(
|
||||
!matches!(
|
||||
policy.evaluate_bash_command_policy("env -- -S 'rm -rf /tmp/victim'"),
|
||||
Some(Decision::Reject(_))
|
||||
),
|
||||
"env -- -S must not be treated as split-string"
|
||||
);
|
||||
|
||||
let nested_alt = |depth: usize| {
|
||||
let mut s = "env -S 'rm -rf /tmp/victim'".to_string();
|
||||
for i in 0..depth {
|
||||
s = if i % 2 == 0 {
|
||||
format!("command {s}")
|
||||
} else {
|
||||
format!("timeout 1 {s}")
|
||||
};
|
||||
}
|
||||
s
|
||||
};
|
||||
must_reject(&nested_alt(4));
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&nested_alt(MAX_NORMALIZE_ROUNDS + 2)),
|
||||
Some(Decision::Ask) | Some(Decision::Reject(_))
|
||||
),
|
||||
"over-budget normalize must not fail open"
|
||||
);
|
||||
let wrapped = format!(
|
||||
"{}env -S 'rm -rf /tmp/victim'",
|
||||
"env ".repeat(MAX_WRAPPER_DEPTH + 1)
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
policy.evaluate_bash_command_policy(&wrapped),
|
||||
Some(Decision::Ask) | Some(Decision::Reject(_))
|
||||
),
|
||||
"wrapper-exhausted env -S must not fail open"
|
||||
);
|
||||
|
||||
assert!(
|
||||
policy
|
||||
.evaluate_bash_command_policy("env FOO=1 echo hi")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ── default action tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2480,8 +2480,7 @@ mod tests {
|
|||
let (entries, logs) = parse_mcp_entries_capturing_logs(&json, "deniedMcpServers");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
matches!(&entries[0], AllowedMcpServer::Name { name }
|
||||
if name == "internal-only"),
|
||||
matches!(&entries[0], AllowedMcpServer::Name { name } if name == "internal-only"),
|
||||
"expected a Name entry, got {entries:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1228,6 +1228,18 @@ mod tests {
|
|||
.arg("pdeathsig_does_not_let_a_child_outlive_its_parent") // unique substring filter
|
||||
.arg("--nocapture")
|
||||
.env(PDEATHSIG_HELPER_ENV, "1")
|
||||
// The helper is a fresh libtest run of exactly the one filtered test.
|
||||
// Strip Bazel's per-shard test env so that when this target is built
|
||||
// with `shard_count > 1`, the re-exec'd helper does not re-apply
|
||||
// sharding to its single filtered test — otherwise the test could be
|
||||
// partitioned into a shard other than the inherited TEST_SHARD_INDEX,
|
||||
// run zero tests, and exit 0 instead of PDEATHSIG_HELPER_OK, failing
|
||||
// the driver's verdict assertion. Also drop the inherited test filter
|
||||
// so only our positional filter selects the test.
|
||||
.env_remove("TEST_SHARD_INDEX")
|
||||
.env_remove("TEST_TOTAL_SHARDS")
|
||||
.env_remove("TEST_SHARD_STATUS_FILE")
|
||||
.env_remove("TESTBRIDGE_TEST_ONLY")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
|
|
|
|||
|
|
@ -2360,8 +2360,9 @@ pub struct RehydrateSessionResponse {
|
|||
// ============================================================================
|
||||
|
||||
use xai_fast_worktree::{
|
||||
DbStats, GcOptions, GcReport, ListFilter, WorktreeDb, WorktreeKind, WorktreeRecord,
|
||||
gc_worktrees as fw_gc_worktrees, rebuild_worktree_db, resolve_grok_home,
|
||||
AutoGcOptions, DbStats, GcOptions, GcReport, ListFilter, WorktreeAutoGcLayer, WorktreeDb,
|
||||
WorktreeKind, WorktreeRecord, gc_worktrees as fw_gc_worktrees, maybe_auto_gc,
|
||||
rebuild_worktree_db, resolve_grok_home, resolve_worktree_auto_gc_from_layers,
|
||||
};
|
||||
|
||||
pub fn open_db() -> Result<WorktreeDb> {
|
||||
|
|
@ -2414,10 +2415,85 @@ pub fn gc_worktrees_mgmt(
|
|||
max_age_secs,
|
||||
force,
|
||||
dry_run,
|
||||
..Default::default()
|
||||
};
|
||||
fw_gc_worktrees(&db, &opts)
|
||||
}
|
||||
|
||||
/// Map settings → resolve layer (shared by shell + workspace).
|
||||
pub fn worktree_auto_gc_layer_from_settings(
|
||||
s: &xai_grok_config_types::WorktreeAutoGcSettings,
|
||||
) -> WorktreeAutoGcLayer {
|
||||
use std::collections::BTreeMap;
|
||||
use xai_grok_config_types::WorktreeKindMaxAge;
|
||||
|
||||
let max_age_by_kind = s
|
||||
.max_age_by_kind
|
||||
.as_ref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let kind = WorktreeKind::from_str_opt(k)?;
|
||||
let age = match v {
|
||||
WorktreeKindMaxAge::Secs(n) => Some(*n),
|
||||
WorktreeKindMaxAge::Never => None,
|
||||
};
|
||||
Some((kind, age))
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
WorktreeAutoGcLayer {
|
||||
enabled: s.enabled,
|
||||
max_age_secs: s.max_age_secs,
|
||||
min_interval_secs: s.min_interval_secs,
|
||||
dry_run: s.dry_run,
|
||||
include_orphan_snapshots: s.include_orphan_snapshots,
|
||||
max_age_by_kind,
|
||||
include_rebuild: s.include_rebuild,
|
||||
rebuild_min_interval_secs: s.rebuild_min_interval_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Env + `$GROK_HOME/config.toml` only — **`remote=None` is intentional**.
|
||||
///
|
||||
/// Workspace handle startup has no remote-settings blob (unlike shell agent
|
||||
/// init, which resolves env > TOML > remote). Remote `worktree_auto_gc`
|
||||
/// kill-switch / staged rollout therefore does not apply on pure-workspace
|
||||
/// processes; use `GROK_WORKTREE_AUTO_GC=0` / `GROK_WORKTREE_AUTO_GC_DRY_RUN=1`
|
||||
/// or local TOML until remote is plumbed into `make_workspace_handle`.
|
||||
fn resolve_worktree_auto_gc_local() -> xai_fast_worktree::ResolvedWorktreeAutoGc {
|
||||
use xai_grok_config_types::WorktreeAutoGcSettings;
|
||||
|
||||
let local = if let Ok(home) = resolve_grok_home() {
|
||||
let path = home.join("config.toml");
|
||||
if let Ok(text) = std::fs::read_to_string(&path)
|
||||
&& let Ok(root) = text.parse::<toml::Value>()
|
||||
{
|
||||
root.get("worktree")
|
||||
.and_then(|w| w.get("auto_gc"))
|
||||
// toml::Value only deserializes by value (no &Value Deserializer).
|
||||
.and_then(|v| WorktreeAutoGcSettings::deserialize(v.clone()).ok())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let layer = local.as_ref().map(worktree_auto_gc_layer_from_settings);
|
||||
// remote=None: see doc comment on this function.
|
||||
resolve_worktree_auto_gc_from_layers(layer.as_ref(), None)
|
||||
}
|
||||
|
||||
/// Sync auto-GC for handle startup (caller must `spawn_blocking`).
|
||||
pub fn run_auto_gc_best_effort() {
|
||||
let opts = AutoGcOptions::from_resolved(resolve_worktree_auto_gc_local());
|
||||
if let Err(e) = WorktreeDb::open_default().and_then(|db| maybe_auto_gc(&db, &opts)) {
|
||||
tracing::warn!(error = %e, "auto worktree gc failed");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn worktree_db_stats() -> Result<DbStats> {
|
||||
let db = open_db()?;
|
||||
db.stats()
|
||||
|
|
|
|||
Loading…
Reference in a new issue