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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue