mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
refactor(authority): move auto-review and tool-call risk policy out of tui/ (#6935 RS-8)
The policy that decides Allow / Block / AskUser for a tool call renders
nothing, yet lived under `tui/`, so config, the engine and the subagent tools
all reached up into the terminal namespace for it. It now lives in
`core::authority`:
- `tui/auto_review.rs` -> `core/authority/auto_review.rs` (git mv, content
unchanged apart from its one `tui::approval` import)
- `RiskLevel` / `classify_risk` move from `tui/approval/policy.rs` into
`core/authority/risk.rs` verbatim (imports and module docs only differ), and
are re-exported from `core::authority`
- every `crate::tui::auto_review::` reference (89) repointed
Predecessor replaced: the `tui::auto_review` module and the risk half of
`tui::approval::policy`. `ApprovalStakes` and `ToolCategory` stay with the
approval views.
Evidence (local only; no CI, provider or product run):
- `nextest -p codewhale-tui` filtered to auto_review|risk|approval|authority|
reviewer|subagent: 1522 passed, 0 failed
- `check-runtime-contract-budget.py`: PASS, all 55 metrics exactly at budget
- `cargo fmt --all -- --check`: clean
- `grep tui::auto_review|tui::approval::{RiskLevel,classify_risk}` over
crates/: empty
Not covered / known failing on clean HEAD, unrelated to this change (both
reproduced with RS-8 reverted):
- `event_capacity_cancelled_parallel_tool_keeps_completed_span_and_call_when_available`
fails at test_cases_01.rs:770
- `full_access_permission_allow_runs_background_destructive_shell_without_a_hold`
does not finish (killed at 419s)
The full workspace suite was not run.
Refs #6935
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
88d81e64a7
commit
d776dcb61f
20 files changed
+452
-422
No files matched your search
+33
-23
@@ -2759,14 +2759,17 @@ pub struct AutoReviewRuleConfig {
|
||||
}
|
||||
|
||||
impl AutoReviewConfig {
|
||||
fn to_runtime_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
|
||||
crate::tui::auto_review::AutoReviewPolicy {
|
||||
fn to_runtime_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
allow_rules: self
|
||||
.allow
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| {
|
||||
rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Allow)
|
||||
rule.to_runtime_rule(
|
||||
index,
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
block_rules: self
|
||||
@@ -2774,7 +2777,10 @@ impl AutoReviewConfig {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| {
|
||||
rule.to_runtime_rule(index, crate::tui::auto_review::AutoReviewAction::Block)
|
||||
rule.to_runtime_rule(
|
||||
index,
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -2791,12 +2797,12 @@ impl AutoReviewRuleConfig {
|
||||
fn to_runtime_rule(
|
||||
&self,
|
||||
index: usize,
|
||||
action: crate::tui::auto_review::AutoReviewAction,
|
||||
) -> crate::tui::auto_review::AutoReviewRule {
|
||||
action: crate::core::authority::auto_review::AutoReviewAction,
|
||||
) -> crate::core::authority::auto_review::AutoReviewRule {
|
||||
let id_prefix = match action {
|
||||
crate::tui::auto_review::AutoReviewAction::Allow => "allow",
|
||||
crate::tui::auto_review::AutoReviewAction::Block => "block",
|
||||
crate::tui::auto_review::AutoReviewAction::AskUser => "ask",
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow => "allow",
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block => "block",
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser => "ask",
|
||||
};
|
||||
let id = self
|
||||
.id
|
||||
@@ -2813,14 +2819,14 @@ impl AutoReviewRuleConfig {
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
|
||||
let mut rule = match action {
|
||||
crate::tui::auto_review::AutoReviewAction::Allow => {
|
||||
crate::tui::auto_review::AutoReviewRule::allow(id, reason)
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::allow(id, reason)
|
||||
}
|
||||
crate::tui::auto_review::AutoReviewAction::Block => {
|
||||
crate::tui::auto_review::AutoReviewRule::block(id, reason)
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
|
||||
}
|
||||
crate::tui::auto_review::AutoReviewAction::AskUser => {
|
||||
crate::tui::auto_review::AutoReviewRule::block(id, reason)
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2893,16 +2899,20 @@ fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_auto_review_action_kind(raw: &str) -> Option<crate::tui::auto_review::ToolActionKind> {
|
||||
fn parse_auto_review_action_kind(
|
||||
raw: &str,
|
||||
) -> Option<crate::core::authority::auto_review::ToolActionKind> {
|
||||
match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"read" | "mcp_read" => Some(crate::tui::auto_review::ToolActionKind::Read),
|
||||
"write" => Some(crate::tui::auto_review::ToolActionKind::Write),
|
||||
"shell" => Some(crate::tui::auto_review::ToolActionKind::Shell),
|
||||
"read" | "mcp_read" => Some(crate::core::authority::auto_review::ToolActionKind::Read),
|
||||
"write" => Some(crate::core::authority::auto_review::ToolActionKind::Write),
|
||||
"shell" => Some(crate::core::authority::auto_review::ToolActionKind::Shell),
|
||||
"external" | "network" | "git" | "mcp_action" | "browser" | "unknown" => {
|
||||
Some(crate::tui::auto_review::ToolActionKind::External)
|
||||
Some(crate::core::authority::auto_review::ToolActionKind::External)
|
||||
}
|
||||
"publish" => Some(crate::core::authority::auto_review::ToolActionKind::Publish),
|
||||
"destructive" | "secret" => {
|
||||
Some(crate::core::authority::auto_review::ToolActionKind::Destructive)
|
||||
}
|
||||
"publish" => Some(crate::tui::auto_review::ToolActionKind::Publish),
|
||||
"destructive" | "secret" => Some(crate::tui::auto_review::ToolActionKind::Destructive),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -4322,7 +4332,7 @@ impl Config {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auto_review_policy(&self) -> crate::tui::auto_review::AutoReviewPolicy {
|
||||
pub fn auto_review_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
self.auto_review
|
||||
.as_ref()
|
||||
.map(AutoReviewConfig::to_runtime_policy)
|
||||
|
||||
@@ -1057,10 +1057,10 @@ reason = "read_file is allowed"
|
||||
config.validate()?;
|
||||
|
||||
let policy = config.auto_review_policy();
|
||||
let shell_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
|
||||
let shell_context = crate::core::authority::auto_review::AutoReviewContext::from_tool_call(
|
||||
"exec_shell",
|
||||
&serde_json::json!({"command": "cargo test"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
codewhale_execpolicy::ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1068,14 +1068,14 @@ reason = "read_file is allowed"
|
||||
let shell_decision = policy.evaluate(&shell_context);
|
||||
assert_eq!(
|
||||
shell_decision.action,
|
||||
crate::tui::auto_review::AutoReviewAction::Block
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block
|
||||
);
|
||||
assert_eq!(shell_decision.rule_id.as_deref(), Some("block-shell"));
|
||||
|
||||
let read_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
|
||||
let read_context = crate::core::authority::auto_review::AutoReviewContext::from_tool_call(
|
||||
"read_file",
|
||||
&serde_json::json!({"path": "README.md"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
codewhale_execpolicy::ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1083,7 +1083,7 @@ reason = "read_file is allowed"
|
||||
let read_decision = policy.evaluate(&read_context);
|
||||
assert_eq!(
|
||||
read_decision.action,
|
||||
crate::tui::auto_review::AutoReviewAction::Allow
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow
|
||||
);
|
||||
assert_eq!(read_decision.rule_id.as_deref(), Some("allow-read-file"));
|
||||
|
||||
@@ -1111,7 +1111,7 @@ fn auto_review_scenario() -> Result<()> {
|
||||
assert_eq!(policy.block_rules.len(), 1);
|
||||
assert_eq!(
|
||||
policy.block_rules[0].action_kind,
|
||||
Some(crate::tui::auto_review::ToolActionKind::External)
|
||||
Some(crate::core::authority::auto_review::ToolActionKind::External)
|
||||
);
|
||||
}
|
||||
// from auto_review_text_contains_fails_closed_instead_of_broadening_a_rule
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
//! in one place so prompt metadata, tool catalogs, and runtime gates cannot
|
||||
//! drift independently.
|
||||
|
||||
pub mod auto_review;
|
||||
pub mod risk;
|
||||
|
||||
pub use risk::{RiskLevel, classify_risk};
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
pub use crate::core::authority::RunOrigin;
|
||||
|
||||
use crate::tui::approval::{RiskLevel, ToolCategory, classify_risk, get_tool_category_for_call};
|
||||
use crate::core::authority::{RiskLevel, ToolCategory, classify_risk, get_tool_category_for_call};
|
||||
use codewhale_execpolicy::ApprovalMode;
|
||||
use serde_json::{Value, json};
|
||||
use std::borrow::Cow;
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Tool-call risk policy.
|
||||
//!
|
||||
//! Classifies a call so the engine's gates and the approval views agree on
|
||||
//! how much the call can touch. Intentionally UI-free.
|
||||
|
||||
use crate::tools::canonical_action::canonical_action_alias;
|
||||
use codewhale_execpolicy::command_safety::is_parallel_readonly_command;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ToolCategory;
|
||||
|
||||
/// Stakes-based variant for the takeover modal.
|
||||
///
|
||||
/// `RiskLevel::Benign` lets a single keystroke commit the approval.
|
||||
/// `RiskLevel::Destructive` keeps stronger warning copy and styling
|
||||
/// around approvals that can touch files, shell, or remote state.
|
||||
///
|
||||
/// Routing rules live in [`classify_risk`] - when in doubt, route to
|
||||
/// `Destructive`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskLevel {
|
||||
Benign,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
/// Decide the stakes variant for an approval request.
|
||||
///
|
||||
/// The bias is conservative: a category we don't recognise routes to
|
||||
/// `Destructive`, and any shell command that `command_safety` flags as
|
||||
/// `Dangerous` is forced to `Destructive` even when the rest of the
|
||||
/// request looks calm. The split lets the modal render stronger warning
|
||||
/// copy on anything that can touch state outside this turn.
|
||||
#[must_use]
|
||||
pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
|
||||
let tool_name = canonical_action_alias(tool_name, params);
|
||||
match category {
|
||||
// Read paths and discovery.
|
||||
ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
|
||||
// Query-only network is benign; opening a URL pulls arbitrary
|
||||
// remote content, so it stays destructive.
|
||||
ToolCategory::Network => match tool_name {
|
||||
"web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign,
|
||||
// web_run is benign for search/query, but its `open`/`click`
|
||||
// actions fetch model-supplied URLs (arbitrary remote content) -
|
||||
// destructive, consistent with fetch_url.
|
||||
"web_run" => {
|
||||
let fetches_url = params
|
||||
.get("open")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
|| params
|
||||
.get("click")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|a| !a.is_empty());
|
||||
if fetches_url {
|
||||
RiskLevel::Destructive
|
||||
} else {
|
||||
RiskLevel::Benign
|
||||
}
|
||||
}
|
||||
_ => RiskLevel::Destructive,
|
||||
},
|
||||
// Shell stays destructive unless the existing command-safety analyzer
|
||||
// can prove the concrete command is read-only.
|
||||
ToolCategory::Shell => {
|
||||
if let Some(cmd) = params.get("command").and_then(Value::as_str)
|
||||
&& is_parallel_readonly_command(cmd)
|
||||
{
|
||||
return RiskLevel::Benign;
|
||||
}
|
||||
RiskLevel::Destructive
|
||||
}
|
||||
// Sub-agent lifecycle: status/peek are inspection-only. Starts and
|
||||
// other actions keep the explicit-options keymap (the child's own
|
||||
// gates govern what it may do once running).
|
||||
ToolCategory::Agent => match params.get("action").and_then(Value::as_str) {
|
||||
Some("status" | "peek" | "list") => RiskLevel::Benign,
|
||||
_ => RiskLevel::Destructive,
|
||||
},
|
||||
// File writes, MCP actions, unclassified surfaces - all require
|
||||
// explicit confirmation.
|
||||
ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => {
|
||||
RiskLevel::Destructive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::authority::{get_tool_category, get_tool_category_for_call};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn classifies_read_only_surfaces_as_benign() {
|
||||
for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] {
|
||||
let category = get_tool_category(name);
|
||||
assert_eq!(
|
||||
classify_risk(name, category, &json!({})),
|
||||
RiskLevel::Benign,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_stateful_or_unknown_surfaces_as_destructive() {
|
||||
for name in [
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"mcp_linear_save_issue",
|
||||
"fetch_url",
|
||||
"unknown_tool",
|
||||
] {
|
||||
let category = get_tool_category(name);
|
||||
assert_eq!(
|
||||
classify_risk(name, category, &json!({})),
|
||||
RiskLevel::Destructive,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_risk_uses_command_safety_analysis() {
|
||||
let category = get_tool_category("exec_shell");
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"exec_shell",
|
||||
category,
|
||||
&json!({"command": "git status --short"})
|
||||
),
|
||||
RiskLevel::Benign
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"exec_shell",
|
||||
category,
|
||||
&json!({"command": "rm -rf /tmp/example"})
|
||||
),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_exec_flags_are_not_benign() {
|
||||
let category = get_tool_category("exec_shell");
|
||||
for command in [
|
||||
"fd -x ./pwn.sh",
|
||||
"fd -uHtx ./pwn.sh",
|
||||
"rg --pre /tmp/evil.sh needle .",
|
||||
"git grep -O needle",
|
||||
"git grep -nO needle",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_risk("exec_shell", category, &json!({"command": command})),
|
||||
RiskLevel::Destructive,
|
||||
"{command} should not be classified as benign"
|
||||
);
|
||||
}
|
||||
|
||||
for command in [
|
||||
"fd -e rs .",
|
||||
"fd -H --type f src",
|
||||
"rg needle crates/",
|
||||
"git grep needle crates/",
|
||||
"git grep -n needle crates/",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_risk("exec_shell", category, &json!({"command": command})),
|
||||
RiskLevel::Benign,
|
||||
"{command} should remain benign"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_run_open_and_click_fetch_remote_content() {
|
||||
let category = get_tool_category("web_run");
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"web_run",
|
||||
category,
|
||||
&json!({"search_query": [{"q": "rust"}]})
|
||||
),
|
||||
RiskLevel::Benign
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"web_run",
|
||||
category,
|
||||
&json!({"click": [{"ref_id": "x", "id": 1}]})
|
||||
),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_actions_keep_legacy_approval_categories_and_risk() {
|
||||
let cases = [
|
||||
("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive),
|
||||
("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive),
|
||||
(
|
||||
"Bash",
|
||||
"interact",
|
||||
ToolCategory::Shell,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"Bash",
|
||||
"cancel",
|
||||
ToolCategory::Shell,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("File", "read", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("File", "list", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("File", "search_name", ToolCategory::Safe, RiskLevel::Benign),
|
||||
(
|
||||
"File",
|
||||
"search_content",
|
||||
ToolCategory::Safe,
|
||||
RiskLevel::Benign,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"write",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"edit",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"patch",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Git", "status", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "diff", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "log", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "show", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "blame", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "commit_plan", ToolCategory::Safe, RiskLevel::Benign),
|
||||
(
|
||||
"Run",
|
||||
"tests",
|
||||
ToolCategory::Unknown,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"Run",
|
||||
"verifiers",
|
||||
ToolCategory::Unknown,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Web", "search", ToolCategory::Network, RiskLevel::Benign),
|
||||
(
|
||||
"Web",
|
||||
"fetch",
|
||||
ToolCategory::Network,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Web", "wait", ToolCategory::Network, RiskLevel::Benign),
|
||||
];
|
||||
|
||||
for (family, action, expected_category, expected_risk) in cases {
|
||||
let params = json!({"action": action});
|
||||
let category = get_tool_category_for_call(family, ¶ms);
|
||||
assert_eq!(category, expected_category, "{family}.{action}");
|
||||
assert_eq!(
|
||||
classify_risk(family, category, ¶ms),
|
||||
expected_risk,
|
||||
"{family}.{action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ pub struct EngineConfig {
|
||||
/// Feature flags controlling tool availability.
|
||||
pub features: Features,
|
||||
/// Deterministic auto-review policy for tool calls.
|
||||
pub auto_review_policy: crate::tui::auto_review::AutoReviewPolicy,
|
||||
pub auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy,
|
||||
/// Auto-compaction settings for long conversations.
|
||||
pub compaction: CompactionConfig,
|
||||
/// Shared Todo list state.
|
||||
@@ -604,7 +604,7 @@ impl Default for EngineConfig {
|
||||
launch_concurrency: DEFAULT_MAX_SUBAGENTS,
|
||||
subagents_enabled: true,
|
||||
features: Features::with_defaults(),
|
||||
auto_review_policy: crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
compaction: CompactionConfig::default(),
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
@@ -909,7 +909,7 @@ pub struct Engine {
|
||||
subagent_manager: SharedSubAgentManager,
|
||||
/// The deterministic Auto-Review policy shared with every child runtime
|
||||
/// so children are gated by the same rules as the parent turn.
|
||||
shared_auto_review_policy: Arc<crate::tui::auto_review::AutoReviewPolicy>,
|
||||
shared_auto_review_policy: Arc<crate::core::authority::auto_review::AutoReviewPolicy>,
|
||||
shell_manager: SharedShellManager,
|
||||
/// Read-before-edit snapshots live for the session, not for one turn's
|
||||
/// transient `ToolContext` (#4475).
|
||||
@@ -8945,8 +8945,8 @@ pub(crate) fn auto_review_run_origin_for_plan(detached_start: bool) -> RunOrigin
|
||||
}
|
||||
|
||||
pub(crate) fn auto_review_plan_decision_for_context(
|
||||
policy: &crate::tui::auto_review::AutoReviewPolicy,
|
||||
context: &crate::tui::auto_review::AutoReviewContext<'_>,
|
||||
policy: &crate::core::authority::auto_review::AutoReviewPolicy,
|
||||
context: &crate::core::authority::auto_review::AutoReviewContext<'_>,
|
||||
) -> (AutoReviewPlanDecision, Value) {
|
||||
let decision = policy.evaluate(context);
|
||||
let audit_event = policy.audit_event(context, &decision);
|
||||
@@ -8959,13 +8959,17 @@ pub(crate) fn auto_review_plan_decision_for_context(
|
||||
AutoReviewPlanDecision::Allow
|
||||
} else {
|
||||
match decision.action {
|
||||
crate::tui::auto_review::AutoReviewAction::Allow
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow
|
||||
if context.approval_mode == ApprovalMode::Auto =>
|
||||
{
|
||||
AutoReviewPlanDecision::Allow
|
||||
}
|
||||
crate::tui::auto_review::AutoReviewAction::Allow => AutoReviewPlanDecision::NoChange,
|
||||
crate::tui::auto_review::AutoReviewAction::AskUser if decision.built_in_safety_gate => {
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow => {
|
||||
AutoReviewPlanDecision::NoChange
|
||||
}
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser
|
||||
if decision.built_in_safety_gate =>
|
||||
{
|
||||
// Name the built-in gate honestly.
|
||||
let reason = format!(
|
||||
"Built-in safety gate requires approval: {}",
|
||||
@@ -8983,13 +8987,15 @@ pub(crate) fn auto_review_plan_decision_for_context(
|
||||
AutoReviewPlanDecision::ForcePrompt(reason)
|
||||
}
|
||||
}
|
||||
crate::tui::auto_review::AutoReviewAction::AskUser
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser
|
||||
if context.approval_mode == ApprovalMode::Auto =>
|
||||
{
|
||||
AutoReviewPlanDecision::ConsultReviewer(decision.reason.clone())
|
||||
}
|
||||
crate::tui::auto_review::AutoReviewAction::AskUser => AutoReviewPlanDecision::NoChange,
|
||||
crate::tui::auto_review::AutoReviewAction::Block => {
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser => {
|
||||
AutoReviewPlanDecision::NoChange
|
||||
}
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block => {
|
||||
AutoReviewPlanDecision::Block(format!(
|
||||
"Auto-review policy blocked tool '{}': {}",
|
||||
context.tool_name, decision.reason
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Model-backed Auto-Review guardian tier (v0.9.8).
|
||||
//!
|
||||
//! The deterministic policy engine (see [`crate::tui::auto_review`]) decides
|
||||
//! The deterministic policy engine (see [`crate::core::authority::auto_review`]) decides
|
||||
//! first: configured block rules and the built-in safety floor are hard
|
||||
//! blocks that never reach a model. Only deterministic *fallback holds* — the
|
||||
//! `AskUser` outcomes Auto posture would otherwise turn into bare permission
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::core::model_client::ModelClient;
|
||||
use crate::tools::spec::ToolError;
|
||||
use crate::tui::auto_review::{
|
||||
use crate::core::authority::auto_review::{
|
||||
AutoReviewAction, DEFAULT_GUARDIAN_POLICY, ReviewerRiskLevel, ReviewerVerdict,
|
||||
parse_reviewer_verdict,
|
||||
};
|
||||
use crate::core::model_client::ModelClient;
|
||||
use crate::tools::spec::ToolError;
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::{
|
||||
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! driver is Engine::run_turn; the RPC adapter owns its bounded invocation.
|
||||
use super::turn_loop::usage_has_reported_data;
|
||||
use super::*;
|
||||
use crate::core::authority::auto_review::AutoReviewPolicy;
|
||||
use crate::rlm::bridge::{RlmUsageAccumulator, RlmUsageReservation};
|
||||
use crate::rlm::turn::{RlmRoundTrace, RlmTermination, RlmTurnResult};
|
||||
use crate::tui::auto_review::AutoReviewPolicy;
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -1322,15 +1322,15 @@ async fn operate_conversation_reaches_provider_when_workers_are_disabled() {
|
||||
}
|
||||
|
||||
fn auto_review_plan_decision(
|
||||
policy: &crate::tui::auto_review::AutoReviewPolicy,
|
||||
policy: &crate::core::authority::auto_review::AutoReviewPolicy,
|
||||
tool_name: &str,
|
||||
tool_input: &Value,
|
||||
run_origin: crate::tui::auto_review::RunOrigin,
|
||||
run_origin: crate::core::authority::auto_review::RunOrigin,
|
||||
approval_mode: ApprovalMode,
|
||||
workspace_trusted: bool,
|
||||
workspace: Option<&Path>,
|
||||
) -> (AutoReviewPlanDecision, Value) {
|
||||
let context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
|
||||
let context = crate::core::authority::auto_review::AutoReviewContext::from_tool_call(
|
||||
tool_name,
|
||||
tool_input,
|
||||
run_origin,
|
||||
@@ -1347,10 +1347,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_classifies_publish_and_holds_without_prompting
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "git push origin main"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1369,10 +1369,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_classifier_allow_executes_without_prompting
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"read_file",
|
||||
&json!({"path": "Cargo.toml"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1384,10 +1384,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_allows_ordinary_shell_probe_without_prompting
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "git remote -v && git rev-parse --show-toplevel && git branch --show-current && git rev-parse HEAD && git tag --list 'v0.8.65'"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1400,10 +1400,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_routes_unknown_tool_to_reviewer_in_auto
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"mystery_tool",
|
||||
&json!({"value": true}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1420,10 +1420,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_policy_blocks_publish_when_approval_is_never
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"github_publish_release",
|
||||
&json!({"tag": "v0.8.64"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Never,
|
||||
true,
|
||||
None,
|
||||
@@ -1442,10 +1442,10 @@ fn auto_review_scenario() {
|
||||
// from auto_review_allows_ordinary_test_command_without_prompting
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "cargo test"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -1461,10 +1461,10 @@ fn auto_review_scenario() {
|
||||
std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
|
||||
std::fs::create_dir(tmp.path().join("src")).expect("source directory");
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"write_file",
|
||||
&json!({"path": "src/lib.rs", "content": "pub fn ready() {}\n"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
Some(tmp.path()),
|
||||
@@ -1480,10 +1480,10 @@ fn auto_review_scenario() {
|
||||
std::fs::create_dir(tmp.path().join(".git")).expect("git marker");
|
||||
for path in ["../outside.rs", "/etc/hostname", ".env", ".git/config"] {
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"write_file",
|
||||
&json!({"path": path, "content": "blocked"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
Some(tmp.path()),
|
||||
|
||||
@@ -338,10 +338,10 @@ fn auto_review_scenario_2() {
|
||||
// from auto_review_routes_interactive_destructive_shell_to_reviewer
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "rm -rf /"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -363,10 +363,10 @@ fn auto_review_scenario_2() {
|
||||
("read_secret", json!({"name": "provider-token"})),
|
||||
] {
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
tool_name,
|
||||
&input,
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -386,20 +386,20 @@ fn auto_review_scenario_2() {
|
||||
{
|
||||
assert_eq!(
|
||||
auto_review_run_origin_for_plan(false),
|
||||
crate::tui::auto_review::RunOrigin::Interactive
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive
|
||||
);
|
||||
assert_eq!(
|
||||
auto_review_run_origin_for_plan(true),
|
||||
crate::tui::auto_review::RunOrigin::Background
|
||||
crate::core::authority::auto_review::RunOrigin::Background
|
||||
);
|
||||
}
|
||||
// from auto_review_policy_holds_background_destructive_under_suggest
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "rm -rf ~/", "background": true}),
|
||||
crate::tui::auto_review::RunOrigin::Background,
|
||||
crate::core::authority::auto_review::RunOrigin::Background,
|
||||
ApprovalMode::Suggest,
|
||||
true,
|
||||
None,
|
||||
@@ -418,10 +418,10 @@ fn auto_review_scenario_2() {
|
||||
// from auto_review_policy_blocks_background_destructive_under_never
|
||||
{
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": "rm -rf ~/", "background": true}),
|
||||
crate::tui::auto_review::RunOrigin::Background,
|
||||
crate::core::authority::auto_review::RunOrigin::Background,
|
||||
ApprovalMode::Never,
|
||||
true,
|
||||
None,
|
||||
@@ -462,10 +462,10 @@ fn auto_review_routes_shell_commands_requiring_approval_to_reviewer() {
|
||||
"cargo test $(curl https://example.com)",
|
||||
] {
|
||||
let (decision, audit) = auto_review_plan_decision(
|
||||
&crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
&crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
"exec_shell",
|
||||
&json!({"command": command}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
@@ -492,13 +492,13 @@ fn auto_review_routes_shell_commands_requiring_approval_to_reviewer() {
|
||||
|
||||
#[test]
|
||||
fn auto_review_plan_decision_uses_configured_policy() {
|
||||
let policy = crate::tui::auto_review::AutoReviewPolicy {
|
||||
let policy = crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
block_rules: vec![
|
||||
crate::tui::auto_review::AutoReviewRule::block(
|
||||
crate::core::authority::auto_review::AutoReviewRule::block(
|
||||
"configured-shell-block",
|
||||
"shell requires maintainer review",
|
||||
)
|
||||
.action_kind(crate::tui::auto_review::ToolActionKind::Shell),
|
||||
.action_kind(crate::core::authority::auto_review::ToolActionKind::Shell),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -507,7 +507,7 @@ fn auto_review_plan_decision_uses_configured_policy() {
|
||||
&policy,
|
||||
"exec_shell",
|
||||
&json!({"command": "cargo test"}),
|
||||
crate::tui::auto_review::RunOrigin::Interactive,
|
||||
crate::core::authority::auto_review::RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
true,
|
||||
None,
|
||||
|
||||
@@ -1296,14 +1296,17 @@ impl Engine {
|
||||
async fn consult_auto_review_guardian(
|
||||
&self,
|
||||
client: &dyn crate::core::model_client::ModelClient,
|
||||
context: &crate::tui::auto_review::AutoReviewContext<'_>,
|
||||
context: &crate::core::authority::auto_review::AutoReviewContext<'_>,
|
||||
tool_input: &Value,
|
||||
held_reason: &str,
|
||||
tool_id: &str,
|
||||
turn: &mut TurnContext,
|
||||
) -> Result<(), ToolError> {
|
||||
let context_text =
|
||||
crate::tui::auto_review::build_reviewer_context(context, held_reason, tool_input);
|
||||
let context_text = crate::core::authority::auto_review::build_reviewer_context(
|
||||
context,
|
||||
held_reason,
|
||||
tool_input,
|
||||
);
|
||||
let _ = self
|
||||
.send_event(Event::status(format!(
|
||||
"Auto-Review checking '{}'",
|
||||
@@ -2150,7 +2153,7 @@ impl Engine {
|
||||
|
||||
if blocked_error.is_none() {
|
||||
let review_context =
|
||||
crate::tui::auto_review::AutoReviewContext::from_tool_call_async(
|
||||
crate::core::authority::auto_review::AutoReviewContext::from_tool_call_async(
|
||||
&tool_name,
|
||||
&tool_input,
|
||||
if self.is_acp_turn() {
|
||||
|
||||
@@ -2940,7 +2940,7 @@ pub struct SubAgentRuntime {
|
||||
pub parent_mode: AppMode,
|
||||
/// The session's deterministic Auto-Review policy (configured allow/block
|
||||
/// rules plus the built-in safety floor), shared with every descendant.
|
||||
pub auto_review_policy: std::sync::Arc<crate::tui::auto_review::AutoReviewPolicy>,
|
||||
pub auto_review_policy: std::sync::Arc<crate::core::authority::auto_review::AutoReviewPolicy>,
|
||||
/// Whether the host can answer an approval prompt for a child (an
|
||||
/// interactive TUI). Headless hosts keep the fail-closed denial.
|
||||
pub parent_can_prompt: bool,
|
||||
@@ -3019,7 +3019,7 @@ impl SubAgentRuntime {
|
||||
todos: crate::tools::todo::new_shared_todo_list(),
|
||||
parent_mode: AppMode::Agent,
|
||||
auto_review_policy: std::sync::Arc::new(
|
||||
crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
),
|
||||
parent_can_prompt: false,
|
||||
approval_receipt_store: None,
|
||||
@@ -3056,7 +3056,7 @@ impl SubAgentRuntime {
|
||||
#[must_use]
|
||||
pub fn with_permission_posture(
|
||||
mut self,
|
||||
auto_review_policy: std::sync::Arc<crate::tui::auto_review::AutoReviewPolicy>,
|
||||
auto_review_policy: std::sync::Arc<crate::core::authority::auto_review::AutoReviewPolicy>,
|
||||
parent_can_prompt: bool,
|
||||
) -> Self {
|
||||
self.auto_review_policy = auto_review_policy;
|
||||
|
||||
@@ -15397,7 +15397,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime {
|
||||
fork_context: None,
|
||||
parent_mode: AppMode::Agent,
|
||||
auto_review_policy: std::sync::Arc::new(
|
||||
crate::tui::auto_review::AutoReviewPolicy::default(),
|
||||
crate::core::authority::auto_review::AutoReviewPolicy::default(),
|
||||
),
|
||||
parent_can_prompt: false,
|
||||
approval_receipt_store: Some(Ok(crate::approval_log::ApprovalReceiptStore::new(
|
||||
@@ -23938,7 +23938,7 @@ mod child_permission_gate {
|
||||
crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions")),
|
||||
));
|
||||
runtime = runtime.with_permission_posture(
|
||||
std::sync::Arc::new(crate::tui::auto_review::AutoReviewPolicy::default()),
|
||||
std::sync::Arc::new(crate::core::authority::auto_review::AutoReviewPolicy::default()),
|
||||
parent_can_prompt,
|
||||
);
|
||||
let manager = Arc::clone(&runtime.manager);
|
||||
@@ -25460,7 +25460,7 @@ mod child_permission_gate {
|
||||
"rm -rf {}",
|
||||
shlex::try_quote(&build.to_string_lossy()).unwrap()
|
||||
);
|
||||
// Parent classification is covered in tui::auto_review; this runtime
|
||||
// Parent classification is covered in core::authority::auto_review; this runtime
|
||||
// test proves the child actually executes and preserves the gate receipt.
|
||||
registry
|
||||
.execute("agent_gate", "bash", json!({"command": command}))
|
||||
|
||||
@@ -72,10 +72,9 @@ use previews::{
|
||||
pub use view::ApprovalOption;
|
||||
pub use view::ApprovalView;
|
||||
|
||||
pub use policy::{
|
||||
ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes,
|
||||
get_tool_category_for_call,
|
||||
};
|
||||
pub use policy::{ApprovalStakes, ToolCategory, classify_stakes, get_tool_category_for_call};
|
||||
|
||||
use crate::core::authority::{RiskLevel, classify_risk};
|
||||
|
||||
/// User's decision for a pending approval
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Approval risk and stakes policy.
|
||||
//! Approval stakes for the takeover modal.
|
||||
//!
|
||||
//! This module is intentionally UI-free: it classifies tool calls so the
|
||||
//! approval and elevation views can render the decision without owning the
|
||||
//! policy itself.
|
||||
//! The risk decision itself lives in `crate::core::authority::risk`. This maps
|
||||
//! it onto the three visual weights the approval views render.
|
||||
|
||||
use crate::core::authority::RiskLevel;
|
||||
use crate::tools::canonical_action::canonical_action_alias;
|
||||
use codewhale_execpolicy::command_safety::is_parallel_readonly_command;
|
||||
use serde_json::Value;
|
||||
|
||||
// Tool categorization is runtime policy (the extension host and auto-review
|
||||
@@ -13,20 +12,6 @@ use serde_json::Value;
|
||||
// approval views.
|
||||
pub use crate::core::authority::{ToolCategory, get_tool_category_for_call};
|
||||
|
||||
/// Stakes-based variant for the takeover modal.
|
||||
///
|
||||
/// `RiskLevel::Benign` lets a single keystroke commit the approval.
|
||||
/// `RiskLevel::Destructive` keeps stronger warning copy and styling
|
||||
/// around approvals that can touch files, shell, or remote state.
|
||||
///
|
||||
/// Routing rules live in [`classify_risk`] - when in doubt, route to
|
||||
/// `Destructive`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskLevel {
|
||||
Benign,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
/// Presentation-level stakes for the approval prompt (#3883 follow-up).
|
||||
///
|
||||
/// `RiskLevel` drives keymaps and stays conservative ("not provably
|
||||
@@ -58,276 +43,16 @@ pub fn classify_stakes(
|
||||
return ApprovalStakes::Routine;
|
||||
}
|
||||
let semantic_name = canonical_action_alias(tool_name, params);
|
||||
match crate::tui::auto_review::ToolActionKind::from_tool_call(
|
||||
match crate::core::authority::auto_review::ToolActionKind::from_tool_call(
|
||||
semantic_name,
|
||||
params,
|
||||
category,
|
||||
None,
|
||||
) {
|
||||
crate::tui::auto_review::ToolActionKind::Publish
|
||||
| crate::tui::auto_review::ToolActionKind::Destructive => ApprovalStakes::Critical,
|
||||
crate::core::authority::auto_review::ToolActionKind::Publish
|
||||
| crate::core::authority::auto_review::ToolActionKind::Destructive => {
|
||||
ApprovalStakes::Critical
|
||||
}
|
||||
_ => ApprovalStakes::Elevated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide the stakes variant for an approval request.
|
||||
///
|
||||
/// The bias is conservative: a category we don't recognise routes to
|
||||
/// `Destructive`, and any shell command that `command_safety` flags as
|
||||
/// `Dangerous` is forced to `Destructive` even when the rest of the
|
||||
/// request looks calm. The split lets the modal render stronger warning
|
||||
/// copy on anything that can touch state outside this turn.
|
||||
#[must_use]
|
||||
pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel {
|
||||
let tool_name = canonical_action_alias(tool_name, params);
|
||||
match category {
|
||||
// Read paths and discovery.
|
||||
ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign,
|
||||
// Query-only network is benign; opening a URL pulls arbitrary
|
||||
// remote content, so it stays destructive.
|
||||
ToolCategory::Network => match tool_name {
|
||||
"web_search" | "wait_for_dev_server" | "registry_sync" => RiskLevel::Benign,
|
||||
// web_run is benign for search/query, but its `open`/`click`
|
||||
// actions fetch model-supplied URLs (arbitrary remote content) -
|
||||
// destructive, consistent with fetch_url.
|
||||
"web_run" => {
|
||||
let fetches_url = params
|
||||
.get("open")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
|| params
|
||||
.get("click")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|a| !a.is_empty());
|
||||
if fetches_url {
|
||||
RiskLevel::Destructive
|
||||
} else {
|
||||
RiskLevel::Benign
|
||||
}
|
||||
}
|
||||
_ => RiskLevel::Destructive,
|
||||
},
|
||||
// Shell stays destructive unless the existing command-safety analyzer
|
||||
// can prove the concrete command is read-only.
|
||||
ToolCategory::Shell => {
|
||||
if let Some(cmd) = params.get("command").and_then(Value::as_str)
|
||||
&& is_parallel_readonly_command(cmd)
|
||||
{
|
||||
return RiskLevel::Benign;
|
||||
}
|
||||
RiskLevel::Destructive
|
||||
}
|
||||
// Sub-agent lifecycle: status/peek are inspection-only. Starts and
|
||||
// other actions keep the explicit-options keymap (the child's own
|
||||
// gates govern what it may do once running).
|
||||
ToolCategory::Agent => match params.get("action").and_then(Value::as_str) {
|
||||
Some("status" | "peek" | "list") => RiskLevel::Benign,
|
||||
_ => RiskLevel::Destructive,
|
||||
},
|
||||
// File writes, MCP actions, unclassified surfaces - all require
|
||||
// explicit confirmation.
|
||||
ToolCategory::FileWrite | ToolCategory::McpAction | ToolCategory::Unknown => {
|
||||
RiskLevel::Destructive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::authority::get_tool_category;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn classifies_read_only_surfaces_as_benign() {
|
||||
for name in ["read_file", "list_dir", "list_mcp_tools", "web_search"] {
|
||||
let category = get_tool_category(name);
|
||||
assert_eq!(
|
||||
classify_risk(name, category, &json!({})),
|
||||
RiskLevel::Benign,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_stateful_or_unknown_surfaces_as_destructive() {
|
||||
for name in [
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"mcp_linear_save_issue",
|
||||
"fetch_url",
|
||||
"unknown_tool",
|
||||
] {
|
||||
let category = get_tool_category(name);
|
||||
assert_eq!(
|
||||
classify_risk(name, category, &json!({})),
|
||||
RiskLevel::Destructive,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_risk_uses_command_safety_analysis() {
|
||||
let category = get_tool_category("exec_shell");
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"exec_shell",
|
||||
category,
|
||||
&json!({"command": "git status --short"})
|
||||
),
|
||||
RiskLevel::Benign
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"exec_shell",
|
||||
category,
|
||||
&json!({"command": "rm -rf /tmp/example"})
|
||||
),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_exec_flags_are_not_benign() {
|
||||
let category = get_tool_category("exec_shell");
|
||||
for command in [
|
||||
"fd -x ./pwn.sh",
|
||||
"fd -uHtx ./pwn.sh",
|
||||
"rg --pre /tmp/evil.sh needle .",
|
||||
"git grep -O needle",
|
||||
"git grep -nO needle",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_risk("exec_shell", category, &json!({"command": command})),
|
||||
RiskLevel::Destructive,
|
||||
"{command} should not be classified as benign"
|
||||
);
|
||||
}
|
||||
|
||||
for command in [
|
||||
"fd -e rs .",
|
||||
"fd -H --type f src",
|
||||
"rg needle crates/",
|
||||
"git grep needle crates/",
|
||||
"git grep -n needle crates/",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_risk("exec_shell", category, &json!({"command": command})),
|
||||
RiskLevel::Benign,
|
||||
"{command} should remain benign"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_run_open_and_click_fetch_remote_content() {
|
||||
let category = get_tool_category("web_run");
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"web_run",
|
||||
category,
|
||||
&json!({"search_query": [{"q": "rust"}]})
|
||||
),
|
||||
RiskLevel::Benign
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk("web_run", category, &json!({"open": [{"ref_id": "x"}]})),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_risk(
|
||||
"web_run",
|
||||
category,
|
||||
&json!({"click": [{"ref_id": "x", "id": 1}]})
|
||||
),
|
||||
RiskLevel::Destructive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_actions_keep_legacy_approval_categories_and_risk() {
|
||||
let cases = [
|
||||
("Bash", "run", ToolCategory::Shell, RiskLevel::Destructive),
|
||||
("Bash", "wait", ToolCategory::Shell, RiskLevel::Destructive),
|
||||
(
|
||||
"Bash",
|
||||
"interact",
|
||||
ToolCategory::Shell,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"Bash",
|
||||
"cancel",
|
||||
ToolCategory::Shell,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("File", "read", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("File", "list", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("File", "search_name", ToolCategory::Safe, RiskLevel::Benign),
|
||||
(
|
||||
"File",
|
||||
"search_content",
|
||||
ToolCategory::Safe,
|
||||
RiskLevel::Benign,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"write",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"edit",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"File",
|
||||
"patch",
|
||||
ToolCategory::FileWrite,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Git", "status", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "diff", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "log", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "show", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "blame", ToolCategory::Safe, RiskLevel::Benign),
|
||||
("Git", "commit_plan", ToolCategory::Safe, RiskLevel::Benign),
|
||||
(
|
||||
"Run",
|
||||
"tests",
|
||||
ToolCategory::Unknown,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
(
|
||||
"Run",
|
||||
"verifiers",
|
||||
ToolCategory::Unknown,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Web", "search", ToolCategory::Network, RiskLevel::Benign),
|
||||
(
|
||||
"Web",
|
||||
"fetch",
|
||||
ToolCategory::Network,
|
||||
RiskLevel::Destructive,
|
||||
),
|
||||
("Web", "wait", ToolCategory::Network, RiskLevel::Benign),
|
||||
];
|
||||
|
||||
for (family, action, expected_category, expected_risk) in cases {
|
||||
let params = json!({"action": action});
|
||||
let category = get_tool_category_for_call(family, ¶ms);
|
||||
assert_eq!(category, expected_category, "{family}.{action}");
|
||||
assert_eq!(
|
||||
classify_risk(family, category, ¶ms),
|
||||
expected_risk,
|
||||
"{family}.{action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use crate::tui::widgets::{ApprovalWidget, Renderable};
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
#[cfg(test)]
|
||||
use super::RiskLevel;
|
||||
use super::previews::exact_edit_file_preview_lines;
|
||||
use super::{ApprovalRequest, ReviewDecision};
|
||||
#[cfg(test)]
|
||||
use crate::core::authority::RiskLevel;
|
||||
|
||||
/// Indices into the option list shared by both variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -18,7 +18,6 @@ pub(crate) mod agent_roster;
|
||||
pub mod ambient_life;
|
||||
pub mod app;
|
||||
pub mod approval;
|
||||
pub mod auto_review;
|
||||
pub mod auto_router;
|
||||
pub mod automation_panel;
|
||||
mod automation_routing;
|
||||
|
||||
@@ -15,12 +15,11 @@ use std::time::Duration;
|
||||
use crate::commands;
|
||||
#[cfg(test)]
|
||||
use crate::config::ProviderKind;
|
||||
use crate::core::authority::{RiskLevel, ToolCategory};
|
||||
#[cfg(test)]
|
||||
use crate::provider_lake::all_catalog_models_for_provider;
|
||||
use crate::tui::app::{App, ComposerDensity, ViewportState};
|
||||
use crate::tui::approval::{
|
||||
ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory,
|
||||
};
|
||||
use crate::tui::approval::{ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest};
|
||||
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus};
|
||||
use crate::tui::menu_style;
|
||||
use crate::tui::scrolling::TranscriptLineMeta;
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
{
|
||||
"_comment": "Runtime -> UI boundary ratchet (docs/design/TUI_DECONSTRUCTION.md, runtime split). Counts may only go down. Regenerate with python3 scripts/split/module_graph.py --update after removing references; never raise a count by hand.",
|
||||
"totals": {
|
||||
"prod": 69,
|
||||
"test": 88,
|
||||
"prod": 23,
|
||||
"test": 44,
|
||||
"late": 1,
|
||||
"uilib": 1,
|
||||
"doc": 1
|
||||
"doc": 0
|
||||
},
|
||||
"counts": {
|
||||
"prod": {
|
||||
"config|tui": 23,
|
||||
"core|tui": 26,
|
||||
"core|tui": 6,
|
||||
"fleet|lib.rs": 1,
|
||||
"local_ollama|tui": 1,
|
||||
"runtime_api|commands": 9,
|
||||
"runtime_api|tui": 1,
|
||||
"runtime_threads|lib.rs": 2,
|
||||
"tools|commands": 2,
|
||||
"tools|tui": 4
|
||||
"tools|tui": 1
|
||||
},
|
||||
"test": {
|
||||
"client|tui": 3,
|
||||
"config|tui": 9,
|
||||
"core|tui": 39,
|
||||
"config|tui": 2,
|
||||
"core|tui": 4,
|
||||
"doctor|lib.rs": 5,
|
||||
"fleet|lib.rs": 4,
|
||||
"prompts|tui": 1,
|
||||
@@ -30,7 +29,7 @@
|
||||
"runtime_api|lib.rs": 1,
|
||||
"session_manager|tui": 3,
|
||||
"test_support|tui": 7,
|
||||
"tools|tui": 14
|
||||
"tools|tui": 12
|
||||
},
|
||||
"late": {
|
||||
"core|exec_agent": 1
|
||||
@@ -38,8 +37,6 @@
|
||||
"uilib": {
|
||||
"tools|crossterm": 1
|
||||
},
|
||||
"doc": {
|
||||
"core|tui": 1
|
||||
}
|
||||
"doc": {}
|
||||
}
|
||||
}
|
||||
@@ -90,15 +90,16 @@
|
||||
"modes": {
|
||||
"act": {
|
||||
"active": {
|
||||
"bytes": 36557,
|
||||
"identity_sha256": "cc8f1f208bcf83261451f1ff67f7bf65534616cfd50be0102f50aba009a9e59d",
|
||||
"tokens_est": 9140,
|
||||
"bytes": 37873,
|
||||
"identity_sha256": "e2d6dfb7168d48ee8d2a415feac7ff5b8864e18528c233a697055370144eb138",
|
||||
"tokens_est": 9469,
|
||||
"tool_names": [
|
||||
"agent",
|
||||
"bash",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"execute_tools",
|
||||
"file_search",
|
||||
"get_goal",
|
||||
"load_skill",
|
||||
"read",
|
||||
@@ -108,12 +109,12 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 13
|
||||
"tools": 14
|
||||
},
|
||||
"full": {
|
||||
"bytes": 85905,
|
||||
"identity_sha256": "45e989bbe5ac0bb1f2d9084361c021009f30a06539f132ebd4fd2331a1bb1954",
|
||||
"tokens_est": 21477,
|
||||
"bytes": 85363,
|
||||
"identity_sha256": "928e6417732837d26f1f26fe41ea6a11f5b8e3bfc4eeb3dd97a309379cffeff9",
|
||||
"tokens_est": 21341,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Run",
|
||||
@@ -127,7 +128,6 @@
|
||||
"edit",
|
||||
"execute_tools",
|
||||
"file_search",
|
||||
"fim_edit",
|
||||
"finance",
|
||||
"get_goal",
|
||||
"github",
|
||||
@@ -169,20 +169,21 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 53
|
||||
"tools": 52
|
||||
}
|
||||
},
|
||||
"operate": {
|
||||
"active": {
|
||||
"bytes": 36557,
|
||||
"identity_sha256": "cc8f1f208bcf83261451f1ff67f7bf65534616cfd50be0102f50aba009a9e59d",
|
||||
"tokens_est": 9140,
|
||||
"bytes": 37873,
|
||||
"identity_sha256": "e2d6dfb7168d48ee8d2a415feac7ff5b8864e18528c233a697055370144eb138",
|
||||
"tokens_est": 9469,
|
||||
"tool_names": [
|
||||
"agent",
|
||||
"bash",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"execute_tools",
|
||||
"file_search",
|
||||
"get_goal",
|
||||
"load_skill",
|
||||
"read",
|
||||
@@ -192,12 +193,12 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 13
|
||||
"tools": 14
|
||||
},
|
||||
"full": {
|
||||
"bytes": 85905,
|
||||
"identity_sha256": "45e989bbe5ac0bb1f2d9084361c021009f30a06539f132ebd4fd2331a1bb1954",
|
||||
"tokens_est": 21477,
|
||||
"bytes": 85363,
|
||||
"identity_sha256": "928e6417732837d26f1f26fe41ea6a11f5b8e3bfc4eeb3dd97a309379cffeff9",
|
||||
"tokens_est": 21341,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Run",
|
||||
@@ -211,7 +212,6 @@
|
||||
"edit",
|
||||
"execute_tools",
|
||||
"file_search",
|
||||
"fim_edit",
|
||||
"finance",
|
||||
"get_goal",
|
||||
"github",
|
||||
@@ -253,19 +253,20 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 53
|
||||
"tools": 52
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"active": {
|
||||
"bytes": 35027,
|
||||
"identity_sha256": "df6676989a677fb08fecc8bf7ae12caf4fa88e0cae3d143cea6a4da4382b746c",
|
||||
"tokens_est": 8757,
|
||||
"bytes": 36343,
|
||||
"identity_sha256": "6c432c75bf776d95b367b17308b171c3eaba8e70aa0c2f76bac453b3c3368a21",
|
||||
"tokens_est": 9086,
|
||||
"tool_names": [
|
||||
"agent",
|
||||
"bash",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"file_search",
|
||||
"get_goal",
|
||||
"load_skill",
|
||||
"read",
|
||||
@@ -275,12 +276,12 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 12
|
||||
"tools": 13
|
||||
},
|
||||
"full": {
|
||||
"bytes": 54842,
|
||||
"bytes": 55333,
|
||||
"identity_sha256": "ac8af1f4988199825be7b00b054c258724a44074b1d4e6de6c92ade7c1cffe63",
|
||||
"tokens_est": 13711,
|
||||
"tokens_est": 13834,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Web",
|
||||
|
||||
Reference in new issue
Block a user