Synced from monorepo

Synced from monorepo

Changes:
- grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop
- pager: clickable ▲ jumps to the top of the response being read
- grok-shell: keep a large task log from making the completion message too long
- Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app
- pager: poll the tmux probe teardown grace instead of sleeping it
- security: vendor-compat MCP kill switch is now actually enforced when reported as on
- grok-shell: restore session eviction when a leader client disconnects
- Bump rust-toolchain to 1.93.0
- workspace: lexical-normalize permission path patterns before glob matching
- pager: reject garbage Enter in the /resume picker
- pager: show Mermaid affordances in plan mode preview
- pager: drop manage-account link from /session-info
- workspace: auto-approve read-only git queries; defer write floor to auto classifier
- Add free-form pattern editor to the "Always allow" command prompt
- grok-shell: fix /btw caching
- pager: Tab walks answers in the ask_user_question card
- External-provider auth refresh: single 7s attempt instead of 3×5s
- pager: don't resurrect finished background tasks as Running when completion arrives first
- pager: report tmux truecolor clamping in Doctor
- Fix plan viewer scrollbar click+drag hijacked by comment gutter
- pager/shell: stop double Recap after the same last turn
- sampler: preserve x-should-retry through stream collection
- pager: clear plan-mode indicator immediately when the user approves a plan
- pager: tmux does not re-read its config on reattach

Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
This commit is contained in:
grokkybara[bot] 2026-08-03 08:17:57 +00:00
commit 780d1388ff
323 changed files with 12258 additions and 7226 deletions

View file

@ -368,9 +368,8 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str>
hit!("lsp");
}
// Project `.cursor/mcp.json` — vendor MCP loading is default-on and tagged
// `Project`, so a repo shipping ONLY this file must still be gated. (File
// presence is enough; if the `.cursor` compat flag is off the servers won't
// spawn and gating is a harmless no-op.)
// `Project`, so a repo shipping ONLY this file must still be gated (file
// presence is enough).
if cwd.join(".cursor").join("mcp.json").is_file() {
hit!("mcp");
}

View file

@ -1079,6 +1079,7 @@ mod tests {
owner_session_id: None,
description: None,
is_backgrounded: false,
output_total_bytes: 0,
})
}
fn started_id(n: &ToolNotification) -> &str {

View file

@ -13,6 +13,7 @@ use super::bash_command_splitting::{
PlainCommand, is_wrapper_command, strip_wrapper_command, try_parse_shell,
try_parse_word_only_commands_sequence, unwrap_wrappers,
};
use super::exec_risk::{git_words_are_read_only_query, git_words_have_unsafe_query_option};
use super::shell_access::{
command_words_write_paths, command_write_paths_in_tree, is_safe_write_sink,
};
@ -453,10 +454,10 @@ impl HeuristicPermissionClassifier {
/// queries.
const ROUTINE_PREFIXES: &[&str] = &[
"cargo ",
"git status",
"git diff",
"git log",
"git branch",
// Read-only git queries are NOT listed here: they go through the shared
// `exec_risk::git_words_are_read_only_query` helper (single verb table +
// unsafe-option table) in `bash_command_is_routine`. Only the local
// write-workflow verbs stay prefix-matched.
"git add",
"git commit",
"git checkout",
@ -464,13 +465,6 @@ const ROUTINE_PREFIXES: &[&str] = &[
"git stash",
"git pull",
"git fetch",
"git show",
"git blame",
"git grep",
"git ls-files",
"git rev-parse",
"git describe",
"git merge-base",
"git worktree list",
"pytest",
"python ",
@ -648,20 +642,19 @@ fn bash_command_is_routine(words: &[String]) -> bool {
if head == "find" {
return find_is_read_only(inner);
}
// `git grep -O<cmd>`/`--open-files-in-pager` executes <cmd>; the write
// model treats `-O` as a read-only order-file (true for diff/log only).
// Git accepts uniquely-abbreviated long options, so any `--o*` word whose
// pre-`=` part prefixes the full option (`--op`, `--open`, ...) blocks too;
// `--or`/`--only-matching` diverge at the 4th char and stay routine.
if head == "git"
&& inner.get(1).is_some_and(|s| s.eq_ignore_ascii_case("grep"))
&& inner.iter().any(|w| {
let flag = w.split('=').next().unwrap_or(w);
w.starts_with("-O")
|| (flag.starts_with("--o") && "--open-files-in-pager".starts_with(flag))
})
{
return false;
// Git: read-only queries decide via the shared helper (one verb table +
// one unsafe-option table with the manager safe lists — `--filters` /
// `--textconv` content drivers, `--output` write sink, `--ext-diff`,
// `grep -O` pager exec, with long-option abbreviations failing closed).
// The local write-workflow verbs (`git add`/`commit`/…) fall through to
// ROUTINE_PREFIXES, still subject to the same unsafe-option table.
if head == "git" {
if git_words_are_read_only_query(inner) {
return true;
}
if git_words_have_unsafe_query_option(inner) {
return false;
}
}
// `tree -o <file>` writes an arbitrary path outside the write model; short
// flags group (`-ao`), so reject any short-flag word containing `o`.
@ -2922,6 +2915,7 @@ mod tests {
"find /repo/templates -name '*boostback*' 2>/dev/null; grep -rn boostback_burn /repo/templates --include '*.template'",
"cd crates && cargo build",
"git status && git diff | head -50",
"cd /repo && cat .gitignore | grep -n vendored ; git check-ignore -v docs/report.md; echo \"---template---\"; cat templates/commit.txt",
] {
assert_eq!(
v(block_all.clone(), cmd).await,
@ -3027,6 +3021,16 @@ mod tests {
"git rev-parse --show-toplevel",
"git merge-base HEAD origin/main",
"git worktree list",
"git check-ignore -v docs/report.md",
"git check-attr -a src/main.rs",
"git cat-file -p HEAD:src/main.rs",
"git ls-tree -r HEAD src",
"git show-ref --heads",
"git for-each-ref refs/heads",
"git rev-list --count HEAD",
"git name-rev HEAD",
"git count-objects -v",
"git shortlog -sn",
"kubectl get pods -n prod",
"kubectl logs my-pod",
"kubectl describe deploy my-app",
@ -3050,6 +3054,8 @@ mod tests {
for cmd in [
"git worktree remove ../x",
"git remote add origin evil",
"git cat-file --filters HEAD:data.bin",
"git cat-file --textconv HEAD:data.bin",
"git push --force",
"kubectl delete pod my-pod",
"kubectl apply -f x.yaml",

View file

@ -622,6 +622,14 @@ pub(crate) fn unwrap_wrappers(words: &[String]) -> &[String] {
unwrap_wrappers_checked(words).words
}
/// Peel wrapper commands (`timeout`, `nice`, `env`, …) from a command's words,
/// exposing the same normalization the permission enforcer applies before
/// matching session grants. The pager's "Always allow" pattern editor uses this
/// so its pre-fill and match preview agree with enforcement on wrapped commands.
pub fn unwrap_command_wrappers(words: &[String]) -> &[String] {
unwrap_wrappers(words)
}
/// 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> {

View file

@ -226,7 +226,12 @@ pub(crate) fn segment_exec_facts(words: &[String]) -> SegmentExecFacts {
}
}
const SAFE_GIT_SUBCOMMANDS: &[&str] = &[
/// Read-only git query verbs. SINGLE SOURCE for every git allow decision:
/// [`git_words_are_read_only_query`] (the manager safe lists and the auto-mode
/// routine heuristic both call it) and the `alias.<verb> = !cmd` shadowing
/// check in the ambient config scan below. Add a new read-only verb here and
/// every consumer inherits it — do not grow per-consumer prefix lists.
pub(crate) const SAFE_GIT_SUBCOMMANDS: &[&str] = &[
"status",
"branch",
"log",
@ -234,8 +239,126 @@ const SAFE_GIT_SUBCOMMANDS: &[&str] = &[
"ls-files",
"show",
"rev-parse",
"blame",
"grep",
"describe",
"merge-base",
"check-ignore",
"check-attr",
"cat-file",
"ls-tree",
"show-ref",
"for-each-ref",
"rev-list",
"name-rev",
"count-objects",
"shortlog",
];
/// Options that make an otherwise read-only git verb run repo-configured
/// content drivers or write arbitrary paths — one table applied to EVERY
/// [`SAFE_GIT_SUBCOMMANDS`] verb, so a new safe verb inherits the policy:
/// `--filters`/`--textconv` run `filter.*.smudge` / `diff.*.textconv`
/// (`filter.*.smudge` is outside the ambient local-config exec scan),
/// `--ext-diff` runs the external diff driver, `--output` writes an arbitrary
/// file, and `--open-files-in-pager` executes a pager command (`git grep`'s
/// short-attached `-O<cmd>` form is guarded in
/// [`git_words_have_unsafe_query_option`]).
const GIT_QUERY_UNSAFE_OPTIONS: &[&str] = &[
"--filters",
"--textconv",
"--output",
"--ext-diff",
"--open-files-in-pager",
];
/// Git accepts uniquely-abbreviated long options, so any `--` word (pre-`=`,
/// ≥3 chars) that prefixes a table entry fails closed — including
/// abbreviations a specific verb would resolve to a benign sibling
/// (`git grep --text` collides with `--textconv` and prompts).
fn git_query_option_is_unsafe(word: &str) -> bool {
let flag = word.split('=').next().unwrap_or(word);
flag.len() > 2
&& GIT_QUERY_UNSAFE_OPTIONS
.iter()
.any(|full| full.starts_with(flag))
}
/// Resolve the subcommand index, skipping only modeled-benign globals:
/// `-C <path>` / `-C<path>` (the ambient config scan tracks the retargeted
/// cwd) and `--no-pager` / `-P`. Every other pre-subcommand option fails
/// closed (`None`) — `-c`, `--config-env`, `--git-dir`, `--work-tree`,
/// `--exec-path`, `--paginate`, `--attr-source`, … can change what executes
/// or which config a query reads.
fn git_safe_query_verb_index(words: &[String]) -> Option<usize> {
let mut i = 1;
loop {
let tok = words.get(i).map(String::as_str)?;
if tok == "-" || tok == "--" {
return None;
}
if !tok.starts_with('-') {
return Some(i);
}
if tok == "-C" {
i += 2;
continue;
}
if attached_git_c_path(tok).is_some() || tok == "--no-pager" || tok == "-P" {
i += 1;
continue;
}
return None;
}
}
/// True when a `git` invocation carries an option from the shared unsafe
/// table ([`GIT_QUERY_UNSAFE_OPTIONS`], plus `git grep`'s short-attached
/// `-O<cmd>`), whatever the verb. Used on its own to keep a session
/// whitelist-prefix grant from riding over a driver/write flag, and by
/// [`git_words_are_read_only_query`].
pub(crate) fn git_words_have_unsafe_query_option(words: &[String]) -> bool {
if words.first().map(String::as_str) != Some("git") {
return false;
}
if words.iter().skip(1).any(|w| git_query_option_is_unsafe(w)) {
return true;
}
// `git grep -O<cmd>` / `-O <cmd>` executes <cmd>; the short-attached form
// is not a long-option abbreviation, so guard it verb-specifically.
matches!(git_safe_query_verb_index(words), Some(i) if words[i] == "grep")
&& words.iter().skip(1).any(|w| w.starts_with("-O"))
}
/// Single decision point for auto-approvable read-only `git` queries, shared
/// by the manager safe lists and the auto-mode routine heuristic so verb
/// policy and flag policy live in one place:
/// 1. resolve the subcommand via [`git_safe_query_verb_index`] (benign
/// globals skipped, config/retarget/unknown globals fail closed — plus a
/// redundant [`git_has_exec_risk_global`] belt for odd `-C` value shapes);
/// 2. allow only [`SAFE_GIT_SUBCOMMANDS`] verbs;
/// 3. reject the shared unsafe-option table
/// ([`git_words_have_unsafe_query_option`]).
///
/// Callers pass wrapper-peeled words; `words[0]` must be literally `git`
/// (path-qualified or case-variant "git" binaries fail closed — a different
/// binary of the same basename must not ride the allowlist).
pub(crate) fn git_words_are_read_only_query(words: &[String]) -> bool {
if words.first().map(String::as_str) != Some("git") {
return false;
}
if git_has_exec_risk_global(words) {
return false;
}
let Some(verb_idx) = git_safe_query_verb_index(words) else {
return false;
};
if !SAFE_GIT_SUBCOMMANDS.contains(&words[verb_idx].as_str()) {
return false;
}
!git_words_have_unsafe_query_option(words)
}
fn local_git_config_entry_is_exec(name: &str, value: &str) -> bool {
let name = name.to_ascii_lowercase();
let value = value.trim();
@ -536,6 +659,79 @@ mod tests {
}
}
#[test]
fn read_only_git_queries() {
// Safe verbs, benign globals, ordinary flags.
for cmd in [
"git status",
"git -C sub status",
"git -C/abs/path log --oneline --graph",
"git --no-pager diff --stat",
"git -P show HEAD",
"git cat-file -p HEAD:src/main.rs",
"git grep --only-matching pattern",
"git grep --or -e a -e b",
"git rev-parse --show-toplevel",
"git shortlog -sn",
] {
assert!(git_words_are_read_only_query(&words(cmd)), "{cmd}");
}
// Non-query verbs, unmodeled/exec globals, non-bare git.
for cmd in [
"git push --force",
"git checkout main",
"git",
"git -C sub",
"git -c core.fsmonitor=/x status",
"git --git-dir=/evil/.git status",
"git --exec-path=/evil status",
"git -p status",
"git --paginate status",
"git --attr-source=evil check-attr -a f",
"git -- status",
"/usr/bin/git status",
"Git status",
"rm -rf /",
] {
assert!(!git_words_are_read_only_query(&words(cmd)), "{cmd}");
}
}
#[test]
fn unsafe_query_options_apply_to_every_verb() {
// Driver / write flags block on any verb, abbreviations fail closed.
for cmd in [
"git cat-file --filters HEAD:data.bin",
"git cat-file --textconv HEAD:data.bin",
"git cat-file --filt HEAD:data.bin",
"git show --textconv HEAD:data.bin",
"git log --textconv -p",
"git log --ext-diff",
"git show --output=/tmp/out HEAD",
"git log --output /tmp/out",
"git grep -Ovim TODO",
"git grep -O touch-evil TODO",
"git grep --open-files-in-pager=sh TODO",
"git grep --op=sh TODO",
] {
assert!(git_words_have_unsafe_query_option(&words(cmd)), "{cmd}");
assert!(!git_words_are_read_only_query(&words(cmd)), "{cmd}");
}
for cmd in [
"git cat-file -p HEAD:src/main.rs",
"git log --oneline",
"git log --only-matching",
"git grep --or -e a -e b",
"git show --stat HEAD",
] {
assert!(!git_words_have_unsafe_query_option(&words(cmd)), "{cmd}");
}
// `-O` outside `git grep` is not the pager flag.
assert!(!git_words_have_unsafe_query_option(&words(
"git log -O/tmp/orderfile"
)));
}
#[test]
fn interleaved_wrapper_transparent_facts() {
let f = segment_exec_facts(&words("command git status"));

View file

@ -25,13 +25,15 @@ pub(crate) struct GatePreflight {
}
impl GatePreflight {
/// `cwd` is the requesting session's execution cwd (not necessarily the
/// manager's): path rules and shell-file operands anchor to it.
pub(crate) fn evaluate(
policy: Option<&CompiledPolicy>,
access: &AccessKind,
cwd: &Path,
auto_mode: bool,
) -> Self {
let direct = policy.and_then(|policy| policy.evaluate(access));
let direct = policy.and_then(|policy| policy.evaluate_with_cwd(access, Some(cwd)));
let (bash_command, shell_file) = match (policy, access) {
(Some(policy), AccessKind::Bash(cmd)) => (
policy.evaluate_bash_command_gate(cmd),

View file

@ -197,6 +197,7 @@ pub(crate) fn reply_to_outcome(reply: &Value) -> PromptOutcome {
"approve" => PromptOutcome::AllowOnce,
"always_approve" => match scope_kind_value(reply) {
Some(("bash_command", Some(value))) => PromptOutcome::AllowAlwaysBashCommand(value),
Some(("bash_glob", Some(value))) => PromptOutcome::AllowAlwaysBashGlob(value),
Some(("server_prefix", Some(value))) => PromptOutcome::AllowAlwaysMcpServer(value),
Some(("domain", Some(value))) => PromptOutcome::AllowAlwaysDomain(value),
_ => PromptOutcome::AllowAlways,
@ -280,6 +281,7 @@ pub fn prompt_outcome_allows(outcome: &PromptOutcome) -> bool {
| PromptOutcome::AllowAlways
| PromptOutcome::AllowEditsForSession
| PromptOutcome::AllowAlwaysBashCommand(_)
| PromptOutcome::AllowAlwaysBashGlob(_)
| PromptOutcome::AllowAlwaysDomain(_)
| PromptOutcome::AllowAlwaysMcpTool(_)
| PromptOutcome::AllowAlwaysMcpServer(_)

View file

@ -12,8 +12,9 @@ use crate::permission::bash_command_splitting::{
is_setup_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers,
};
use crate::permission::exec_risk::{
AmbientScanPlan, ambient_exec_risk_from_plan, ambient_scan_plan_from_segments,
script_may_invoke_git, segment_exec_facts,
AmbientScanPlan, SAFE_GIT_SUBCOMMANDS, ambient_exec_risk_from_plan,
ambient_scan_plan_from_segments, git_words_are_read_only_query,
git_words_have_unsafe_query_option, script_may_invoke_git, segment_exec_facts,
};
use crate::permission::gate_preflight::GatePreflight;
use crate::permission::policy::{CompiledPolicy, ShellWord};
@ -24,8 +25,8 @@ use crate::permission::shell_access::{
};
use crate::permission::state::{PermissionState, load_state_from_disk, persist_state};
use crate::permission::types::{
AccessKind, ClientType, Decision, EditPathContext, EditPolicy, PermissionCommand,
PermissionEvent, PromptPolicy,
AccessKind, ClientType, Decision, EditPolicy, PermissionCommand, PermissionEvent, PromptPolicy,
RequestPathContext,
};
use xai_grok_mcp::servers::parse_mcp_qualified_name;
use xai_grok_paths::AbsPathBuf;
@ -350,6 +351,11 @@ fn is_safe_command_words(words: &[String]) -> bool {
if ps_dumps_environment(words) {
return false;
}
// Git rides its own shared decision helper (verb allowlist + unsafe-option
// table in `exec_risk.rs`), not the string prefixes below.
if words.first().map(String::as_str) == Some("git") {
return git_words_are_read_only_query(words);
}
let joined = words.join(" ");
is_safe_command_words_str(&joined)
}
@ -358,19 +364,25 @@ fn matches_command_prefix(cmd: &str, pattern: &str) -> bool {
cmd == pattern || (cmd.starts_with(pattern) && cmd.as_bytes().get(pattern.len()) == Some(&b' '))
}
/// `git <read-only verb>` prefix match, derived from the single
/// [`SAFE_GIT_SUBCOMMANDS`] verb table. String-level only (whitelist scope /
/// fallback) — the words paths decide via
/// [`git_words_are_read_only_query`], which also rejects unsafe options.
fn is_safe_git_query_prefix(cmd: &str) -> bool {
cmd.strip_prefix("git ").is_some_and(|rest| {
SAFE_GIT_SUBCOMMANDS
.iter()
.any(|verb| matches_command_prefix(rest, verb))
})
}
/// Shared prefix check used by both the tree-sitter path and the fallback path.
fn is_safe_command_words_str(cmd: &str) -> bool {
matches_command_prefix(cmd, "ls")
|| matches_command_prefix(cmd, "cat")
|| matches_command_prefix(cmd, "pwd")
|| matches_command_prefix(cmd, "date")
|| matches_command_prefix(cmd, "git status")
|| matches_command_prefix(cmd, "git branch")
|| matches_command_prefix(cmd, "git log")
|| matches_command_prefix(cmd, "git diff")
|| matches_command_prefix(cmd, "git ls-files")
|| matches_command_prefix(cmd, "git show")
|| matches_command_prefix(cmd, "git rev-parse")
|| is_safe_git_query_prefix(cmd)
|| matches_command_prefix(cmd, "whoami")
|| matches_command_prefix(cmd, "hostname")
|| matches_command_prefix(cmd, "uptime")
@ -408,14 +420,9 @@ const ALWAYS_SAFE_COMMANDS: &[&str] = &[
"hostname",
"uptime",
"ps",
// Git read-only commands
"git status",
"git branch",
"git log",
"git diff",
"git ls-files",
"git show",
"git rev-parse",
// Git read-only queries are NOT listed here: they go through the shared
// `exec_risk::git_words_are_read_only_query` helper (single verb table +
// unsafe-option table) in `is_always_safe_command_words`.
// Search commands
"grep",
"rg",
@ -446,6 +453,11 @@ fn is_always_safe_command_words(words: &[String]) -> bool {
if ps_dumps_environment(words) {
return false;
}
// Git rides its own shared decision helper (verb allowlist + unsafe-option
// table in `exec_risk.rs`), not the prefix list below.
if words.first().map(String::as_str) == Some("git") {
return git_words_are_read_only_query(words);
}
let joined = words.join(" ");
@ -516,11 +528,20 @@ fn is_dangerous_command_words(words: &[String]) -> bool {
/// Whitelist matching helper. Uses `matches_command_prefix` so that user
/// allow/deny entries enforce a word boundary after the prefix — preventing
/// the "git" entry from matching "gitleaks" (CWE-183).
/// the "git" entry from matching "gitleaks" (CWE-183). Metacharacters in a
/// literal grant stay literal; glob patterns live in `allowed_bash_globs` and
/// are matched separately (see [`matches_bash_glob`]).
fn matches_whitelist_prefix(segment_str: &str, allowed_prefix: &str) -> bool {
matches_command_prefix(segment_str, allowed_prefix)
}
/// Whether a user-authored glob grant (`allowed_bash_globs`) authorizes
/// `segment_str`, using the same matcher as the config `[permission]` rules and
/// the pattern-editor preview, so what the user previewed is what auto-allows.
fn matches_bash_glob(segment_str: &str, pattern: &str) -> bool {
super::policy::bash_pattern_matches_command(pattern, segment_str)
}
/// Ordinary command-segment outcome, before script-level effect floors.
#[derive(Debug)]
pub(crate) enum SegmentEvaluation {
@ -655,7 +676,11 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
let matched_grant = state
.allowed_bash_commands
.iter()
.any(|a| matches_whitelist_prefix(&s, a));
.any(|a| matches_whitelist_prefix(&s, a))
|| state
.allowed_bash_globs
.iter()
.any(|g| matches_bash_glob(&s, g));
all_segments_granted &= matched_grant;
// 2. Dangerous commands must be prompted even if a whitelist prefix
@ -667,14 +692,19 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
continue;
}
// kubectl config/auth flags, `rg --pre`, and env-dumping `ps` (BSD
// `e`/`E`) must prompt even under a whitelist *prefix* grant. Always-allow
// persists only the verb prefix (e.g. "kubectl get", or a bare "ps" from
// approving `ps aux`), so it cannot be trusted to auto-allow these
// secret-exposing variants (H1 #3877754). An exact segment grant still
// auto-allows below. Do NOT set any_dangerous — that would also block
// exact grants.
if (kubectl_has_unsafe_flag(words) || rg_has_pre_flag(words) || ps_dumps_environment(words))
// kubectl config/auth flags, `rg --pre`, env-dumping `ps` (BSD
// `e`/`E`), and git driver/write options (`--textconv`, `--filters`,
// `--output`, `--ext-diff`, `grep -O`) must prompt even under a
// whitelist *prefix* grant. Always-allow persists only the verb prefix
// (e.g. "kubectl get", "git cat-file", or a bare "ps" from approving
// `ps aux`), so it cannot be trusted to auto-allow these
// secret-exposing / exec-capable variants (H1 #3877754). An exact
// segment grant still auto-allows below. Do NOT set any_dangerous —
// that would also block exact grants.
if (kubectl_has_unsafe_flag(words)
|| rg_has_pre_flag(words)
|| ps_dumps_environment(words)
|| git_words_have_unsafe_query_option(words))
&& !state.allowed_bash_commands.contains(&s)
{
needs_prompt.push(s);
@ -908,7 +938,7 @@ impl PermissionHandle {
subagent_type: Option<String>,
subagent_description: Option<String>,
) -> Decision {
self.request_with_edit_path_context(
self.request_with_path_context(
access,
tool_call_update,
None,
@ -919,13 +949,14 @@ impl PermissionHandle {
.await
}
/// Request permission with the edit tool's per-session execution cwd.
/// Shared parent/subagent managers must use this for `AccessKind::Edit`.
pub async fn request_with_edit_path_context(
/// Request permission with the requesting session's execution cwd.
/// Shared parent/subagent managers must use this for every path-bearing
/// access: path rules and edit-target resolution anchor to it.
pub async fn request_with_path_context(
&self,
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
edit_path_context: Option<EditPathContext>,
path_context: Option<RequestPathContext>,
session_id: Option<String>,
subagent_type: Option<String>,
subagent_description: Option<String>,
@ -942,7 +973,7 @@ impl PermissionHandle {
let msg = PermissionCommand::Request {
access,
tool_call_update,
edit_path_context,
path_context,
respond_to: tx,
session_id,
subagent_type,
@ -1024,12 +1055,30 @@ fn bash_request_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bo
|| bash_exec_floor_requires_prompt(evaluation)
}
/// Floors that consult the auto-mode classifier before prompting instead of
/// hard-prompting: unvetted env assignments and real-file writes (auto mode
/// already accepts the same mutations via the Edit tool, so a redirect like
/// `printf … >> notes.md` gets the classifier's judgment rather than an
/// unconditional prompt). Injection env, opaque shells, exec-risk flags, and
/// dangerous segments (`rm`/`chmod`/`kill`/`git push`/… — whose operands also
/// count as real-file writes, e.g. `rm -rf /`) never defer, and a deferred
/// Block still prompts — never a silent deny.
fn bash_request_floor_defers_to_classifier(evaluation: Option<&BashEvaluation>) -> bool {
evaluation.is_some_and(|evaluation| {
!evaluation.writes_real_file
&& !evaluation.has_opaque_shell
!evaluation.has_opaque_shell
&& !evaluation.exec_risk
&& evaluation.env_risk == EnvRisk::Unvetted
&& !matches!(
evaluation.segments,
SegmentEvaluation::NeedsPrompts {
any_dangerous: true,
..
}
)
&& match evaluation.env_risk {
EnvRisk::Safe => evaluation.writes_real_file,
EnvRisk::Unvetted => true,
EnvRisk::Injection => false,
}
})
}
@ -1409,7 +1458,7 @@ fn spawn_permission_manager_with_pin(
PermissionCommand::Request {
access,
tool_call_update,
edit_path_context,
path_context,
mut respond_to,
session_id: request_session_id,
subagent_type: request_subagent_type,
@ -1417,6 +1466,17 @@ fn spawn_permission_manager_with_pin(
} => {
// wait_ms timer; starts at dequeue so it excludes time queued behind others.
let request_received = std::time::Instant::now();
// The requesting session's execution cwd. A shared
// parent/subagent manager must anchor path rules, shell
// gates, and ambient scans where the tool actually
// resolves paths — not the manager cwd, where a child's
// relative path would wrongly satisfy rooted allows like
// `Read(./**)`. Direct callers without context keep the
// manager cwd.
let request_cwd = path_context
.as_ref()
.map(|context| context.real_cwd.as_path())
.unwrap_or_else(|| cwd.as_path());
// Effective mode (yolo wins); stable for the arm (single-threaded actor).
let permission_mode = if yolo_mode {
xai_grok_telemetry::enums::PermissionMode::AlwaysApprove
@ -1527,7 +1587,7 @@ fn spawn_permission_manager_with_pin(
AccessKind::Bash(cmd) => {
let mut evaluation = evaluate_bash(cmd, &state, true);
if let Some(raw) = evaluation.ambient_segments.take() {
let session_cwd = cwd.as_path().to_path_buf();
let session_cwd = request_cwd.to_path_buf();
let plan = ambient_scan_plan_from_segments(&raw, &session_cwd);
// FailClosed needs no git2; CheckDirs is blocking.
let ambient_risk = match plan {
@ -1562,7 +1622,7 @@ fn spawn_permission_manager_with_pin(
}
_ => None,
};
let protected_edit = match (&access, edit_path_context.as_ref()) {
let protected_edit = match (&access, path_context.as_ref()) {
(AccessKind::Edit(path), Some(context)) => {
let resolved = resolve_model_path(
&context.real_cwd,
@ -1588,7 +1648,7 @@ fn spawn_permission_manager_with_pin(
let preflight = GatePreflight::evaluate(
compiled_policy.as_ref(),
&access,
cwd.as_path(),
request_cwd,
auto_mode,
);
let policy_decision = preflight.policy_decision();
@ -1671,7 +1731,8 @@ fn spawn_permission_manager_with_pin(
// fast-path/classifier allows. Policy Ask still prompts below
// unless auto fast-path/classifier decides first for non-forced
// paths; policy Asks and Bash request floors skip auto entirely
// unless they defer (fail-closed gate Ask / unvetted-env floor).
// unless they defer (fail-closed gate Ask / unvetted-env /
// real-file write floors).
if auto_mode
&& preflight.admits_auto_classifier()
&& (!bash_request_floor_requires_prompt(bash_evaluation.as_ref())
@ -2135,6 +2196,11 @@ fn spawn_permission_manager_with_pin(
persist_state(&cwd, &state, client_id_ref).await;
(Decision::Allow, "allow_always_bash")
}
PromptOutcome::AllowAlwaysBashGlob(pattern) => {
state.allowed_bash_globs.insert(pattern.clone());
persist_state(&cwd, &state, client_id_ref).await;
(Decision::Allow, "allow_always_bash_glob")
}
PromptOutcome::AllowAlwaysDomain(_)
| PromptOutcome::AllowAlwaysMcpTool(_)
| PromptOutcome::AllowAlwaysMcpServer(_)
@ -2198,7 +2264,8 @@ fn spawn_permission_manager_with_pin(
persist_state(&cwd, &state, client_id_ref).await;
(Decision::Allow, "allow_always")
}
PromptOutcome::AllowAlwaysBashCommand(_) => {
PromptOutcome::AllowAlwaysBashCommand(_)
| PromptOutcome::AllowAlwaysBashGlob(_) => {
// Not reachable for non-bash access; defensive.
(Decision::Allow, "allow_always_bash")
}
@ -2617,7 +2684,7 @@ mod tests {
#[tokio::test]
#[cfg(unix)]
async fn shared_manager_uses_request_edit_path_context() {
async fn shared_manager_uses_request_path_context() {
use std::os::unix::fs::symlink;
let local = tokio::task::LocalSet::new();
@ -2631,7 +2698,7 @@ mod tests {
let transport = fake_hub(serde_json::json!({ "outcome": "approve" }));
let (mgr, _events) = test_manager_with_hub(&parent_cwd, transport.clone());
mgr.set_auto_mode(true);
let context = EditPathContext {
let context = RequestPathContext {
real_cwd: child.path().to_path_buf(),
display_cwd: Some(display.path().to_path_buf()),
};
@ -2641,7 +2708,7 @@ mod tests {
display.path().join("src.rs"),
] {
assert_eq!(
mgr.request_with_edit_path_context(
mgr.request_with_path_context(
AccessKind::Edit(displayed.to_string_lossy().into_owned()),
tool_call(),
Some(context.clone()),
@ -2662,6 +2729,78 @@ mod tests {
.await;
}
/// Path rules anchor to the request's execution cwd, not the manager's:
/// a rule rooted at the parent workspace must key on file identity, so a
/// subagent's relative path (which resolves under the child cwd) must not
/// be normalized into the parent workspace and hit the parent's rule.
#[tokio::test]
async fn shared_manager_path_rules_anchor_to_request_cwd() {
use crate::permission::types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let parent = tempfile::tempdir().unwrap();
let child = tempfile::tempdir().unwrap();
let parent_cwd = AbsPathBuf::new(parent.path().to_path_buf()).unwrap();
let config = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Ask,
tool: ToolFilter::Read,
pattern: Some(format!("{}/**", parent.path().display())),
pattern_mode: PatternMode::Glob,
}]);
let tc = || {
acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc")),
acp::ToolCallUpdateFields::default(),
)
};
let (mgr, _e) = test_manager_with_config(&parent_cwd, config, false);
let context = RequestPathContext {
real_cwd: child.path().to_path_buf(),
display_cwd: None,
};
// Absolute parent-workspace file: the rule keys on identity
// regardless of the request cwd.
let parent_file = parent.path().join("src/main.rs");
let d = mgr
.request_with_path_context(
AccessKind::Read(Some(parent_file.to_string_lossy().into_owned())),
tc(),
Some(context.clone()),
None,
None,
None,
)
.await;
assert!(
!matches!(d, Decision::Allow),
"parent-workspace read must hit the parent rule, got {d:?}"
);
// A bare relative from the child session resolves under the
// CHILD cwd — outside the parent workspace — so the parent
// rule must not match; the read keeps its default auto-allow.
let d = mgr
.request_with_path_context(
AccessKind::Read(Some("src/main.rs".into())),
tc(),
Some(context),
None,
None,
None,
)
.await;
assert!(
matches!(d, Decision::Allow),
"child-relative read must not be normalized into the parent workspace, got {d:?}"
);
})
.await;
}
#[tokio::test]
async fn hub_permission_reject_aborts() {
let local = tokio::task::LocalSet::new();
@ -5051,7 +5190,7 @@ mod tests {
}
#[tokio::test]
async fn auto_mode_write_floor_prompts_despite_classifier_allow() {
async fn auto_mode_write_floor_defers_to_classifier_allow() {
use crate::permission::auto_mode::LlmPermissionClassifier;
let local = tokio::task::LocalSet::new();
local
@ -5066,9 +5205,44 @@ mod tests {
mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text(
r#"{"thinking":"looks fine","shouldBlock":false,"reason":"ok"}"#,
)));
for cmd in ["V=1 cat payload > out", "printf 'done\\n' >> progress.md"] {
let d = mgr
.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None)
.await;
assert!(matches!(d, Decision::Allow), "{cmd}: {d:?}");
let ev = events.try_recv().expect("event must be emitted");
assert_eq!(
ev.decision_reason.as_deref(),
Some("auto_classifier_allow"),
"{cmd}"
);
}
assert_eq!(prompts.borrow().len(), 0);
})
.await;
}
/// A classifier Block on a deferred write floor stays prompt-binding:
/// the user is asked, never a silent deny.
#[tokio::test]
async fn auto_mode_write_floor_classifier_block_prompts() {
use crate::permission::auto_mode::LlmPermissionClassifier;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let tmp = tempfile::tempdir().unwrap();
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
let client = RecordingClient::default();
let prompts = client.prompts.clone();
let (mgr, mut events) =
manager_with_recording_client(&cwd, None, client, ClientType::Generic);
mgr.set_auto_mode(true);
mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text(
r#"{"thinking":"risky sink","shouldBlock":true,"reason":"no"}"#,
)));
let d = mgr
.request(
AccessKind::Bash("V=1 cat payload > out".into()),
AccessKind::Bash("cat payload > out".into()),
tool_call(),
None,
None,
@ -5077,7 +5251,7 @@ mod tests {
.await;
assert!(matches!(d, Decision::Reject(_)), "{d:?}");
let ev = events.try_recv().expect("event must be emitted");
assert_eq!(ev.decision_reason.as_deref(), Some("bash_request_floor"));
assert_eq!(ev.decision_reason.as_deref(), Some("auto_classifier_block"));
assert_eq!(prompts.borrow().len(), 1);
})
.await;
@ -5246,7 +5420,7 @@ mod tests {
.send(PermissionCommand::Request {
access: AccessKind::Bash("curl http://example.com".into()),
tool_call_update: tool_call(),
edit_path_context: None,
path_context: None,
respond_to: tx,
session_id: None,
subagent_type: None,
@ -5344,7 +5518,7 @@ mod tests {
input: serde_json::Value::Null,
},
tool_call_update: tool_call(),
edit_path_context: None,
path_context: None,
respond_to,
session_id: None,
subagent_type: None,
@ -5410,7 +5584,7 @@ mod tests {
.send(PermissionCommand::Request {
access: AccessKind::Bash("curl http://example.com".into()),
tool_call_update: tool_call(),
edit_path_context: None,
path_context: None,
respond_to: tx,
session_id: None,
subagent_type: None,
@ -5945,6 +6119,29 @@ mod tests {
"rg --pre-glob '*.pdf' --pre pdftotext pattern"
));
// The shared unsafe-option table applies to EVERY read-only git verb:
// `--filters`/`--textconv` (and unique long-option abbreviations) run
// repo-configured content drivers, `--output` writes an arbitrary
// path, `--ext-diff` runs the external diff driver, `grep -O` runs a
// pager.
assert!(is_safe_command("git cat-file -p HEAD:src/main.rs"));
assert!(!is_safe_command("git cat-file --filters HEAD:data.bin"));
assert!(!is_safe_command("git cat-file --textconv HEAD:data.bin"));
assert!(!is_safe_command("git cat-file --filt HEAD:data.bin"));
assert!(!is_safe_command("git show --textconv HEAD:data.bin"));
assert!(!is_safe_command("git log --textconv -p"));
assert!(!is_safe_command("git log --ext-diff"));
assert!(!is_safe_command("git show --output=/tmp/out HEAD"));
assert!(!is_safe_command("git grep -Osh TODO"));
assert!(!is_safe_command("git grep --open-files-in-pager=sh TODO"));
// Read-only queries resolve through benign globals; exec/retarget or
// unmodeled globals fail closed.
assert!(is_safe_command("git -C sub status"));
assert!(is_safe_command("git --no-pager log --oneline"));
assert!(is_safe_command("git grep -n TODO src"));
assert!(!is_safe_command("git --exec-path=/evil status"));
assert!(!is_safe_command("git -p status"));
// kubectl commands
assert!(is_safe_command("kubectl get pods"));
assert!(is_safe_command("kubectl get pods -n namespace"));
@ -7004,6 +7201,37 @@ mod tests {
assert!(!bash_unsafe_env_floor_requires_prompt(Some(&granted)));
}
/// Real-file writes defer to the auto-mode classifier on their own, but
/// never when combined with an injection env, opaque shell, exec risk, or
/// a dangerous segment.
#[test]
fn write_floor_defers_to_classifier_unless_other_floors() {
let state = PermissionState::default();
let write = evaluate_bash("printf 'done\\n' >> progress.md", &state, true);
assert!(write.writes_real_file);
assert!(bash_request_floor_requires_prompt(Some(&write)));
assert!(bash_request_floor_defers_to_classifier(Some(&write)));
// `rm` operands are real-file writes, but a dangerous command keeps
// the hard prompt floor (never rides the classifier).
let dangerous = evaluate_bash("rm -rf /", &state, true);
assert!(dangerous.writes_real_file);
assert!(bash_request_floor_requires_prompt(Some(&dangerous)));
assert!(!bash_request_floor_defers_to_classifier(Some(&dangerous)));
let injection = evaluate_bash("LD_PRELOAD=/tmp/e.so cat payload > out", &state, true);
assert_eq!(injection.env_risk, EnvRisk::Injection);
assert!(!bash_request_floor_defers_to_classifier(Some(&injection)));
let opaque = evaluate_bash("bash -c 'echo hi' > out", &state, true);
assert!(opaque.has_opaque_shell);
assert!(!bash_request_floor_defers_to_classifier(Some(&opaque)));
let exec = evaluate_bash("git -c core.fsmonitor=/x status > out", &state, true);
assert!(exec.exec_risk);
assert!(!bash_request_floor_defers_to_classifier(Some(&exec)));
}
#[test]
fn opaque_shell_floor_and_exact_grant() {
let cmd = "bash -c 'GIT_CONFIG_COUNT=1 git status'";
@ -7297,6 +7525,38 @@ mod tests {
}
}
#[test]
fn evaluate_bash_glob_grant_matches_mid_command() {
// A pattern-editor grant (allowed_bash_globs) auto-allows the commands
// it previews as matching, and only those.
let mut state = PermissionState::default();
state
.allowed_bash_globs
.insert("gh api repos/owner/*".to_string());
match evaluate_bash_segments("gh api repos/owner/repo/pulls", &state) {
SegmentEvaluation::AutoAllow { via_session_grant } => assert!(via_session_grant),
other => panic!("expected AutoAllow, got {other:?}"),
}
match evaluate_bash_segments("gh api repos/other/repo/pulls", &state) {
SegmentEvaluation::NeedsPrompts { .. } => {}
other => panic!("expected NeedsPrompts, got {other:?}"),
}
}
#[test]
fn evaluate_literal_grant_metacharacters_are_not_wildcards() {
// A literal command grant containing shell metacharacters must NOT act
// as a glob (would silently widen the grant / regress on upgrade).
let mut state = PermissionState::default();
state
.allowed_bash_commands
.insert("find . -name *.rs".to_string());
match evaluate_bash_segments("find . -name Cargo.toml", &state) {
SegmentEvaluation::NeedsPrompts { .. } => {}
other => panic!("expected NeedsPrompts, got {other:?}"),
}
}
#[test]
fn evaluate_dangerous_segment_prompted_even_if_whitelisted() {
// Even if the user somehow whitelisted `rm`, the dangerous-check
@ -8171,7 +8431,7 @@ mod tests {
.send(PermissionCommand::Request {
access: access(),
tool_call_update: tool_call(),
edit_path_context: None,
path_context: None,
respond_to,
session_id: None,
subagent_type: None,

View file

@ -36,7 +36,7 @@ pub use manager::{
PermissionHandle, default_always_allow_scope, spawn_permission_manager,
spawn_permission_manager_with_hub,
};
pub use policy::CompiledPolicy;
pub use policy::{CompiledPolicy, bash_pattern_is_broad, bash_pattern_matches_command};
pub use prompter::{
ALLOW_EDITS_SESSION_OPTION_ID, AcpPrompter, BashCommandPermission, BashCommandSelectedTerms,
ENABLE_ALWAYS_APPROVE_OPTION_ID, MCP_TOOL_NAME_DELIMITER, McpScopeSelection, McpToolPermission,

View file

@ -1,3 +1,5 @@
use std::path::{Component, Path, PathBuf};
use crate::permission::bash_command_splitting::{
MAX_INLINE_SHELL_DEPTH, all_commands_from_script, env_split_string_script,
normalize_command_words,
@ -5,6 +7,7 @@ use crate::permission::bash_command_splitting::{
use crate::permission::types::{
AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
use xai_grok_paths::normalize_lexically;
use xai_grok_tools::implementations::grok_build::web_fetch::domain::normalize_domain;
/// A security-gate escalation with `Ask` provenance. The bash-command and
@ -218,7 +221,16 @@ impl CompiledPolicy {
}
/// Evaluate using deny > ask > allow precedence (order-independent).
///
/// Path rules use lexical collapse only (no session cwd). Prefer
/// [`Self::evaluate_with_cwd`] for Read/Edit/Grep when a workspace cwd is known.
pub fn evaluate(&self, access: &AccessKind) -> Option<Decision> {
self.evaluate_with_cwd(access, None)
}
/// Like [`Self::evaluate`], cwd-joining relative tool paths before the
/// path-glob match.
pub fn evaluate_with_cwd(&self, access: &AccessKind, cwd: Option<&Path>) -> Option<Decision> {
let mut matched_ask = false;
let mut matched_allow = false;
@ -230,7 +242,7 @@ impl CompiledPolicy {
rule,
matcher: matcher.as_ref(),
};
if !pattern_matches(access, &cr) {
if !pattern_matches(access, &cr, cwd) {
continue;
}
match rule.action {
@ -517,26 +529,75 @@ fn matches_command_prefix(cmd: &str, pattern: &str) -> bool {
cmd == pattern || (cmd.starts_with(pattern) && cmd.as_bytes().get(pattern.len()) == Some(&b' '))
}
/// Shared bash allow match: word-boundary prefix OR freeform glob.
///
/// Used by config `[permission]` rules, session `allowed_bash_globs`, and the
/// pattern-editor live preview so the three paths cannot drift. `precompiled`
/// is the matcher from [`CompiledPolicy`] when available; otherwise the
/// pattern is compiled on the fly (session grants / preview).
fn bash_command_matches_pattern(
command: &str,
pattern: &str,
precompiled: Option<&glob::Pattern>,
) -> bool {
let command = command.trim_start();
let pattern = pattern.trim();
if pattern.is_empty() {
return false;
}
if pattern == "*" {
return true;
}
if matches_command_prefix(command, pattern) {
return true;
}
match precompiled {
Some(p) => glob_matches(command, MatchContext::Freeform, Some(p)),
None => match glob::Pattern::new(pattern) {
Ok(p) => glob_matches(command, MatchContext::Freeform, Some(&p)),
Err(_) => false,
},
}
}
fn bash_allow_pattern_matches(
cmd: &str,
rule: &PermissionRule,
matcher: Option<&glob::Pattern>,
) -> bool {
let cmd = cmd.trim_start();
match rule.pattern.as_deref() {
// No pattern (tool-filter only) or `*` → unrestricted for this rule.
None | Some("*") => true,
Some(pattern) => {
matches_command_prefix(cmd, pattern)
|| glob_matches(cmd, MatchContext::Freeform, matcher)
}
Some(pattern) => bash_command_matches_pattern(cmd, pattern, matcher),
}
}
fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>) -> bool {
/// Would a `Bash(pattern)` allow rule match `command`?
///
/// Same semantics as config `[permission]` bash allow rules and session glob
/// grants: word-boundary prefix or freeform glob. `*` matches everything;
/// blank after trim matches nothing.
pub fn bash_pattern_matches_command(pattern: &str, command: &str) -> bool {
bash_command_matches_pattern(command, pattern, None)
}
/// Whether a pattern grants an unscoped range of commands, for the editor's
/// non-blocking "very broad" warning: a bare `*`, or a single token with no
/// argument boundary (`gh`, `gh*`) that covers every invocation of a program.
pub fn bash_pattern_is_broad(pattern: &str) -> bool {
let pattern = pattern.trim();
if pattern.is_empty() {
return false;
}
pattern == "*" || !pattern.contains(char::is_whitespace)
}
fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>, cwd: Option<&Path>) -> bool {
let pattern = match cr.rule.pattern.as_deref() {
Some(p) => p,
None => return true,
};
// Intentional tool-wide open: matches regardless of path spelling.
if pattern == "*" {
return true;
}
@ -548,13 +609,13 @@ fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>) -> bool {
let cmd = cmd.trim_start();
cmd.starts_with(pattern) || glob_matches(cmd, MatchContext::Freeform, cr.matcher)
}
AccessKind::Edit(path) => glob_matches(path, MatchContext::Path, cr.matcher),
AccessKind::Edit(path) => path_context_matches(path, cr, cwd),
AccessKind::Read(path) => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
Some(p) => path_context_matches(p, cr, cwd),
None => false,
},
AccessKind::Grep { path, .. } => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
Some(p) => path_context_matches(p, cr, cwd),
None => false,
},
AccessKind::MCPTool { name, .. } => glob_matches(name, MatchContext::Freeform, cr.matcher),
@ -568,6 +629,81 @@ fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>) -> bool {
}
}
/// Match Read/Edit/Grep after lexical normalize (+ cwd-join). Rooted patterns
/// are self-containing: `..` never survives normalization, and the
/// cwd-relative spellings are generated only for paths genuinely under the
/// cwd, so `Read(./**)` / `Read(src/**)` cannot be escaped via traversal.
/// Unrooted patterns (`*`, leading `**`) keep their documented any-depth
/// meaning.
fn path_context_matches(path: &str, cr: &CompiledRule<'_>, cwd: Option<&Path>) -> bool {
path_match_forms(path, cwd)
.iter()
.any(|text| glob_matches(text, MatchContext::Path, cr.matcher))
}
/// Normalized absolute form, plus cwd-relative and `./`-prefixed spellings when
/// the path stays under cwd (so `Read(./**)` matches bare `src/main.rs`).
/// Normalization never leaves `.`/`..` in the forms, so a relative spelling is
/// produced only for paths genuinely under the cwd. Tilde paths are matched
/// literally only (see [`is_tilde_path`]).
fn path_match_forms(path: &str, cwd: Option<&Path>) -> Vec<String> {
let abs = absolute_normalized_path(path, cwd);
let mut forms = vec![path_match_string(&abs)];
if let Some(cwd) = cwd {
if let Ok(rel) = abs.strip_prefix(normalize_lexically(cwd)) {
let rel_s = path_match_string(rel);
if rel_s.is_empty() || rel_s == "." {
forms.extend([".".to_owned(), "./".to_owned()]);
} else {
forms.push(format!("./{rel_s}"));
forms.push(rel_s);
}
}
} else if abs.is_relative() && !path_has_parent_dir(&abs) && !is_tilde_path(&abs) {
// No session cwd: still offer `./form` so `./**` matches bare relatives.
let lex_s = path_match_string(&abs);
if lex_s != "." && !lex_s.is_empty() {
forms.push(format!("./{lex_s}"));
}
}
forms
}
fn absolute_normalized_path(path: &str, cwd: Option<&Path>) -> PathBuf {
let raw = Path::new(path);
if is_tilde_path(raw) {
// Kept raw: no cwd-join, and no collapse either — `~/../x` collapsing
// to `x` would mint a false workspace-relative identity.
return raw.to_path_buf();
}
let joined = match cwd {
Some(cwd) if !raw.is_absolute() => cwd.join(raw),
_ => raw.to_path_buf(),
};
normalize_lexically(&joined)
}
/// A leading `~` component is expanded to the home directory by the tools
/// (`resolve_model_path`) *after* this gate runs, so such a path must never be
/// treated as cwd-relative: a manufactured `./~/…` spelling would satisfy
/// workspace allows like `./**` while the tool escapes to the real home.
/// Tilde paths are matched literally instead, exactly as patterns treat `~`.
fn is_tilde_path(path: &Path) -> bool {
matches!(
path.components().next(),
Some(Component::Normal(first)) if first.to_string_lossy().starts_with('~')
)
}
fn path_match_string(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn path_has_parent_dir(path: &Path) -> bool {
path.components().any(|c| matches!(c, Component::ParentDir))
}
fn domain_matches(pattern: &str, url: &str) -> bool {
let parsed = match url::Url::parse(url) {
Ok(u) => u,
@ -643,7 +779,7 @@ pub(crate) fn rule_is_catchall(rule: &PermissionRule) -> bool {
rule,
matcher: matcher.as_ref(),
};
let opens_all = |probes: Vec<AccessKind>| probes.iter().all(|a| pattern_matches(a, &cr));
let opens_all = |probes: Vec<AccessKind>| probes.iter().all(|a| pattern_matches(a, &cr, None));
match rule.tool {
ToolFilter::Bash => opens_all(bash_probes()),
ToolFilter::Mcp => opens_all(mcp_probes()),
@ -681,12 +817,16 @@ mod tests {
}
fn matches(access: &AccessKind, rule: &PermissionRule) -> bool {
matches_at(access, rule, None)
}
fn matches_at(access: &AccessKind, rule: &PermissionRule, cwd: Option<&Path>) -> bool {
let policy = CompiledPolicy::new(PermissionConfig::new(vec![rule.clone()]));
let cr = CompiledRule {
rule: &policy.config.rules[0],
matcher: policy.matchers[0].as_ref(),
};
pattern_matches(access, &cr)
pattern_matches(access, &cr, cwd)
}
#[test]
@ -697,6 +837,34 @@ mod tests {
assert!(!matches(&access, &rule_for("cargo*")));
}
#[test]
fn bash_pattern_preview_matches_the_real_evaluator() {
let cmd = "gh api repos/owner/repo/pulls/42 --method PATCH";
// Word-boundary prefixes (the arrow-scope forms) and mid-command globs.
assert!(bash_pattern_matches_command("gh", cmd));
assert!(bash_pattern_matches_command("gh api repos/owner/*", cmd));
assert!(bash_pattern_matches_command("gh api * --method PATCH", cmd));
assert!(!bash_pattern_matches_command("gh api repos/other/*", cmd));
// `gh` must not match `ghostscript`.
assert!(!bash_pattern_matches_command("gh", "ghostscript -h"));
// `*` matches everything; empty/blank never does; leading command
// whitespace can't dodge the match.
assert!(bash_pattern_matches_command("*", cmd));
assert!(!bash_pattern_matches_command("", cmd));
assert!(!bash_pattern_matches_command(" ", cmd));
assert!(bash_pattern_matches_command("gh api", " gh api foo"));
}
#[test]
fn bash_pattern_broadness_flags_only_unscoped_grants() {
assert!(bash_pattern_is_broad("*"));
assert!(bash_pattern_is_broad("gh"));
assert!(bash_pattern_is_broad("gh*"));
assert!(!bash_pattern_is_broad("gh api"));
assert!(!bash_pattern_is_broad("gh api repos/owner/*"));
assert!(!bash_pattern_is_broad(""));
}
#[test]
fn rule_is_catchall_shares_the_evaluator() {
let rule = |tool: ToolFilter, pattern: Option<&str>, mode: PatternMode| PermissionRule {
@ -1485,4 +1653,261 @@ mod tests {
.is_none()
);
}
// ── path normalize before glob match (GBT-4940) ────────────────────────
fn read_allow(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Read,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
fn read_deny(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Read,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
fn eval_read_at(path: &str, rule: &PermissionRule, cwd: &Path) -> Option<Decision> {
CompiledPolicy::new(PermissionConfig::new(vec![rule.clone()]))
.evaluate_with_cwd(&AccessKind::Read(Some(path.into())), Some(cwd))
}
#[test]
fn allow_dot_star_denies_traversal_escapes_and_allows_bare_relatives() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("./**");
for path in [
"src/main.rs",
"./src/main.rs",
"src/./nested/../main.rs",
"/workspace/project/src/main.rs",
] {
assert!(
matches!(eval_read_at(path, &rule, cwd), Some(Decision::Allow)),
"expected allow for {path}"
);
}
for path in [
"../../etc/passwd",
"./../../etc/passwd",
"/etc/passwd",
"/workspace/other/file.rs",
] {
assert!(
eval_read_at(path, &rule, cwd).is_none(),
"expected no allow match for traversal/escape {path}"
);
}
}
#[test]
fn allow_src_star_denies_escape_via_parent_segments() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("src/**");
assert!(matches!(
eval_read_at("src/main.rs", &rule, cwd),
Some(Decision::Allow)
));
assert!(matches!(
eval_read_at("./src/lib.rs", &rule, cwd),
Some(Decision::Allow)
));
// `**` would otherwise consume `..`; normalization erases it first, so
// the escaped path no longer carries the `src/` prefix the glob needs.
assert!(eval_read_at("src/../../etc/passwd", &rule, cwd).is_none());
assert!(eval_read_at("src/../secrets/token", &rule, cwd).is_none());
assert!(eval_read_at("other/main.rs", &rule, cwd).is_none());
}
#[test]
fn deny_env_still_matches_after_normalize() {
let cwd = Path::new("/workspace/project");
let rule = read_deny("**/.env");
assert!(matches!(
eval_read_at(".env", &rule, cwd),
Some(Decision::Reject(_))
));
assert!(matches!(
eval_read_at("foo/../.env", &rule, cwd),
Some(Decision::Reject(_))
));
assert!(matches!(
eval_read_at("./config/../.env", &rule, cwd),
Some(Decision::Reject(_))
));
assert!(eval_read_at("src/main.rs", &rule, cwd).is_none());
}
#[test]
fn allow_star_remains_full_filesystem_open() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("*");
for path in ["/etc/passwd", "../../etc/passwd", "src/main.rs"] {
assert!(
matches!(eval_read_at(path, &rule, cwd), Some(Decision::Allow)),
"pattern=* must allow {path}"
);
}
}
#[test]
fn allow_dot_star_without_cwd_still_blocks_relative_traversal() {
// Lexical collapse alone drops `./../../…` away from `./**`.
let rule = read_allow("./**");
assert!(matches_at(
&AccessKind::Read(Some("src/main.rs".into())),
&rule,
None
));
assert!(!matches_at(
&AccessKind::Read(Some("./../../etc/passwd".into())),
&rule,
None
));
assert!(!matches_at(
&AccessKind::Read(Some("../../etc/passwd".into())),
&rule,
None
));
}
#[test]
fn edit_and_grep_use_same_path_normalize() {
let cwd = Path::new("/workspace/project");
let edit_allow = PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Edit,
pattern: Some("./**".into()),
pattern_mode: PatternMode::Glob,
};
let policy = CompiledPolicy::new(PermissionConfig::new(vec![edit_allow]));
assert!(matches!(
policy.evaluate_with_cwd(&AccessKind::Edit("src/main.rs".into()), Some(cwd)),
Some(Decision::Allow)
));
assert!(
policy
.evaluate_with_cwd(&AccessKind::Edit("./../../etc/passwd".into()), Some(cwd))
.is_none()
);
let grep_deny = PermissionConfig::new(vec![read_deny("**/.env")]);
let policy = CompiledPolicy::new(grep_deny);
assert!(matches!(
policy.evaluate_with_cwd(
&AccessKind::Grep {
path: Some("foo/../.env".into()),
glob: None,
},
Some(cwd),
),
Some(Decision::Reject(_))
));
}
#[test]
fn allow_mid_segment_wildcard_matches_after_normalize() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("src/ma*");
// A wildcard mid-segment must not break matching of the normalized
// relative spellings.
for path in ["src/main.rs", "./src/matrix.rs"] {
assert!(
matches!(eval_read_at(path, &rule, cwd), Some(Decision::Allow)),
"expected allow for {path}"
);
}
assert!(eval_read_at("src/nested/main.rs", &rule, cwd).is_none());
assert!(eval_read_at("src/../marker", &rule, cwd).is_none());
}
#[test]
fn allow_absolute_root_pattern_spans_filesystem() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("/**");
// `/**` is rooted at `/`, not silently narrowed to the cwd.
for path in ["/etc/passwd", "src/main.rs", "../other/file.rs"] {
assert!(
matches!(eval_read_at(path, &rule, cwd), Some(Decision::Allow)),
"expected allow for {path}"
);
}
}
#[test]
fn allow_exact_file_pattern_matches_all_spellings() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("Cargo.toml");
for path in [
"Cargo.toml",
"./Cargo.toml",
"/workspace/project/Cargo.toml",
] {
assert!(
matches!(eval_read_at(path, &rule, cwd), Some(Decision::Allow)),
"expected allow for {path}"
);
}
assert!(eval_read_at("sub/Cargo.toml", &rule, cwd).is_none());
assert!(eval_read_at("Cargo.toml/../secrets", &rule, cwd).is_none());
}
#[test]
fn tilde_paths_never_match_workspace_allows() {
let cwd = Path::new("/workspace/project");
// Tools expand a leading `~` to the real home AFTER this gate runs, so
// a tilde path must never gain cwd-relative spellings (`./~/…` would
// satisfy `./**` while the read escapes the workspace).
let rule = read_allow("./**");
for path in ["~/secrets/key.pem", "~", "~other/refs"] {
assert!(
eval_read_at(path, &rule, cwd).is_none(),
"expected no allow match for tilde path {path}"
);
}
assert!(!matches_at(
&AccessKind::Read(Some("~/secrets/key.pem".into())),
&rule,
None
));
// Collapse must not erase the tilde: `~/../key.pem` is not the
// workspace file `key.pem`.
let pem = read_allow("*.pem");
assert!(eval_read_at("~/../key.pem", &pem, cwd).is_none());
// Literal `~` patterns still key on tilde spellings.
let deny = read_deny("~/**");
assert!(matches!(
eval_read_at("~/secrets/key.pem", &deny, cwd),
Some(Decision::Reject(_))
));
}
#[test]
fn allow_single_star_stays_inside_pattern_directory() {
let cwd = Path::new("/workspace/project");
let rule = read_allow("docs/*.md");
assert!(matches!(
eval_read_at("docs/readme.md", &rule, cwd),
Some(Decision::Allow)
));
assert!(eval_read_at("docs/sub/deep.md", &rule, cwd).is_none());
assert!(eval_read_at("docs/../escape.md", &rule, cwd).is_none());
}
}

View file

@ -155,6 +155,10 @@ pub struct BashCommandPermission {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BashCommandSelectedTerms {
pub command_parts: Vec<String>,
/// The user authored a free-form glob pattern (pattern editor) rather than
/// a literal word-scope selection, so it must be matched with glob semantics.
#[serde(default)]
pub is_glob: bool,
}
/// Delimiter used to qualify MCP tool names as `"<server>__<tool>"`.
@ -287,6 +291,9 @@ pub enum PromptOutcome {
/// Matches the UX of "Yes, allow all edits during this session".
AllowEditsForSession,
AllowAlwaysBashCommand(String),
/// A free-form glob pattern authored in the "Always allow" editor. Matched
/// with glob semantics (unlike the literal-prefix [`Self::AllowAlwaysBashCommand`]).
AllowAlwaysBashGlob(String),
AllowAlwaysDomain(String),
/// Persist this exact MCP tool name in `allowed_mcp_tools`.
AllowAlwaysMcpTool(String),
@ -851,6 +858,7 @@ fn permission_decision_for_outcome(outcome: &PromptOutcome) -> PermissionDecisio
| PromptOutcome::AllowAlways
| PromptOutcome::AllowEditsForSession
| PromptOutcome::AllowAlwaysBashCommand(_)
| PromptOutcome::AllowAlwaysBashGlob(_)
| PromptOutcome::AllowAlwaysDomain(_)
| PromptOutcome::AllowAlwaysMcpTool(_)
| PromptOutcome::AllowAlwaysMcpServer(_) => PermissionDecision::Allow,
@ -934,9 +942,12 @@ fn map_selected_outcome(
)
.ok()
}) {
PromptOutcome::AllowAlwaysBashCommand(
bash_selected_commands.command_parts.join(" "),
)
let pattern = bash_selected_commands.command_parts.join(" ");
if bash_selected_commands.is_glob {
PromptOutcome::AllowAlwaysBashGlob(pattern)
} else {
PromptOutcome::AllowAlwaysBashCommand(pattern)
}
} else if let AccessKind::Bash(cmd) = access {
// No interactive selection meta (e.g. desktop client).
// Compute the primary command from the script.
@ -1135,6 +1146,7 @@ mod tests {
// BashCommandSelectedTerms meta and wins over the raw script.
let meta = serde_json::to_value(BashCommandSelectedTerms {
command_parts: vec!["cargo".to_owned(), "test".to_owned()],
is_glob: false,
})
.unwrap()
.as_object()

View file

@ -194,8 +194,13 @@ impl CompiledPolicy {
) -> Option<GateDecision> {
let path = normalize_shell_path(token);
let is_absolute = is_absolute_shell_path(&path);
// Cwd-aware rule match mirrors the direct Read/Edit tool gate, so a
// rooted rule like `Read(src/**)` also keys on the same file spelled
// absolutely. An unpinned cwd anchors nothing: relative operands then
// keep text-only matching (absolute operands are cwd-independent).
let rule_cwd = (is_absolute || !cwd_unpinned).then_some(cwd);
// Escalate only: drop Allow so a file allow-rule can't auto-approve here.
let escalate = |access: &AccessKind| match self.evaluate(access) {
let escalate = |access: &AccessKind| match self.evaluate_with_cwd(access, rule_cwd) {
Some(Decision::Reject(reason)) => Some(GateDecision::Reject(reason)),
Some(Decision::Ask) => Some(GateDecision::AskRuleMatch),
_ => None,
@ -1409,6 +1414,32 @@ mod tests {
));
}
#[test]
fn shell_gate_matches_cwd_relative_rules_on_absolute_operands() {
let deny = compiled(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"src/**",
)]);
// A rooted relative rule keys on the same file spelled absolutely,
// matching the direct Read tool gate (which evaluates with the cwd).
assert!(matches!(
deny.evaluate_shell_file_access_gate("cat /work/src/secret.txt", cwd()),
Some(GateDecision::Reject(_))
));
// An absolute operand is cwd-independent, so it stays covered even
// after a `cd` unpins the working directory.
assert!(matches!(
deny.evaluate_shell_file_access_gate("cd /tmp && cat /work/src/secret.txt", cwd()),
Some(GateDecision::Reject(_))
));
// Outside the working directory the rooted rule stays silent.
assert_eq!(
deny.evaluate_shell_file_access_gate("cat /elsewhere/src/secret.txt", cwd()),
None
);
}
#[test]
fn sensitive_edit_targets_and_lexical_aliases_prompt() {
for path in [

View file

@ -15,6 +15,11 @@ pub struct PermissionState {
pub allow_bash_execute: bool,
pub allowed_bash_commands: HashSet<String>,
pub disallowed_bash_commands: HashSet<String>,
/// Glob patterns the user authored via the "Always allow" pattern editor
/// (e.g. `gh api repos/owner/*`). Matched with glob semantics, unlike the
/// literal-prefix [`Self::allowed_bash_commands`]; kept separate so a command
/// grant that happens to contain shell metacharacters is never a wildcard.
pub allowed_bash_globs: HashSet<String>,
/// Domains the user has approved for `web_fetch`
/// during this session.
pub allowed_web_fetch_domains: HashSet<String>,
@ -56,6 +61,7 @@ impl Default for PermissionState {
allow_bash_execute: false,
allowed_bash_commands: HashSet::new(),
disallowed_bash_commands: HashSet::new(),
allowed_bash_globs: HashSet::new(),
allowed_web_fetch_domains: HashSet::new(),
allowed_mcp_tools: HashSet::new(),
allowed_mcp_servers: HashSet::new(),

View file

@ -229,8 +229,12 @@ impl<'de> Deserialize<'de> for EditPolicy {
deserializer.deserialize_str(V)
}
}
/// The requesting session's execution cwd for one permission request. Shared
/// parent/subagent managers serve sessions whose cwd differs from the
/// manager's, so path rules and edit-target resolution must anchor to where
/// the requesting tool actually resolves paths, not where the manager lives.
#[derive(Debug, Clone)]
pub struct EditPathContext {
pub struct RequestPathContext {
pub real_cwd: std::path::PathBuf,
pub display_cwd: Option<std::path::PathBuf>,
}
@ -239,7 +243,7 @@ pub enum PermissionCommand {
Request {
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
edit_path_context: Option<EditPathContext>,
path_context: Option<RequestPathContext>,
respond_to: oneshot::Sender<Decision>,
/// Session ID originating this request. Used to attribute
/// permission events to child subagents.