Synced from monorepo
Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -7409,7 +7409,7 @@ pub(crate) mod tests {
|
|||
let spec = xai_grok_hooks::config::HookSpec {
|
||||
name: "injected".into(),
|
||||
event: xai_grok_hooks::event::HookEventName::SessionStart,
|
||||
handler_type: "command".into(),
|
||||
handler_type: xai_grok_hooks::config::HandlerType::Command,
|
||||
configured_matcher: None,
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
//! Auto permission mode: LLM transcript classifier with safe fast-paths.
|
||||
//!
|
||||
//! Port of common agent auto-permission classifier semantics adapted to Grok's
|
||||
//! `AccessKind` permission gate (classifier blocks prompt the user; upstream
|
||||
//! denial-limit tracking is intentionally not ported).
|
||||
//! `AccessKind` permission gate.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
|
@ -24,12 +23,25 @@ use super::types::AccessKind;
|
|||
pub enum ClassifierVerdict {
|
||||
/// Safe to run without user prompt.
|
||||
Allow,
|
||||
/// Blocked by classifier; the user is prompted to decide.
|
||||
Block,
|
||||
/// Classifier unavailable (API error / no client); treated as a block (prompt).
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClassifierOutcome {
|
||||
pub verdict: ClassifierVerdict,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ClassifierVerdict> for ClassifierOutcome {
|
||||
fn from(verdict: ClassifierVerdict) -> Self {
|
||||
Self {
|
||||
verdict,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Role of a single classifier request message (transport-agnostic; the shell
|
||||
/// crate maps these onto sampling-types so this crate stays decoupled).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -138,7 +150,7 @@ pub trait PermissionClassifier: Send + Sync {
|
|||
access: &'a AccessKind,
|
||||
access_detail: Option<&'a str>,
|
||||
context: ClassifierContext,
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierVerdict> + Send + 'a>>;
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierOutcome> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Fixed-verdict classifier for tests and headless fallbacks.
|
||||
|
|
@ -152,16 +164,14 @@ impl PermissionClassifier for FixedClassifier {
|
|||
_access: &'a AccessKind,
|
||||
_access_detail: Option<&'a str>,
|
||||
_context: ClassifierContext,
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierVerdict> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierOutcome> + Send + 'a>> {
|
||||
let v = self.0;
|
||||
Box::pin(async move { v })
|
||||
Box::pin(async move { v.into() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Production default classifier: rule-based transcript-style risk assessment
|
||||
/// without a network call. Blocks known-dangerous patterns; allows routine
|
||||
/// dev commands; **unknown bash defaults to Block** (which prompts the user)
|
||||
/// so auto is not silent always-approve. A live LLM can
|
||||
/// replace this via `set_classifier` and use full transcript context.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct HeuristicPermissionClassifier;
|
||||
|
|
@ -410,7 +420,7 @@ fn classify_bash(cmd: &str) -> ClassifierVerdict {
|
|||
// (or any `env` option) can change which binary runs / how code resolves.
|
||||
// Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't
|
||||
// hide the key.
|
||||
if script_sets_unsafe_env(tree.root_node(), cmd, &cmds) {
|
||||
if script_env_risk(tree.root_node(), cmd, &cmds) != EnvRisk::Safe {
|
||||
return ClassifierVerdict::Block;
|
||||
}
|
||||
// A routine command can still write an arbitrary destination via a redirect
|
||||
|
|
@ -559,7 +569,7 @@ fn package_manager_subcommand_is_routine(prog: &str, inner: &[String]) -> Option
|
|||
LaunchTarget::Unresolved => return Some(false),
|
||||
LaunchTarget::Inner(launched) => {
|
||||
return Some(
|
||||
!command_env_is_unsafe(launched)
|
||||
command_env_risk(launched) == EnvRisk::Safe
|
||||
&& !launched_writes_nonsink(launched)
|
||||
&& bash_command_is_routine(launched),
|
||||
);
|
||||
|
|
@ -742,17 +752,52 @@ fn explicit_launch_target<'a>(head: &str, inner: &'a [String]) -> LaunchTarget<'
|
|||
}
|
||||
}
|
||||
|
||||
/// Default-deny env guard. True (→ Block) if the command assigns any env var
|
||||
/// whose KEY is not in [`SAFE_ENV_KEYS`], or passes an option to `env` (which can
|
||||
/// run a string, clear, or unset the environment). Reads the PARSED tree so
|
||||
/// quoting (`env "LD_PRELOAD=..."`) can't hide a key from the check.
|
||||
pub(crate) fn script_sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> bool {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum EnvRisk {
|
||||
Safe,
|
||||
Unvetted,
|
||||
Injection,
|
||||
}
|
||||
|
||||
const INJECTION_ENV_KEYS: &[&str] = &[
|
||||
"LD_PRELOAD",
|
||||
"LD_AUDIT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"IFS",
|
||||
"PATH",
|
||||
"GIT_EXTERNAL_DIFF",
|
||||
"GIT_PROXY_COMMAND",
|
||||
"PROMPT_COMMAND",
|
||||
];
|
||||
|
||||
const INJECTION_ENV_KEY_PREFIXES: &[&str] = &["DYLD_", "GIT_CONFIG"];
|
||||
|
||||
fn env_key_risk(key: &str) -> EnvRisk {
|
||||
if is_safe_env_key(key) {
|
||||
EnvRisk::Safe
|
||||
} else if INJECTION_ENV_KEYS.contains(&key)
|
||||
|| INJECTION_ENV_KEY_PREFIXES
|
||||
.iter()
|
||||
.any(|p| key.starts_with(p))
|
||||
{
|
||||
EnvRisk::Injection
|
||||
} else {
|
||||
EnvRisk::Unvetted
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest [`EnvRisk`] across the script's env assignments (inline `KEY=val`
|
||||
/// and `env`-form). Reads the PARSED tree so quoting (`env "LD_PRELOAD=..."`)
|
||||
/// can't hide a key.
|
||||
pub(crate) fn script_env_risk(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> EnvRisk {
|
||||
let mut risk = EnvRisk::Safe;
|
||||
// (a) Inline `KEY=val cmd` assignments are `variable_assignment` nodes
|
||||
// (stripped from PlainCommand words), so walk the tree for them.
|
||||
let mut stack = vec![root];
|
||||
while let Some(node) = stack.pop() {
|
||||
if node.kind() == "variable_assignment" && !is_safe_env_key(assignment_key(node, src)) {
|
||||
return true;
|
||||
if node.kind() == "variable_assignment" {
|
||||
risk = risk.max(env_key_risk(assignment_key(node, src)));
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
|
|
@ -761,29 +806,29 @@ pub(crate) fn script_sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCom
|
|||
}
|
||||
// (b) `env`-form assignments/options, even behind other wrappers
|
||||
// (e.g. `timeout 5 env LD_PRELOAD=...`).
|
||||
cmds.iter().any(|c| command_env_is_unsafe(c.words()))
|
||||
cmds.iter()
|
||||
.fold(risk, |risk, c| risk.max(command_env_risk(c.words())))
|
||||
}
|
||||
|
||||
/// Walk a command's wrapper chain; for each `env` invocation treat any option
|
||||
/// flag (`-S`/`-i`/`-u`/`-C`/...) or an assignment KEY outside [`SAFE_ENV_KEYS`]
|
||||
/// as exec-affecting → unsafe. Covers nested wrappers like `timeout 5 env ...`.
|
||||
fn command_env_is_unsafe(words: &[String]) -> bool {
|
||||
fn command_env_risk(words: &[String]) -> EnvRisk {
|
||||
let mut risk = EnvRisk::Safe;
|
||||
let mut current = words;
|
||||
for _ in 0..8 {
|
||||
if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env") {
|
||||
let mut options_done = false;
|
||||
for arg in ¤t[1..] {
|
||||
if arg == "--" {
|
||||
break; // end of env options; the rest is the command
|
||||
options_done = true;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
return true; // env option alters/clears the exec environment
|
||||
if !options_done && arg.starts_with('-') {
|
||||
return EnvRisk::Injection;
|
||||
}
|
||||
match arg.split_once('=') {
|
||||
Some((key, _)) => {
|
||||
if !is_safe_env_key(key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some((key, _)) => risk = risk.max(env_key_risk(key)),
|
||||
None => break, // first plain word is the inner command
|
||||
}
|
||||
}
|
||||
|
|
@ -793,7 +838,7 @@ fn command_env_is_unsafe(words: &[String]) -> bool {
|
|||
None => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
risk
|
||||
}
|
||||
|
||||
/// The variable name assigned by a `variable_assignment` node — its
|
||||
|
|
@ -841,9 +886,9 @@ impl PermissionClassifier for HeuristicPermissionClassifier {
|
|||
access: &'a AccessKind,
|
||||
access_detail: Option<&'a str>,
|
||||
context: ClassifierContext,
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierVerdict> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierOutcome> + Send + 'a>> {
|
||||
let v = Self::classify_sync(tool_name, access, access_detail, &context);
|
||||
Box::pin(async move { v })
|
||||
Box::pin(async move { v.into() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1115,26 +1160,39 @@ pub fn build_classifier_messages(
|
|||
|
||||
/// Parse model JSON / text into a verdict (`shouldBlock` mapping).
|
||||
pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict {
|
||||
parse_classifier_model_output(text).verdict
|
||||
}
|
||||
|
||||
pub const CLASSIFIER_REASON_MAX_LEN: usize = 400;
|
||||
|
||||
fn classifier_reason(v: &serde_json::Value) -> Option<String> {
|
||||
v.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|r| r.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(|r| xai_grok_tools::util::truncate_line(&r, CLASSIFIER_REASON_MAX_LEN).into_owned())
|
||||
}
|
||||
|
||||
pub fn parse_classifier_model_output(text: &str) -> ClassifierOutcome {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return ClassifierVerdict::Unavailable;
|
||||
return ClassifierVerdict::Unavailable.into();
|
||||
}
|
||||
// Prefer JSON object with shouldBlock
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if let Some(b) = v.get("shouldBlock").and_then(|x| x.as_bool()) {
|
||||
return if b {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed)
|
||||
&& let Some(b) = v
|
||||
.get("shouldBlock")
|
||||
.or_else(|| v.get("should_block"))
|
||||
.and_then(|x| x.as_bool())
|
||||
{
|
||||
return ClassifierOutcome {
|
||||
verdict: if b {
|
||||
ClassifierVerdict::Block
|
||||
} else {
|
||||
ClassifierVerdict::Allow
|
||||
};
|
||||
}
|
||||
if let Some(b) = v.get("should_block").and_then(|x| x.as_bool()) {
|
||||
return if b {
|
||||
ClassifierVerdict::Block
|
||||
} else {
|
||||
ClassifierVerdict::Allow
|
||||
};
|
||||
}
|
||||
},
|
||||
reason: classifier_reason(&v),
|
||||
};
|
||||
}
|
||||
// Fenced or embedded JSON
|
||||
if let Some(start) = trimmed.find('{')
|
||||
|
|
@ -1146,15 +1204,18 @@ pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict {
|
|||
.or_else(|| v.get("should_block"))
|
||||
.and_then(|x| x.as_bool())
|
||||
{
|
||||
return if b {
|
||||
ClassifierVerdict::Block
|
||||
} else {
|
||||
ClassifierVerdict::Allow
|
||||
return ClassifierOutcome {
|
||||
verdict: if b {
|
||||
ClassifierVerdict::Block
|
||||
} else {
|
||||
ClassifierVerdict::Allow
|
||||
},
|
||||
reason: classifier_reason(&v),
|
||||
};
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.contains("\"shouldblock\": true") || lower.contains("shouldblock\":true") {
|
||||
return ClassifierVerdict::Block;
|
||||
return ClassifierVerdict::Block.into();
|
||||
}
|
||||
// Deliberately do NOT infer Allow from a loose `"shouldBlock": false` substring:
|
||||
// narrative prose or multiple JSON fragments (from `rfind('}')`) can contain it
|
||||
|
|
@ -1165,9 +1226,9 @@ pub fn parse_classifier_model_text(text: &str) -> ClassifierVerdict {
|
|||
// and flips the verdict, so only honor an unambiguous one-word reply;
|
||||
// anything else is Unavailable → conservative heuristic fallback.
|
||||
match lower.trim() {
|
||||
"block" | "blocked" | "deny" | "denied" => ClassifierVerdict::Block,
|
||||
"allow" | "allowed" | "approve" | "approved" => ClassifierVerdict::Allow,
|
||||
_ => ClassifierVerdict::Unavailable,
|
||||
"block" | "blocked" | "deny" | "denied" => ClassifierVerdict::Block.into(),
|
||||
"allow" | "allowed" | "approve" | "approved" => ClassifierVerdict::Allow.into(),
|
||||
_ => ClassifierVerdict::Unavailable.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1270,7 +1331,7 @@ impl PermissionClassifier for LlmPermissionClassifier {
|
|||
access: &'a AccessKind,
|
||||
access_detail: Option<&'a str>,
|
||||
context: ClassifierContext,
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierVerdict> + Send + 'a>> {
|
||||
) -> Pin<Box<dyn Future<Output = ClassifierOutcome> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
// Deterministic pre-pass: a provable heuristic Allow skips the model
|
||||
// (no side-query latency, no false block); anything unprovable still
|
||||
|
|
@ -1282,7 +1343,7 @@ impl PermissionClassifier for LlmPermissionClassifier {
|
|||
&context,
|
||||
);
|
||||
if heuristic == ClassifierVerdict::Allow {
|
||||
return ClassifierVerdict::Allow;
|
||||
return ClassifierVerdict::Allow.into();
|
||||
}
|
||||
let messages = build_classifier_messages(
|
||||
tool_name,
|
||||
|
|
@ -1308,14 +1369,14 @@ impl PermissionClassifier for LlmPermissionClassifier {
|
|||
None
|
||||
};
|
||||
if let Some(text) = model_text {
|
||||
let v = parse_classifier_model_text(&text);
|
||||
if v != ClassifierVerdict::Unavailable {
|
||||
return v;
|
||||
let outcome = parse_classifier_model_output(&text);
|
||||
if outcome.verdict != ClassifierVerdict::Unavailable {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
// Model unavailable / unparseable: fall back to the heuristic verdict
|
||||
// computed above (non-Allow here — Allow already short-circuited).
|
||||
heuristic
|
||||
heuristic.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1415,7 +1476,8 @@ mod tests {
|
|||
Some("ls"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Allow
|
||||
);
|
||||
let block = FixedClassifier(ClassifierVerdict::Block);
|
||||
|
|
@ -1427,7 +1489,8 @@ mod tests {
|
|||
Some("rm -rf /"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Block
|
||||
);
|
||||
}
|
||||
|
|
@ -1655,6 +1718,60 @@ mod tests {
|
|||
assert_eq!(v("cargo test"), ClassifierVerdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_risk_tiers() {
|
||||
let risk = |cmd: &str| {
|
||||
let tree = try_parse_shell(cmd).expect(cmd);
|
||||
let cmds = try_parse_word_only_commands_sequence(&tree, cmd).unwrap_or_default();
|
||||
script_env_risk(tree.root_node(), cmd, &cmds)
|
||||
};
|
||||
assert_eq!(risk("RUST_LOG=debug cargo test"), EnvRisk::Safe);
|
||||
assert_eq!(risk("cargo test"), EnvRisk::Safe);
|
||||
|
||||
for cmd in [
|
||||
"GH_HOST=github.example.com gh pr view 3135",
|
||||
"FOO=bar make test",
|
||||
"out=$(gh pr view 3135); echo \"$out\"",
|
||||
"env FOO=1 cargo test",
|
||||
"GIT_SSH_COMMAND=/x git fetch",
|
||||
"SSH_ASKPASS=/x ssh host",
|
||||
"PYTHONPATH=/x python s.py",
|
||||
"NODE_OPTIONS=--require=/x npm test",
|
||||
"KUBECONFIG=/x kubectl get pods",
|
||||
"XDG_CONFIG_HOME=/x git status",
|
||||
"LD_LIBRARY_PATH=/x ./app",
|
||||
] {
|
||||
assert_eq!(risk(cmd), EnvRisk::Unvetted, "{cmd}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
risk("bash -c 'GIT_CONFIG_COUNT=1 git status'"),
|
||||
EnvRisk::Safe
|
||||
);
|
||||
assert_eq!(risk("sh -c 'echo hi'"), EnvRisk::Safe);
|
||||
|
||||
assert_eq!(
|
||||
risk("GH_HOST=x LD_PRELOAD=/x gh pr view 1"),
|
||||
EnvRisk::Injection
|
||||
);
|
||||
for cmd in [
|
||||
"LD_PRELOAD=/x cargo test",
|
||||
"env \"DYLD_INSERT_LIBRARIES=/x\" cargo test",
|
||||
"GIT_CONFIG_COUNT=1 git status",
|
||||
"PATH=/tmp cargo test",
|
||||
"BASH_ENV=/x bash -c true",
|
||||
"IFS=x sh -c cmd",
|
||||
"env -i cargo test",
|
||||
"env -S 'rm -rf ~' ls",
|
||||
"env -- LD_PRELOAD=/x cargo test",
|
||||
"GIT_EXTERNAL_DIFF=/x git diff",
|
||||
"GIT_PROXY_COMMAND=/x git fetch",
|
||||
"PROMPT_COMMAND=/x bash",
|
||||
] {
|
||||
assert_eq!(risk(cmd), EnvRisk::Injection, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `cp`/`mv` write/replace arbitrary destinations the redirect guard can't
|
||||
/// see, so they must NOT be auto-allowed (`cp evil ~/.bashrc`).
|
||||
#[test]
|
||||
|
|
@ -2187,7 +2304,8 @@ mod tests {
|
|||
Some("cargo test"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Allow
|
||||
);
|
||||
// dangerous stays blocked via heuristic
|
||||
|
|
@ -2199,7 +2317,8 @@ mod tests {
|
|||
Some("rm -rf /"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Block
|
||||
);
|
||||
|
||||
|
|
@ -2212,7 +2331,8 @@ mod tests {
|
|||
Some("cargo test"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Allow,
|
||||
"unparseable model text → heuristic allow for cargo"
|
||||
);
|
||||
|
|
@ -2236,7 +2356,8 @@ mod tests {
|
|||
Some("cargo test"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Allow
|
||||
);
|
||||
}
|
||||
|
|
@ -2259,6 +2380,7 @@ mod tests {
|
|||
ClassifierContext::default(),
|
||||
)
|
||||
.await
|
||||
.verdict
|
||||
};
|
||||
// Provably routine chains (incl. the reported `find; grep` repro) must
|
||||
// allow despite the model saying block.
|
||||
|
|
@ -2312,12 +2434,43 @@ mod tests {
|
|||
Some("cargo test"),
|
||||
ctx,
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.verdict,
|
||||
ClassifierVerdict::Block,
|
||||
"hostile transcript must reach the model, whose block stands"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classifier_outcome_threads_model_reason() {
|
||||
let block = LlmPermissionClassifier::with_fixed_model_text(
|
||||
r#"{"thinking":"t","shouldBlock":true,"reason":"pushes to a remote"}"#,
|
||||
);
|
||||
let outcome = block
|
||||
.classify(
|
||||
"run_terminal_command",
|
||||
&AccessKind::Bash("git push origin main".into()),
|
||||
Some("git push origin main"),
|
||||
ClassifierContext::default(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(outcome.verdict, ClassifierVerdict::Block);
|
||||
assert_eq!(outcome.reason.as_deref(), Some("pushes to a remote"));
|
||||
|
||||
let blank =
|
||||
parse_classifier_model_output(r#"{"thinking":"t","shouldBlock":true,"reason":" "}"#);
|
||||
assert_eq!(blank.verdict, ClassifierVerdict::Block);
|
||||
assert_eq!(blank.reason, None);
|
||||
let terse = parse_classifier_model_output("block");
|
||||
assert_eq!(terse.verdict, ClassifierVerdict::Block);
|
||||
assert_eq!(terse.reason, None);
|
||||
let fenced = parse_classifier_model_output(
|
||||
"```json\n{\"thinking\":\"t\",\"shouldBlock\":true,\"reason\":\"exfil\"}\n```",
|
||||
);
|
||||
assert_eq!(fenced.verdict, ClassifierVerdict::Block);
|
||||
assert_eq!(fenced.reason.as_deref(), Some("exfil"));
|
||||
}
|
||||
|
||||
/// The routine-prefix additions cover everyday read-only / navigation
|
||||
/// commands; their mutating siblings stay blocked (word-boundary scoping).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ use chrono::Utc;
|
|||
use tokio::sync::{mpsc, oneshot};
|
||||
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
|
||||
use crate::permission::auto_mode::script_sets_unsafe_env;
|
||||
use crate::permission::auto_mode::{EnvRisk, script_env_risk};
|
||||
use crate::permission::bash_command_splitting::{
|
||||
is_setup_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers,
|
||||
};
|
||||
use crate::permission::policy::CompiledPolicy;
|
||||
use crate::permission::policy::{CompiledPolicy, shell_dash_c_script};
|
||||
use crate::permission::prompter::{AcpPrompter, PromptOutcome};
|
||||
use crate::permission::shell_access::{
|
||||
combine_decisions, command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink,
|
||||
|
|
@ -38,6 +38,9 @@ mod reasons {
|
|||
pub const AUTO_FAST_PATH: &str = "auto_fast_path";
|
||||
pub const AUTO_CLASSIFIER_ALLOW: &str = "auto_classifier_allow";
|
||||
pub const AUTO_CLASSIFIER_BLOCK: &str = "auto_classifier_block";
|
||||
pub const AUTO_CLASSIFIER_DENY: &str = "auto_classifier_deny";
|
||||
pub const AUTO_CLASSIFIER_UNAVAILABLE: &str = "auto_classifier_unavailable";
|
||||
pub const AUTO_DENIAL_LIMIT: &str = "auto_denial_limit";
|
||||
pub const SANDBOX_AUTO: &str = "sandbox_auto";
|
||||
pub const PERSISTED_GRANT: &str = "persisted_grant";
|
||||
pub const SESSION_GRANT: &str = "session_grant";
|
||||
|
|
@ -46,9 +49,18 @@ mod reasons {
|
|||
pub const SESSION_DENY: &str = "session_deny";
|
||||
pub const PROMPT_DENY: &str = "prompt_deny";
|
||||
pub const NEEDS_USER: &str = "needs_user";
|
||||
pub const BASH_REQUEST_FLOOR: &str = "bash_request_floor";
|
||||
pub const OPAQUE_SHELL: &str = "opaque_shell";
|
||||
pub const REQUESTER_GONE: &str = "requester_gone";
|
||||
}
|
||||
|
||||
pub const AUTO_DENY_CONSECUTIVE_LIMIT: u32 = 3;
|
||||
pub const AUTO_DENY_TOTAL_LIMIT: u32 = 20;
|
||||
|
||||
const AUTO_DENY_GUIDANCE: &str = "Take a safer approach that stays within what the user asked \
|
||||
for; do not retry this exact action or attempt to work around the denial. If no safer \
|
||||
alternative exists, ask the user how to proceed.";
|
||||
|
||||
/// Canonical permission-mode string for the uploaded artifact. Matches
|
||||
/// `config.ui.permission_mode` (hyphenated) for trace-internal consistency,
|
||||
/// deliberately diverging from the telemetry enum's underscore Mixpanel serde.
|
||||
|
|
@ -364,9 +376,10 @@ pub(crate) enum SegmentEvaluation {
|
|||
struct BashEvaluation {
|
||||
segments: SegmentEvaluation,
|
||||
writes_real_file: bool,
|
||||
sets_unsafe_env: bool,
|
||||
env_risk: EnvRisk,
|
||||
exact_grant: bool,
|
||||
all_segments_granted: bool,
|
||||
has_opaque_shell: bool,
|
||||
}
|
||||
|
||||
/// Parse and classify one Bash request once, keeping ordinary segment outcome
|
||||
|
|
@ -377,16 +390,17 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
|
|||
return BashEvaluation {
|
||||
segments: SegmentEvaluation::Unparseable,
|
||||
writes_real_file: false,
|
||||
sets_unsafe_env: false,
|
||||
env_risk: EnvRisk::Safe,
|
||||
exact_grant,
|
||||
all_segments_granted: false,
|
||||
has_opaque_shell: false,
|
||||
};
|
||||
};
|
||||
let writes_real_file = command_write_paths_in_tree(tree.root_node(), cmd)
|
||||
.into_iter()
|
||||
.any(|path| !is_safe_write_sink(&path));
|
||||
let segments = try_parse_word_only_commands_sequence(&tree, cmd);
|
||||
let sets_unsafe_env = script_sets_unsafe_env(
|
||||
let env_risk = script_env_risk(
|
||||
tree.root_node(),
|
||||
cmd,
|
||||
segments.as_deref().unwrap_or_default(),
|
||||
|
|
@ -395,15 +409,17 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
|
|||
return BashEvaluation {
|
||||
segments: SegmentEvaluation::Unparseable,
|
||||
writes_real_file,
|
||||
sets_unsafe_env,
|
||||
env_risk,
|
||||
exact_grant,
|
||||
all_segments_granted: false,
|
||||
has_opaque_shell: false,
|
||||
};
|
||||
};
|
||||
let mut needs_prompt: Vec<String> = Vec::new();
|
||||
let mut any_dangerous = false;
|
||||
let mut via_session_grant = false;
|
||||
let mut all_segments_granted = true;
|
||||
let mut has_opaque_shell = false;
|
||||
for parsed in segments {
|
||||
let raw_words = parsed.words();
|
||||
// Peel wrapper commands like `timeout 30 …`, `env FOO=1 …`, `nice -n 5 …`
|
||||
|
|
@ -411,6 +427,11 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
|
|||
// such as `timeout 30 rm -rf /tmp/foo` would be treated as a benign
|
||||
// `timeout` invocation and silently auto-allowed.
|
||||
let words = unwrap_wrappers(raw_words);
|
||||
if shell_dash_c_script(words).is_some()
|
||||
|| words.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("eval")
|
||||
{
|
||||
has_opaque_shell = true;
|
||||
}
|
||||
if is_setup_command(words) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -427,9 +448,10 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
|
|||
"User previously rejected `{d}` for this session"
|
||||
)),
|
||||
writes_real_file,
|
||||
sets_unsafe_env,
|
||||
env_risk,
|
||||
exact_grant,
|
||||
all_segments_granted,
|
||||
has_opaque_shell,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -473,9 +495,10 @@ fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) ->
|
|||
BashEvaluation {
|
||||
segments,
|
||||
writes_real_file,
|
||||
sets_unsafe_env,
|
||||
env_risk,
|
||||
exact_grant,
|
||||
all_segments_granted,
|
||||
has_opaque_shell,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -764,12 +787,26 @@ fn bash_write_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool
|
|||
}
|
||||
|
||||
fn bash_unsafe_env_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool {
|
||||
evaluation.is_some_and(|evaluation| evaluation.sets_unsafe_env && !evaluation.exact_grant)
|
||||
evaluation
|
||||
.is_some_and(|evaluation| evaluation.env_risk != EnvRisk::Safe && !evaluation.exact_grant)
|
||||
}
|
||||
|
||||
fn bash_opaque_shell_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool {
|
||||
evaluation.is_some_and(|evaluation| evaluation.has_opaque_shell && !evaluation.exact_grant)
|
||||
}
|
||||
|
||||
fn bash_request_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool {
|
||||
bash_write_floor_requires_prompt(evaluation)
|
||||
|| bash_unsafe_env_floor_requires_prompt(evaluation)
|
||||
|| bash_opaque_shell_floor_requires_prompt(evaluation)
|
||||
}
|
||||
|
||||
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.env_risk == EnvRisk::Unvetted
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_may_auto_allow_bash(evaluation: Option<&BashEvaluation>, sandbox_active: bool) -> bool {
|
||||
|
|
@ -1062,6 +1099,8 @@ fn spawn_permission_manager_with_pin(
|
|||
// fallback always uses the actor's transcript turns).
|
||||
let mut auto_classifier: Option<crate::permission::auto_mode::SharedClassifier> =
|
||||
Some(crate::permission::auto_mode::default_auto_mode_classifier());
|
||||
let mut auto_consecutive_denials: u32 = 0;
|
||||
let mut auto_total_denials: u32 = 0;
|
||||
// Recent turns + project AGENTS.md for classifier context (set by session).
|
||||
let mut classifier_turns: Vec<crate::permission::auto_mode::ClassifierTurn> = Vec::new();
|
||||
let mut recorded_permission_decisions: Vec<crate::permission::auto_mode::ClassifierTurn> =
|
||||
|
|
@ -1332,6 +1371,24 @@ fn spawn_permission_manager_with_pin(
|
|||
continue;
|
||||
}
|
||||
|
||||
if auto_mode
|
||||
&& !policy_forced_prompt
|
||||
&& !shell_forced_prompt
|
||||
&& !protected_edit
|
||||
&& !bash_request_floor_requires_prompt(bash_evaluation.as_ref())
|
||||
&& matches!(policy_decision, Some(Decision::Allow))
|
||||
{
|
||||
tracing::info!(
|
||||
tool = ?tool_name,
|
||||
source = "policy",
|
||||
"permission policy: allow rule matched (before auto classifier)"
|
||||
);
|
||||
let decision = Decision::Allow;
|
||||
emit_event(&decision, true, false, None, Some(reasons::POLICY_ALLOW));
|
||||
let _ = respond_to.send(decision);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto mode: classifier + fast-paths (not silent always-approve).
|
||||
// Policy deny already handled; forced Ask falls through unless
|
||||
// fast-path/classifier allows. Policy Ask still prompts below
|
||||
|
|
@ -1340,7 +1397,8 @@ fn spawn_permission_manager_with_pin(
|
|||
if auto_mode
|
||||
&& !policy_forced_prompt
|
||||
&& !shell_forced_prompt
|
||||
&& !bash_request_floor_requires_prompt(bash_evaluation.as_ref())
|
||||
&& (!bash_request_floor_requires_prompt(bash_evaluation.as_ref())
|
||||
|| bash_request_floor_defers_to_classifier(bash_evaluation.as_ref()))
|
||||
{
|
||||
use crate::permission::auto_mode::{
|
||||
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
|
||||
|
|
@ -1372,7 +1430,7 @@ fn spawn_permission_manager_with_pin(
|
|||
auto_prompt_reason = Some(reasons::NEEDS_USER);
|
||||
}
|
||||
AutoFastPath::Classify => {
|
||||
let verdict = if let Some(ref clf) = auto_classifier {
|
||||
let outcome = if let Some(ref clf) = auto_classifier {
|
||||
use crate::permission::auto_mode::ClassifierContext;
|
||||
let mut turns = classifier_turns.clone();
|
||||
turns.extend(recorded_permission_decisions.iter().cloned());
|
||||
|
|
@ -1392,9 +1450,9 @@ fn spawn_permission_manager_with_pin(
|
|||
} else {
|
||||
// No classifier wired: treat as unavailable, which
|
||||
// prompts the user (never a silent allow).
|
||||
Some(ClassifierVerdict::Unavailable)
|
||||
Some(ClassifierVerdict::Unavailable.into())
|
||||
};
|
||||
let Some(verdict) = verdict else {
|
||||
let Some(outcome) = outcome else {
|
||||
tracing::info!(tool = %tool_name, "permission requester gone; classify abandoned");
|
||||
emit_event(
|
||||
&Decision::Cancelled,
|
||||
|
|
@ -1405,15 +1463,13 @@ fn spawn_permission_manager_with_pin(
|
|||
);
|
||||
continue;
|
||||
};
|
||||
// Allow runs without a prompt; Block/Unavailable
|
||||
// both surface the interactive picker (never a
|
||||
// silent reject).
|
||||
match verdict {
|
||||
match outcome.verdict {
|
||||
ClassifierVerdict::Allow => {
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
"auto mode: classifier allow"
|
||||
);
|
||||
auto_consecutive_denials = 0;
|
||||
let decision = Decision::Allow;
|
||||
emit_event(
|
||||
&decision,
|
||||
|
|
@ -1425,15 +1481,72 @@ fn spawn_permission_manager_with_pin(
|
|||
let _ = respond_to.send(decision);
|
||||
continue;
|
||||
}
|
||||
ClassifierVerdict::Block | ClassifierVerdict::Unavailable => {
|
||||
ClassifierVerdict::Block
|
||||
if bash_request_floor_requires_prompt(
|
||||
bash_evaluation.as_ref(),
|
||||
) =>
|
||||
{
|
||||
tracing::info!(
|
||||
tool = %tool_name,
|
||||
"auto mode: classifier blocked — prompting user"
|
||||
"auto mode: classifier declined floor-deferred command — prompting user"
|
||||
);
|
||||
// Fall through to interactive prompter.
|
||||
auto_forced_prompt = true;
|
||||
auto_prompt_reason = Some(reasons::AUTO_CLASSIFIER_BLOCK);
|
||||
}
|
||||
ClassifierVerdict::Block
|
||||
if auto_consecutive_denials
|
||||
< AUTO_DENY_CONSECUTIVE_LIMIT
|
||||
&& auto_total_denials < AUTO_DENY_TOTAL_LIMIT =>
|
||||
{
|
||||
auto_consecutive_denials += 1;
|
||||
auto_total_denials += 1;
|
||||
tracing::info!(
|
||||
tool = %tool_name,
|
||||
consecutive = auto_consecutive_denials,
|
||||
total = auto_total_denials,
|
||||
"auto mode: classifier blocked — denying and continuing"
|
||||
);
|
||||
let reason = match &outcome.reason {
|
||||
Some(r) => format!(
|
||||
"Auto mode blocked this action ({}). \
|
||||
{AUTO_DENY_GUIDANCE}",
|
||||
r.trim_end_matches('.')
|
||||
),
|
||||
None => format!(
|
||||
"Auto mode blocked this action. \
|
||||
{AUTO_DENY_GUIDANCE}"
|
||||
),
|
||||
};
|
||||
let decision = Decision::PolicyDeny(reason);
|
||||
emit_event(
|
||||
&decision,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some(reasons::AUTO_CLASSIFIER_DENY),
|
||||
);
|
||||
let _ = respond_to.send(decision);
|
||||
continue;
|
||||
}
|
||||
ClassifierVerdict::Block => {
|
||||
tracing::info!(
|
||||
tool = %tool_name,
|
||||
consecutive = auto_consecutive_denials,
|
||||
total = auto_total_denials,
|
||||
"auto mode: denial limit reached — prompting user"
|
||||
);
|
||||
auto_forced_prompt = true;
|
||||
auto_prompt_reason = Some(reasons::AUTO_DENIAL_LIMIT);
|
||||
}
|
||||
ClassifierVerdict::Unavailable => {
|
||||
tracing::info!(
|
||||
tool = %tool_name,
|
||||
"auto mode: classifier unavailable — prompting user"
|
||||
);
|
||||
auto_forced_prompt = true;
|
||||
auto_prompt_reason =
|
||||
Some(reasons::AUTO_CLASSIFIER_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1658,8 +1771,14 @@ fn spawn_permission_manager_with_pin(
|
|||
// A policy/shell `ask` wins; else the auto-mode reason; else unapproved.
|
||||
let prompt_trigger = if policy_forced_prompt || shell_forced_prompt {
|
||||
reasons::POLICY_ASK
|
||||
} else if let Some(reason) = auto_prompt_reason {
|
||||
reason
|
||||
} else if bash_opaque_shell_floor_requires_prompt(bash_evaluation.as_ref()) {
|
||||
reasons::OPAQUE_SHELL
|
||||
} else if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) {
|
||||
reasons::BASH_REQUEST_FLOOR
|
||||
} else {
|
||||
auto_prompt_reason.unwrap_or(reasons::NEEDS_USER)
|
||||
reasons::NEEDS_USER
|
||||
};
|
||||
if respond_to.is_closed() {
|
||||
tracing::info!(tool = %tool_name, "permission requester gone; prompt suppressed");
|
||||
|
|
@ -1882,6 +2001,9 @@ fn spawn_permission_manager_with_pin(
|
|||
.drain(..len - MAX_RECORDED_PERMISSION_DECISIONS);
|
||||
}
|
||||
}
|
||||
if user_prompted && outcome_str != "error" {
|
||||
auto_consecutive_denials = 0;
|
||||
}
|
||||
let trigger = if matches!(decision, Decision::Cancelled)
|
||||
&& respond_to.is_closed()
|
||||
{
|
||||
|
|
@ -2980,14 +3102,14 @@ mod tests {
|
|||
context: crate::permission::auto_mode::ClassifierContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<Output = crate::permission::auto_mode::ClassifierVerdict>
|
||||
dyn std::future::Future<Output = crate::permission::auto_mode::ClassifierOutcome>
|
||||
+ Send
|
||||
+ 'a,
|
||||
>,
|
||||
> {
|
||||
self.seen.lock().unwrap().push(context);
|
||||
let v = self.verdict;
|
||||
Box::pin(async move { v })
|
||||
Box::pin(async move { v.into() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3643,6 +3765,237 @@ mod tests {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn floor_prompt_records_bash_request_floor_reason() {
|
||||
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 (mgr, mut events) =
|
||||
manager_with_recording_client(&cwd, None, client, ClientType::Generic);
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash("cat payload > out".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(d, Decision::Reject(_)));
|
||||
let ev = events.try_recv().expect("event must be emitted");
|
||||
assert_eq!(ev.decision_reason.as_deref(), Some("bash_request_floor"));
|
||||
assert!(ev.user_prompted);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_mode_unvetted_env_defers_to_classifier_allow() {
|
||||
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":"read-only","shouldBlock":false,"reason":"pr read"}"#,
|
||||
)));
|
||||
for cmd in [
|
||||
"GH_HOST=github.example.com gh pr view 3135 --json title",
|
||||
"PYTHONPATH=/x python s.py",
|
||||
"out=$(gh pr view 3135); echo \"$out\"",
|
||||
] {
|
||||
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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_mode_injection_env_prompts_despite_classifier_allow() {
|
||||
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":"looks fine","shouldBlock":false,"reason":"ok"}"#,
|
||||
)));
|
||||
for cmd in [
|
||||
UNSAFE_GIT_STATUS,
|
||||
"LD_PRELOAD=/tmp/e.so ls",
|
||||
"env -i git status",
|
||||
] {
|
||||
let d = mgr
|
||||
.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None)
|
||||
.await;
|
||||
assert!(matches!(d, Decision::Reject(_)), "{cmd}: {d:?}");
|
||||
let ev = events.try_recv().expect("event must be emitted");
|
||||
assert_eq!(
|
||||
ev.decision_reason.as_deref(),
|
||||
Some("bash_request_floor"),
|
||||
"{cmd}"
|
||||
);
|
||||
}
|
||||
assert_eq!(prompts.borrow().len(), 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_mode_opaque_shell_prompts_despite_classifier_allow() {
|
||||
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":"looks fine","shouldBlock":false,"reason":"ok"}"#,
|
||||
)));
|
||||
for cmd in [
|
||||
"bash -c 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.pager GIT_CONFIG_VALUE_0=cat git status'",
|
||||
"sh -c 'LD_PRELOAD=/x ls'",
|
||||
"bash -c 'echo hi'",
|
||||
"eval 'echo hi'",
|
||||
"env bash -c 'echo hi'",
|
||||
] {
|
||||
let d = mgr
|
||||
.request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None)
|
||||
.await;
|
||||
assert!(matches!(d, Decision::Reject(_)), "{cmd}: {d:?}");
|
||||
let ev = events.try_recv().expect("event must be emitted");
|
||||
assert_eq!(ev.decision_reason.as_deref(), Some("opaque_shell"), "{cmd}");
|
||||
}
|
||||
assert_eq!(prompts.borrow().len(), 5);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn injection_env_runs_under_yolo() {
|
||||
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_yolo_mode(true);
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash(UNSAFE_GIT_STATUS.into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(d, Decision::Allow), "{d:?}");
|
||||
let ev = events.try_recv().expect("event must be emitted");
|
||||
assert_eq!(ev.decision_reason.as_deref(), Some("yolo"));
|
||||
assert_eq!(prompts.borrow().len(), 0);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_mode_write_floor_prompts_despite_classifier_allow() {
|
||||
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":"looks fine","shouldBlock":false,"reason":"ok"}"#,
|
||||
)));
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash("V=1 cat payload > out".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.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!(prompts.borrow().len(), 1);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_mode_unvetted_env_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":"suspicious","shouldBlock":true,"reason":"no"}"#,
|
||||
)));
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash("CUSTOM_TOKEN=x curl-ish --post".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(d, Decision::Reject(_)));
|
||||
assert_eq!(prompts.borrow().len(), 1);
|
||||
let ev = events.try_recv().expect("event must be emitted");
|
||||
assert_eq!(ev.decision_reason.as_deref(), Some("auto_classifier_block"));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_edit_floor_covers_auto_config_allow_and_dont_ask() {
|
||||
use crate::permission::types::{PermissionRule, RuleAction, ToolFilter};
|
||||
|
|
@ -4982,34 +5335,81 @@ mod tests {
|
|||
#[test]
|
||||
fn unsafe_environment_detection_covers_script_forms() {
|
||||
let state = PermissionState::default();
|
||||
for (cmd, unsafe_env) in [
|
||||
(UNSAFE_GIT_STATUS, true),
|
||||
for (cmd, env_risk) in [
|
||||
(UNSAFE_GIT_STATUS, EnvRisk::Injection),
|
||||
(
|
||||
concat!(
|
||||
"env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor ",
|
||||
"GIT_CONFIG_VALUE_0=/tmp/pwn git status"
|
||||
),
|
||||
true,
|
||||
EnvRisk::Injection,
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
"set -a; GIT_CONFIG_COUNT=1; GIT_CONFIG_KEY_0=core.fsmonitor; ",
|
||||
"GIT_CONFIG_VALUE_0=/tmp/pwn; git status"
|
||||
),
|
||||
true,
|
||||
EnvRisk::Injection,
|
||||
),
|
||||
("RUST_LOG=debug git status", false),
|
||||
("LD_PRELOAD=/tmp/e.so ls", EnvRisk::Injection),
|
||||
("env -i git status", EnvRisk::Injection),
|
||||
(
|
||||
"GH_HOST=github.example.com gh pr view 3135",
|
||||
EnvRisk::Unvetted,
|
||||
),
|
||||
("KUBECONFIG=/x kubectl get pods", EnvRisk::Unvetted),
|
||||
("out=$(gh pr view 3135); echo \"$out\"", EnvRisk::Unvetted),
|
||||
("RUST_LOG=debug git status", EnvRisk::Safe),
|
||||
] {
|
||||
let evaluation = evaluate_bash(cmd, &state, true);
|
||||
assert_eq!(evaluation.sets_unsafe_env, unsafe_env, "{cmd}");
|
||||
assert_eq!(evaluation.env_risk, env_risk, "{cmd}");
|
||||
assert_eq!(
|
||||
bash_unsafe_env_floor_requires_prompt(Some(&evaluation)),
|
||||
unsafe_env,
|
||||
env_risk != EnvRisk::Safe,
|
||||
"{cmd}"
|
||||
);
|
||||
assert_eq!(
|
||||
bash_request_floor_defers_to_classifier(Some(&evaluation)),
|
||||
env_risk == EnvRisk::Unvetted,
|
||||
"{cmd}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injection_env_floor_respects_exact_grant() {
|
||||
let cmd = UNSAFE_GIT_STATUS;
|
||||
let ungranted = evaluate_bash(cmd, &PermissionState::default(), true);
|
||||
assert_eq!(ungranted.env_risk, EnvRisk::Injection);
|
||||
assert!(bash_unsafe_env_floor_requires_prompt(Some(&ungranted)));
|
||||
assert!(!bash_request_floor_defers_to_classifier(Some(&ungranted)));
|
||||
|
||||
let granted_state = PermissionState {
|
||||
allowed_bash_commands: HashSet::from([cmd.to_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let granted = evaluate_bash(cmd, &granted_state, true);
|
||||
assert!(!bash_unsafe_env_floor_requires_prompt(Some(&granted)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_shell_floor_and_exact_grant() {
|
||||
let cmd = "bash -c 'GIT_CONFIG_COUNT=1 git status'";
|
||||
let ungranted = evaluate_bash(cmd, &PermissionState::default(), true);
|
||||
assert!(ungranted.has_opaque_shell);
|
||||
assert_eq!(ungranted.env_risk, EnvRisk::Safe);
|
||||
assert!(bash_opaque_shell_floor_requires_prompt(Some(&ungranted)));
|
||||
assert!(bash_request_floor_requires_prompt(Some(&ungranted)));
|
||||
assert!(!bash_request_floor_defers_to_classifier(Some(&ungranted)));
|
||||
|
||||
let granted_state = PermissionState {
|
||||
allowed_bash_commands: HashSet::from([cmd.to_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let granted = evaluate_bash(cmd, &granted_state, true);
|
||||
assert!(!bash_opaque_shell_floor_requires_prompt(Some(&granted)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_env_floor_blocks_broad_grants_but_preserves_exact_decisions() {
|
||||
let cmd = UNSAFE_GIT_STATUS;
|
||||
|
|
@ -5024,7 +5424,7 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
let evaluation = evaluate_bash(cmd, &state, true);
|
||||
assert!(evaluation.sets_unsafe_env);
|
||||
assert_ne!(evaluation.env_risk, EnvRisk::Safe);
|
||||
assert_eq!(
|
||||
bash_grant_pre_decision(
|
||||
cmd,
|
||||
|
|
@ -5553,11 +5953,10 @@ mod tests {
|
|||
"classifier allow must allow without user click, got {d:?}"
|
||||
);
|
||||
|
||||
// Classifier block denies.
|
||||
mgr.set_classifier(Some(Arc::new(FixedClassifier(ClassifierVerdict::Block))));
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash("rm -rf /".into()),
|
||||
AccessKind::Bash("git push origin main".into()),
|
||||
dummy_update.clone(),
|
||||
None,
|
||||
None,
|
||||
|
|
@ -5565,8 +5964,8 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(d, Decision::Reject(_)),
|
||||
"classifier block must deny, got {d:?}"
|
||||
matches!(d, Decision::PolicyDeny(_)),
|
||||
"classifier block must deny-and-continue, got {d:?}"
|
||||
);
|
||||
|
||||
// Always-approve (yolo) skips classifier entirely.
|
||||
|
|
@ -5794,22 +6193,16 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(d, Decision::Reject(_)),
|
||||
"LLM block on real gate must deny, got {d:?}"
|
||||
matches!(&d, Decision::PolicyDeny(r) if r.contains("exfil")),
|
||||
"LLM block on real gate must deny-and-continue with the \
|
||||
classifier reason threaded through, got {d:?}"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Guardrail: in interactive AUTO mode a classifier BLOCK must surface the
|
||||
/// standard permission picker on the FIRST block (no silent denial), and
|
||||
/// that picker is the SAME one the normal interactive path builds — it must
|
||||
/// still offer "enable always-approve" at position 0. Drives the real prompt
|
||||
/// through `manager_with_recording_client` so a refactor that swapped in an
|
||||
/// auto-specific picker on the block path would fail here (a pure
|
||||
/// `build_options` unit test cannot catch that).
|
||||
#[tokio::test]
|
||||
async fn auto_classifier_block_prompt_still_offers_always_approve() {
|
||||
async fn auto_classifier_block_denies_then_escalates_to_prompt() {
|
||||
use crate::permission::auto_mode::LlmPermissionClassifier;
|
||||
use crate::permission::prompter::ENABLE_ALWAYS_APPROVE_OPTION_ID;
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
|
@ -5824,48 +6217,184 @@ mod tests {
|
|||
let (mgr, _e) =
|
||||
manager_with_recording_client(&cwd, None, client, ClientType::GrokPager);
|
||||
mgr.set_auto_mode(true);
|
||||
// Force classify to Block. Interactive auto mode must now prompt
|
||||
// on the FIRST block instead of denying.
|
||||
mgr.set_classifier(Some(LlmPermissionClassifier::with_fixed_model_text(
|
||||
r#"{"thinking":"t","shouldBlock":true,"reason":"x"}"#,
|
||||
r#"{"thinking":"t","shouldBlock":true,"reason":"reaches beyond the machine"}"#,
|
||||
)));
|
||||
|
||||
// A non-allowlisted MCP tool (name has no "ask_user"/"confirm"+
|
||||
// "human") routes to `AutoFastPath::Classify`, not the needs-user
|
||||
// fast path, so the classifier — and thus the prompt-on-block — runs.
|
||||
let d = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
let request = || async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
mgr.request(
|
||||
AccessKind::MCPTool {
|
||||
name: "test_server__do_thing".into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("classifier-block request must resolve, not hang")
|
||||
};
|
||||
|
||||
for i in 0..AUTO_DENY_CONSECUTIVE_LIMIT {
|
||||
let d = request().await;
|
||||
assert!(
|
||||
matches!(&d, Decision::PolicyDeny(r) if r.contains("reaches beyond the machine")),
|
||||
"block #{} within budget must PolicyDeny with the classifier reason, got {d:?}",
|
||||
i + 1
|
||||
);
|
||||
assert_eq!(
|
||||
prompts.borrow().len(),
|
||||
0,
|
||||
"deny-and-continue must not prompt within the budget"
|
||||
);
|
||||
}
|
||||
|
||||
let d = request().await;
|
||||
assert!(
|
||||
matches!(d, Decision::Reject(_)),
|
||||
"escalated prompt is answered reject-once by the recording client, got {d:?}"
|
||||
);
|
||||
{
|
||||
let recorded = prompts.borrow();
|
||||
assert_eq!(
|
||||
recorded.len(),
|
||||
1,
|
||||
"the block past the consecutive limit must prompt exactly once"
|
||||
);
|
||||
assert_eq!(
|
||||
recorded[0].options.first().map(|o| o.option_id.0.as_ref()),
|
||||
Some(ENABLE_ALWAYS_APPROVE_OPTION_ID),
|
||||
"escalation picker must still offer enable-always-approve at position 0"
|
||||
);
|
||||
}
|
||||
|
||||
let d = request().await;
|
||||
assert!(
|
||||
matches!(d, Decision::PolicyDeny(_)),
|
||||
"after a human decision the consecutive budget must reset, got {d:?}"
|
||||
);
|
||||
assert_eq!(prompts.borrow().len(), 1, "no second prompt after reset");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_classifier_total_denial_limit_escalates() {
|
||||
use crate::permission::auto_mode::{
|
||||
ClassifierContext, ClassifierOutcome, ClassifierVerdict, PermissionClassifier,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
struct CyclingClassifier(AtomicU32);
|
||||
impl PermissionClassifier for CyclingClassifier {
|
||||
fn classify<'a>(
|
||||
&'a self,
|
||||
_tool_name: &'a str,
|
||||
_access: &'a AccessKind,
|
||||
_access_detail: Option<&'a str>,
|
||||
_context: ClassifierContext,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ClassifierOutcome> + Send + 'a>>
|
||||
{
|
||||
let i = self.0.fetch_add(1, Ordering::Relaxed);
|
||||
let v = if i % 3 == 2 {
|
||||
ClassifierVerdict::Allow
|
||||
} else {
|
||||
ClassifierVerdict::Block
|
||||
};
|
||||
Box::pin(async move { v.into() })
|
||||
}
|
||||
}
|
||||
|
||||
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 (mgr, _ev) = test_manager(&cwd, false, None);
|
||||
mgr.set_auto_mode(true);
|
||||
mgr.set_classifier(Some(std::sync::Arc::new(CyclingClassifier(
|
||||
AtomicU32::new(0),
|
||||
))));
|
||||
let request = || async {
|
||||
mgr.request(
|
||||
AccessKind::MCPTool {
|
||||
name: "test_server__do_thing".into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
AccessKind::Bash("git push origin main".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("classifier-block prompt must resolve, not hang");
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
let cycles = AUTO_DENY_TOTAL_LIMIT / 2;
|
||||
for cycle in 0..cycles {
|
||||
for step in 0..3 {
|
||||
let d = request().await;
|
||||
if step == 2 {
|
||||
assert!(
|
||||
matches!(d, Decision::Allow),
|
||||
"cycle {cycle} allow step must Allow, got {d:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(d, Decision::PolicyDeny(_)),
|
||||
"cycle {cycle} block step must PolicyDeny under the cap, got {d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let d = request().await;
|
||||
assert!(
|
||||
matches!(d, Decision::Reject(_)),
|
||||
"prompt is answered reject-once by the recording client, got {d:?}"
|
||||
"block past the total cap must escalate to the prompt path, got {d:?}"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let recorded = prompts.borrow();
|
||||
assert_eq!(
|
||||
recorded.len(),
|
||||
1,
|
||||
"the first classifier block must prompt exactly once (no silent denial)"
|
||||
);
|
||||
// The prepended always-approve option must be position 0 of the
|
||||
// picker (subsumes presence).
|
||||
assert_eq!(
|
||||
recorded[0].options.first().map(|o| o.option_id.0.as_ref()),
|
||||
Some(ENABLE_ALWAYS_APPROVE_OPTION_ID),
|
||||
"classifier-block picker must still offer enable-always-approve at position 0"
|
||||
);
|
||||
#[tokio::test]
|
||||
async fn auto_policy_allow_beats_classifier_deny() {
|
||||
use crate::permission::auto_mode::{ClassifierVerdict, FixedClassifier};
|
||||
use crate::permission::types::{
|
||||
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
|
||||
};
|
||||
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 config = PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Allow,
|
||||
tool: ToolFilter::Bash,
|
||||
pattern: Some("my-deploy-tool *".to_owned()),
|
||||
pattern_mode: PatternMode::Glob,
|
||||
}]);
|
||||
let (mgr, _ev) = test_manager_with_config(&cwd, config, false);
|
||||
mgr.set_auto_mode(true);
|
||||
mgr.set_classifier(Some(std::sync::Arc::new(FixedClassifier(
|
||||
ClassifierVerdict::Block,
|
||||
))));
|
||||
for i in 0..(AUTO_DENY_CONSECUTIVE_LIMIT + 1) {
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Bash("my-deploy-tool --stage".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(d, Decision::Allow),
|
||||
"policy allow must beat classifier deny (request #{}), got {d:?}",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ pub mod types;
|
|||
|
||||
pub use auto_mode::{
|
||||
AUTO_MODE_CLASSIFIER_SYSTEM_PROMPT, AutoFastPath, CLASSIFIER_TURN_MAX_LEN, ClassifierContext,
|
||||
ClassifierMessage, ClassifierMessageRole, ClassifierPromptType, ClassifierTurn,
|
||||
ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier,
|
||||
ClassifierMessage, ClassifierMessageRole, ClassifierOutcome, ClassifierPromptType,
|
||||
ClassifierTurn, ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier,
|
||||
HeuristicPermissionClassifier, LlmPermissionClassifier, PermissionClassifier, SharedClassifier,
|
||||
access_requires_user_interaction, auto_mode_fast_path, build_classifier_messages,
|
||||
classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access,
|
||||
is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args,
|
||||
is_auto_mode_allowlisted_tool_name, parse_classifier_model_output, parse_classifier_model_text,
|
||||
permission_decision_args,
|
||||
};
|
||||
pub use hub_permission::{
|
||||
PermissionHookTransport, ToolServerPermissionTransport, access_kind_for_hub_tool,
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ impl From<PermissionConfig> for CompiledPolicy {
|
|||
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
|
||||
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
|
||||
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
|
||||
fn shell_dash_c_script(words: &[String]) -> Option<&str> {
|
||||
pub(crate) fn shell_dash_c_script(words: &[String]) -> Option<&str> {
|
||||
let program = words.first()?.rsplit(['/', '\\']).next()?;
|
||||
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -1755,7 +1755,7 @@ mod tests {
|
|||
let spec = xai_grok_hooks::config::HookSpec {
|
||||
name: "global/safety".to_string(),
|
||||
event: xai_grok_hooks::event::HookEventName::PreToolUse,
|
||||
handler_type: "command".to_string(),
|
||||
handler_type: xai_grok_hooks::config::HandlerType::Command,
|
||||
configured_matcher: Some("Bash".to_string()),
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
|
|
@ -1865,7 +1865,7 @@ mod tests {
|
|||
HookSpecWire {
|
||||
name,
|
||||
event,
|
||||
handler_type,
|
||||
handler_type: handler_type.as_str().to_string(),
|
||||
configured_matcher,
|
||||
enabled,
|
||||
command,
|
||||
|
|
@ -1880,7 +1880,7 @@ mod tests {
|
|||
let spec = HookSpec {
|
||||
name: "global/safety".to_string(),
|
||||
event: xai_grok_hooks::event::HookEventName::PreToolUse,
|
||||
handler_type: "command".to_string(),
|
||||
handler_type: xai_grok_hooks::config::HandlerType::Command,
|
||||
configured_matcher: Some("Bash".to_string()),
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue