17dbed13c7
Harvested from PR #2885 by @greyfreedom. Wires ask-rules into the app-server and core ExecPolicyEngine (previously inert). Removes the original PR's NeedsApproval arm that incorrectly allow-listed the working directory as a network host. Co-Authored-By: greyfreedom <11493871+greyfreedom@users.noreply.github.com>
854 lines
31 KiB
Rust
854 lines
31 KiB
Rust
pub mod bash_arity;
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use anyhow::Result;
|
|
use bash_arity::BashArityDict;
|
|
use codewhale_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Priority layer for a permission ruleset. Higher ordinal = higher priority.
|
|
/// On conflict, the highest-priority layer's longest matching prefix wins.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RulesetLayer {
|
|
BuiltinDefault = 0,
|
|
Agent = 1,
|
|
User = 2,
|
|
}
|
|
|
|
/// A named set of allow/deny prefix rules at a given priority layer.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Ruleset {
|
|
/// Priority layer this ruleset belongs to.
|
|
pub layer: RulesetLayer,
|
|
/// Command prefixes that are allowed without requiring approval.
|
|
pub trusted_prefixes: Vec<String>,
|
|
/// Command prefixes that are always blocked, regardless of trust rules.
|
|
pub denied_prefixes: Vec<String>,
|
|
/// Typed rules that mark specific tool invocations as requiring approval.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub ask_rules: Vec<ToolAskRule>,
|
|
}
|
|
|
|
impl Ruleset {
|
|
/// Creates an empty ruleset at the builtin default priority layer.
|
|
pub fn builtin_default() -> Self {
|
|
Self {
|
|
layer: RulesetLayer::BuiltinDefault,
|
|
trusted_prefixes: vec![],
|
|
denied_prefixes: vec![],
|
|
ask_rules: vec![],
|
|
}
|
|
}
|
|
|
|
/// Creates an agent-layer ruleset with the given trusted and denied prefixes.
|
|
pub fn agent(trusted: Vec<String>, denied: Vec<String>) -> Self {
|
|
Self {
|
|
layer: RulesetLayer::Agent,
|
|
trusted_prefixes: trusted,
|
|
denied_prefixes: denied,
|
|
ask_rules: vec![],
|
|
}
|
|
}
|
|
|
|
/// Creates a user-layer ruleset with the given trusted and denied prefixes.
|
|
pub fn user(trusted: Vec<String>, denied: Vec<String>) -> Self {
|
|
Self {
|
|
layer: RulesetLayer::User,
|
|
trusted_prefixes: trusted,
|
|
denied_prefixes: denied,
|
|
ask_rules: vec![],
|
|
}
|
|
}
|
|
|
|
/// Attaches typed ask rules to this ruleset and returns it.
|
|
pub fn with_ask_rules(mut self, ask_rules: Vec<ToolAskRule>) -> Self {
|
|
self.ask_rules = ask_rules;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Typed rule that marks a tool invocation as requiring approval.
|
|
///
|
|
/// This foundation is intentionally ask-only. Existing trusted/denied command
|
|
/// prefix behavior is preserved while typed ask records can make
|
|
/// `AskForApproval::Never` reject invocations that cannot be approved.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ToolAskRule {
|
|
/// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`).
|
|
pub tool: String,
|
|
/// Optional command prefix to match against (uses arity-aware matching).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub command: Option<String>,
|
|
/// Optional file path pattern to match against.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
impl ToolAskRule {
|
|
/// Creates a new ask rule matching any invocation of the given tool.
|
|
pub fn new(tool: impl Into<String>) -> Self {
|
|
Self {
|
|
tool: tool.into(),
|
|
command: None,
|
|
path: None,
|
|
}
|
|
}
|
|
|
|
/// Creates an ask rule for `exec_shell` matching a specific command prefix.
|
|
pub fn exec_shell(command: impl Into<String>) -> Self {
|
|
Self {
|
|
tool: "exec_shell".to_string(),
|
|
command: Some(command.into()),
|
|
path: None,
|
|
}
|
|
}
|
|
|
|
/// Creates an ask rule for a file-tool matching a specific path pattern.
|
|
pub fn file_path(tool: impl Into<String>, path: impl Into<String>) -> Self {
|
|
Self {
|
|
tool: tool.into(),
|
|
command: None,
|
|
path: Some(path.into()),
|
|
}
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
let mut parts = vec![format!("tool={}", self.tool)];
|
|
if let Some(command) = &self.command {
|
|
parts.push(format!("command={command}"));
|
|
}
|
|
if let Some(path) = &self.path {
|
|
parts.push(format!("path={path}"));
|
|
}
|
|
parts.join(" ")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
/// Policy mode controlling when tool invocations require human approval.
|
|
pub enum AskForApproval {
|
|
/// Skip approval if the command matches a trusted prefix; otherwise require it.
|
|
UnlessTrusted,
|
|
/// Allow execution and only request approval after a failure occurs.
|
|
OnFailure,
|
|
/// Always require approval before execution.
|
|
OnRequest,
|
|
/// Reject invocations outright based on specific criteria.
|
|
Reject {
|
|
/// Whether sandbox approval requests are rejected.
|
|
sandbox_approval: bool,
|
|
/// Whether rule-exception requests are rejected.
|
|
rules: bool,
|
|
/// Whether MCP elicitation requests are rejected.
|
|
mcp_elicitations: bool,
|
|
},
|
|
/// Never require approval; forbid commands that would need it.
|
|
Never,
|
|
}
|
|
|
|
/// A proposed amendment to the execution policy, suggesting new trusted prefixes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ExecPolicyAmendment {
|
|
/// Command prefixes to add to the trusted list.
|
|
pub prefixes: Vec<String>,
|
|
}
|
|
|
|
/// The approval requirement determined by the execution policy engine.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub enum ExecApprovalRequirement {
|
|
/// Execution is allowed without approval.
|
|
Skip {
|
|
/// Whether the sandbox should be bypassed for this execution.
|
|
bypass_sandbox: bool,
|
|
/// Optional proposed policy amendment (e.g., to persist the allowed prefix).
|
|
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
|
},
|
|
/// Execution is allowed but requires human approval first.
|
|
NeedsApproval {
|
|
/// Human-readable reason explaining why approval is needed.
|
|
reason: String,
|
|
/// Optional proposed policy amendment that would be applied on approval.
|
|
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
|
/// Proposed network policy amendments that would be applied on approval.
|
|
proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
|
|
},
|
|
/// Execution is forbidden by policy.
|
|
Forbidden {
|
|
/// Human-readable reason explaining why execution is forbidden.
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
impl ExecApprovalRequirement {
|
|
/// Returns the human-readable reason for this approval requirement.
|
|
pub fn reason(&self) -> &str {
|
|
match self {
|
|
ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
|
|
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
|
|
ExecApprovalRequirement::Forbidden { reason } => reason,
|
|
}
|
|
}
|
|
|
|
/// Returns a short phase label: `"allowed"`, `"needs_approval"`, or `"forbidden"`.
|
|
pub fn phase(&self) -> &'static str {
|
|
match self {
|
|
ExecApprovalRequirement::Skip { .. } => "allowed",
|
|
ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
|
|
ExecApprovalRequirement::Forbidden { .. } => "forbidden",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The result of evaluating a command against the execution policy.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ExecPolicyDecision {
|
|
/// Whether the command is allowed to execute.
|
|
pub allow: bool,
|
|
/// Whether human approval is required before execution.
|
|
pub requires_approval: bool,
|
|
/// The detailed approval requirement, including any proposed amendments.
|
|
pub requirement: ExecApprovalRequirement,
|
|
/// The rule that matched, if any (e.g. a trusted prefix or ask rule label).
|
|
pub matched_rule: Option<String>,
|
|
}
|
|
|
|
impl ExecPolicyDecision {
|
|
/// Returns the human-readable reason for this decision.
|
|
pub fn reason(&self) -> &str {
|
|
self.requirement.reason()
|
|
}
|
|
}
|
|
|
|
/// Input context provided to the execution policy engine for a single check.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExecPolicyContext<'a> {
|
|
/// The shell command string being evaluated.
|
|
pub command: &'a str,
|
|
/// The current working directory at invocation time.
|
|
pub cwd: &'a str,
|
|
/// The tool name (e.g. `"exec_shell"`, `"edit_file"`). Defaults to `"exec_shell"` when `None`.
|
|
pub tool: Option<&'a str>,
|
|
/// An optional file path relevant to the invocation (used for path-based ask rules).
|
|
pub path: Option<&'a str>,
|
|
/// The current approval policy mode.
|
|
pub ask_for_approval: AskForApproval,
|
|
/// The sandbox mode in effect, if any (e.g. `"workspace-write"`).
|
|
pub sandbox_mode: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ExecPolicyEngine {
|
|
/// Layered rulesets (builtin → agent → user). When non-empty, takes precedence
|
|
/// over the legacy flat lists below.
|
|
rulesets: Vec<Ruleset>,
|
|
/// Legacy flat lists kept for backward compatibility with `new()`.
|
|
trusted_prefixes: Vec<String>,
|
|
denied_prefixes: Vec<String>,
|
|
approved_for_session: HashSet<String>,
|
|
/// Arity dictionary for command-prefix allow-rule matching.
|
|
arity_dict: BashArityDict,
|
|
}
|
|
|
|
impl ExecPolicyEngine {
|
|
/// Legacy constructor: wraps the two vecs into a User-layer ruleset.
|
|
pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
|
|
Self {
|
|
rulesets: vec![],
|
|
trusted_prefixes,
|
|
denied_prefixes,
|
|
approved_for_session: HashSet::new(),
|
|
arity_dict: BashArityDict::new(),
|
|
}
|
|
}
|
|
|
|
/// Build an engine from explicit layered rulesets.
|
|
/// Rulesets are sorted by layer priority on construction.
|
|
pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
|
|
rulesets.sort_by_key(|r| r.layer);
|
|
Self {
|
|
rulesets,
|
|
trusted_prefixes: vec![],
|
|
denied_prefixes: vec![],
|
|
approved_for_session: HashSet::new(),
|
|
arity_dict: BashArityDict::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a ruleset layer (re-sorts internally).
|
|
pub fn add_ruleset(&mut self, ruleset: Ruleset) {
|
|
self.rulesets.push(ruleset);
|
|
self.rulesets.sort_by_key(|r| r.layer);
|
|
}
|
|
|
|
/// Resolve the effective trusted/denied prefix sets by merging all rulesets.
|
|
///
|
|
/// Collects all prefixes from every layer (builtin → agent → user) into flat
|
|
/// trusted/denied lists. The `check()` method then applies deny-always-wins
|
|
/// semantics: any matching deny prefix blocks the command regardless of layer.
|
|
/// Trusted rules are only consulted after deny checks pass.
|
|
fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
|
|
if self.rulesets.is_empty() {
|
|
return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
|
|
}
|
|
// Collect all trusted/denied across all layers, highest-priority last so they
|
|
// shadow lower-priority entries with the same prefix.
|
|
let mut trusted: Vec<String> = vec![];
|
|
let mut denied: Vec<String> = vec![];
|
|
for rs in &self.rulesets {
|
|
trusted.extend(rs.trusted_prefixes.iter().cloned());
|
|
denied.extend(rs.denied_prefixes.iter().cloned());
|
|
}
|
|
// Also merge legacy flat lists as user-layer.
|
|
trusted.extend(self.trusted_prefixes.iter().cloned());
|
|
denied.extend(self.denied_prefixes.iter().cloned());
|
|
(trusted, denied)
|
|
}
|
|
|
|
fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option<ToolAskRule> {
|
|
let tool = ctx.tool.unwrap_or("exec_shell");
|
|
|
|
self.rulesets
|
|
.iter()
|
|
.flat_map(|ruleset| {
|
|
ruleset
|
|
.ask_rules
|
|
.iter()
|
|
.map(move |rule| (ruleset.layer, rule))
|
|
})
|
|
.filter(|(_, rule)| rule.tool == tool)
|
|
.filter(|(_, rule)| match rule.command.as_deref() {
|
|
Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
|
|
None => true,
|
|
})
|
|
.filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
|
|
(Some(pattern), Some(path)) => {
|
|
normalize_path_value(pattern) == normalize_path_value(path)
|
|
}
|
|
(Some(_), None) => false,
|
|
(None, _) => true,
|
|
})
|
|
.max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule)))
|
|
.map(|(_, rule)| rule.clone())
|
|
}
|
|
|
|
/// Records an approval key for the current session so subsequent checks skip approval.
|
|
pub fn remember_session_approval(&mut self, approval_key: String) {
|
|
self.approved_for_session.insert(approval_key);
|
|
}
|
|
|
|
/// Returns whether the given approval key has been recorded for this session.
|
|
pub fn is_session_approved(&self, approval_key: &str) -> bool {
|
|
self.approved_for_session.contains(approval_key)
|
|
}
|
|
|
|
/// Evaluates a command against the policy and returns a decision.
|
|
///
|
|
/// The evaluation order is: deny rules first (always win), then trusted prefix
|
|
/// matching (arity-aware), then typed ask rules, and finally the approval mode.
|
|
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
|
|
let normalized = normalize_command(ctx.command);
|
|
let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
|
|
// Deny rules use word-boundary prefix matching: the command must either
|
|
// equal the rule or start with the rule followed by a space, so "rm"
|
|
// blocks "rm -rf /" but NOT "rmdir" or "rmview".
|
|
if let Some(rule) = denied_prefixes.iter().find(|rule| {
|
|
let norm_rule = normalize_command(rule);
|
|
normalized == norm_rule
|
|
|| (normalized.starts_with(&norm_rule)
|
|
&& normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
|
|
}) {
|
|
return Ok(ExecPolicyDecision {
|
|
allow: false,
|
|
requires_approval: false,
|
|
matched_rule: Some(rule.clone()),
|
|
requirement: ExecApprovalRequirement::Forbidden {
|
|
reason: format!("Command blocked by denied prefix rule '{rule}'"),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Allow (trusted) rules use arity-aware prefix matching so that
|
|
// `auto_allow = ["git status"]` matches `git status -s` but NOT
|
|
// `git push origin main`.
|
|
let trusted_rule = trusted_prefixes
|
|
.iter()
|
|
.find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
|
|
.cloned();
|
|
let is_trusted = trusted_rule.is_some();
|
|
|
|
let ask_rule = self.matching_ask_rule(&ctx);
|
|
|
|
let mut matched_ask_rule = None;
|
|
// Resolve a matching typed ask-rule first. Ask-rules take precedence over
|
|
// mode-based handling for everything except `Never` (which forbids,
|
|
// because no prompt can be shown) and `Reject { rules: true }` (which
|
|
// explicitly rejects rule-exceptions). This ordering is checked against
|
|
// the experimental `if let` match-guard the original PR used; it is
|
|
// reproduced here with plain control flow for edition-2024 stable.
|
|
let ask_rule_requirement = match &ctx.ask_for_approval {
|
|
AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
|
|
_ => ask_rule.as_ref().map(|rule| {
|
|
matched_ask_rule = Some(rule.label());
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
|
|
proposed_execpolicy_amendment: None,
|
|
// A typed ask-rule approval (exec/fn/MCP) must not touch
|
|
// network policy. The original PR allow-listed `ctx.cwd` as a
|
|
// network host here, which is incorrect and security-relevant:
|
|
// approving e.g. an exec rule should never create a network
|
|
// allow-entry. Emit no network amendments for ask-rule prompts.
|
|
proposed_network_policy_amendments: Vec::new(),
|
|
}
|
|
}),
|
|
};
|
|
|
|
let requirement = if let Some(req) = ask_rule_requirement {
|
|
req
|
|
} else {
|
|
match &ctx.ask_for_approval {
|
|
AskForApproval::Never => {
|
|
if let Some(rule) = &ask_rule {
|
|
matched_ask_rule = Some(rule.label());
|
|
ExecApprovalRequirement::Forbidden {
|
|
reason: format!(
|
|
"Typed ask rule '{}' requires approval, but approval policy is never.",
|
|
rule.label()
|
|
),
|
|
}
|
|
} else {
|
|
ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: false,
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
}
|
|
}
|
|
AskForApproval::Reject { rules, .. } if *rules => {
|
|
ExecApprovalRequirement::Forbidden {
|
|
reason: "Policy is configured to reject rule-exceptions.".to_string(),
|
|
}
|
|
}
|
|
AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: false,
|
|
proposed_execpolicy_amendment: None,
|
|
},
|
|
AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: false,
|
|
proposed_execpolicy_amendment: None,
|
|
},
|
|
_ => ExecApprovalRequirement::NeedsApproval {
|
|
reason: if is_trusted {
|
|
"Approval requested by policy mode.".to_string()
|
|
} else {
|
|
"Unmatched command prefix requires approval.".to_string()
|
|
},
|
|
proposed_execpolicy_amendment: if is_trusted {
|
|
None
|
|
} else {
|
|
Some(ExecPolicyAmendment {
|
|
prefixes: vec![first_token(ctx.command)],
|
|
})
|
|
},
|
|
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
|
|
host: ctx.cwd.to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
}],
|
|
},
|
|
}
|
|
};
|
|
|
|
let (allow, requires_approval) = match requirement {
|
|
ExecApprovalRequirement::Skip { .. } => (true, false),
|
|
ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
|
|
ExecApprovalRequirement::Forbidden { .. } => (false, false),
|
|
};
|
|
|
|
Ok(ExecPolicyDecision {
|
|
allow,
|
|
requires_approval,
|
|
matched_rule: matched_ask_rule.or(trusted_rule),
|
|
requirement,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn normalize_command(value: &str) -> String {
|
|
// Normalize: lowercase, collapse internal whitespace to single spaces.
|
|
// This prevents bypass via "git status" (double space) vs "git status".
|
|
value
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
fn first_token(command: &str) -> String {
|
|
command
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
}
|
|
|
|
fn normalize_path_value(value: &str) -> String {
|
|
value
|
|
.replace('\\', "/")
|
|
.trim()
|
|
.trim_matches('/')
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
|
|
rule.tool.len()
|
|
+ rule
|
|
.command
|
|
.as_ref()
|
|
.map_or(0, |command| command.len() + 1000)
|
|
+ rule.path.as_ref().map_or(0, |path| path.len() + 1000)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
|
|
ExecPolicyContext {
|
|
command,
|
|
cwd: "/workspace",
|
|
tool: Some("exec_shell"),
|
|
path: None,
|
|
ask_for_approval,
|
|
sandbox_mode: Some("workspace-write"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
|
|
let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
|
|
|
|
let decision = engine
|
|
.check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
|
|
.unwrap();
|
|
|
|
assert!(decision.allow);
|
|
assert!(!decision.requires_approval);
|
|
assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
|
|
assert!(matches!(
|
|
decision.requirement,
|
|
ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: false,
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn denied_prefix_blocks_even_when_command_is_also_trusted() {
|
|
let engine = ExecPolicyEngine::new(
|
|
vec!["git status".to_string()],
|
|
vec!["git status".to_string()],
|
|
);
|
|
|
|
let decision = engine
|
|
.check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert!(!decision.requires_approval);
|
|
assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
|
|
assert!(matches!(
|
|
decision.requirement,
|
|
ExecApprovalRequirement::Forbidden { .. }
|
|
));
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Command blocked by denied prefix rule 'git status'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
|
|
let engine = ExecPolicyEngine::new(vec![], vec![]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
|
|
.unwrap();
|
|
|
|
assert!(decision.allow);
|
|
assert!(decision.requires_approval);
|
|
assert_eq!(decision.matched_rule, None);
|
|
match decision.requirement {
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
proposed_execpolicy_amendment: Some(amendment),
|
|
proposed_network_policy_amendments,
|
|
..
|
|
} => {
|
|
assert_eq!(amendment.prefixes, vec!["cargo"]);
|
|
assert_eq!(
|
|
proposed_network_policy_amendments,
|
|
vec![NetworkPolicyAmendment {
|
|
host: "/workspace".to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
}]
|
|
);
|
|
}
|
|
other => panic!("expected approval with proposed amendment, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
|
|
let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::OnRequest))
|
|
.unwrap();
|
|
|
|
assert!(decision.allow);
|
|
assert!(decision.requires_approval);
|
|
assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
|
|
match decision.requirement {
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
proposed_execpolicy_amendment,
|
|
..
|
|
} => assert_eq!(proposed_execpolicy_amendment, None),
|
|
other => panic!("expected approval without amendment, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reject_rules_mode_forbids_unmatched_command() {
|
|
let engine = ExecPolicyEngine::new(vec![], vec![]);
|
|
|
|
let decision = engine
|
|
.check(ctx(
|
|
"npm install",
|
|
AskForApproval::Reject {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
mcp_elicitations: false,
|
|
},
|
|
))
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert!(!decision.requires_approval);
|
|
assert_eq!(decision.matched_rule, None);
|
|
assert_eq!(decision.requirement.phase(), "forbidden");
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Policy is configured to reject rule-exceptions."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::Never))
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert!(!decision.requires_approval);
|
|
assert_eq!(
|
|
decision.matched_rule.as_deref(),
|
|
Some("tool=exec_shell command=cargo test")
|
|
);
|
|
assert_eq!(decision.requirement.phase(), "forbidden");
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_requires_approval_under_unless_trusted() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
|
|
.unwrap();
|
|
|
|
assert!(decision.allow);
|
|
assert!(decision.requires_approval);
|
|
assert_eq!(
|
|
decision.matched_rule.as_deref(),
|
|
Some("tool=exec_shell command=cargo test")
|
|
);
|
|
match decision.requirement {
|
|
ExecApprovalRequirement::NeedsApproval {
|
|
proposed_execpolicy_amendment,
|
|
proposed_network_policy_amendments,
|
|
..
|
|
} => {
|
|
assert_eq!(proposed_execpolicy_amendment, None);
|
|
// A typed ask-rule approval must not allow-list the cwd (or
|
|
// anything else) as a network host. See the NeedsApproval arm.
|
|
assert!(
|
|
proposed_network_policy_amendments.is_empty(),
|
|
"ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
|
|
);
|
|
}
|
|
other => panic!("expected typed ask approval, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_requires_approval_under_on_failure() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::OnFailure))
|
|
.unwrap();
|
|
|
|
assert!(decision.allow);
|
|
assert!(decision.requires_approval);
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_overrides_trusted_but_not_deny() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(
|
|
vec!["cargo test".to_string()],
|
|
vec!["cargo test --danger".to_string()],
|
|
)
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let trusted = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
|
|
.unwrap();
|
|
assert!(trusted.allow);
|
|
assert!(trusted.requires_approval);
|
|
assert_eq!(
|
|
trusted.matched_rule.as_deref(),
|
|
Some("tool=exec_shell command=cargo test")
|
|
);
|
|
|
|
let denied = engine
|
|
.check(ctx("cargo test --danger", AskForApproval::Never))
|
|
.unwrap();
|
|
assert!(!denied.allow);
|
|
assert!(!denied.requires_approval);
|
|
assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
|
|
assert_eq!(
|
|
denied.reason(),
|
|
"Command blocked by denied prefix rule 'cargo test --danger'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_prefers_higher_layer_before_specificity() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::agent(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx(
|
|
"cargo test --workspace --all-features",
|
|
AskForApproval::UnlessTrusted,
|
|
))
|
|
.unwrap();
|
|
|
|
assert!(decision.requires_approval);
|
|
assert_eq!(
|
|
decision.matched_rule.as_deref(),
|
|
Some("tool=exec_shell command=cargo test")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reject_rules_mode_still_forbids_matching_ask_rule() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx(
|
|
"cargo test --workspace",
|
|
AskForApproval::Reject {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
mcp_elicitations: false,
|
|
},
|
|
))
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert!(!decision.requires_approval);
|
|
assert_eq!(decision.matched_rule, None);
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Policy is configured to reject rule-exceptions."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec!["cargo test".to_string()], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ctx("cargo test --workspace", AskForApproval::Never))
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert_eq!(
|
|
decision.matched_rule.as_deref(),
|
|
Some("tool=exec_shell command=cargo test")
|
|
);
|
|
assert_eq!(
|
|
decision.reason(),
|
|
"Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ask_path_matching_trims_spaces_before_boundary_slashes() {
|
|
let engine = ExecPolicyEngine::with_rulesets(vec![
|
|
Ruleset::user(vec![], vec![])
|
|
.with_ask_rules(vec![ToolAskRule::file_path("edit_file", " /TMP/PROJECT/ ")]),
|
|
]);
|
|
|
|
let decision = engine
|
|
.check(ExecPolicyContext {
|
|
command: "",
|
|
cwd: "/workspace",
|
|
tool: Some("edit_file"),
|
|
path: Some("tmp/project"),
|
|
ask_for_approval: AskForApproval::Never,
|
|
sandbox_mode: Some("workspace-write"),
|
|
})
|
|
.unwrap();
|
|
|
|
assert!(!decision.allow);
|
|
assert_eq!(
|
|
decision.matched_rule.as_deref(),
|
|
Some("tool=edit_file path= /TMP/PROJECT/ ")
|
|
);
|
|
}
|
|
}
|