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:
parent
a422116582
commit
780d1388ff
323 changed files with 12258 additions and 7226 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue