use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::agent::runloop::unified::state::AutoPermissionDenial;
use anyhow::{Context, Result, anyhow};
use reqwest::Url;
use serde::Deserialize;
use serde_json::Value;
use vtcode_core::command_safety::parse_bash_lc_commands;
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::config::types::AgentConfig as CoreAgentConfig;
use vtcode_core::config::{PermissionsConfig, loader::VTCodeConfig};
use vtcode_core::git_info::get_git_remote_urls_async;
use vtcode_core::llm::{LightweightFeature, provider as uni, resolve_lightweight_route};
use vtcode_core::permissions::{PermissionRequest, build_permission_request};
use vtcode_core::tools::command_args;
const REVIEWER_PROMPT: &str = include_str!("prompts/agent-prompt-auto-permission-rule-reviewer.md");
const STAGE_ONE_RESPONSE_FORMAT: &str = "Respond with exactly ALLOW or BLOCK.";
const STAGE_TWO_RESPONSE_FORMAT: &str = "Return strict JSON: {\"decision\":\"allow|block\",\"reason\":\"...\",\"matched_rule\":\"...\",\"matched_exception\":\"...\"}";
mod probe;
pub(crate) use probe::{
DECISIONS_SUGGESTION, ProbeRuntime, ProbeWarning, decisions_suggestion_applicable, probe_tool_output,
recent_user_context,
};
const MAX_TRANSCRIPT_ENTRIES: usize = 48;
const MAX_ENTRY_CHARS: usize = 1600;
#[derive(Debug, Clone)]
pub(crate) enum AutoPermissionReviewDecision {
Allow { stage: &'static str },
Block(AutoPermissionDenial),
}
#[derive(Debug, Deserialize)]
struct StageTwoDecision {
decision: String,
reason: String,
#[serde(default)]
matched_rule: Option<String>,
#[serde(default)]
matched_exception: Option<String>,
}
pub(crate) async fn review_tool_call(
provider: &mut dyn uni::LLMProvider,
agent_config: &CoreAgentConfig,
vt_cfg: Option<&VTCodeConfig>,
permissions: &PermissionsConfig,
workspace_root: &Path,
history: &[uni::Message],
tool_name: &str,
tool_args: Option<&Value>,
permission_request: &PermissionRequest,
) -> Result<AutoPermissionReviewDecision> {
let transcript = build_classifier_transcript(workspace_root, history);
let pending_action = normalized_tool_payload(workspace_root, tool_name, tool_args);
let stage_one_prompt = review_prompt(
permissions,
workspace_root,
&transcript,
&pending_action,
tool_name,
STAGE_ONE_RESPONSE_FORMAT,
None,
)
.await;
let stage_one_models = selected_models(
agent_config,
vt_cfg,
permissions.auto_permission.model.as_str(),
LightweightFeature::AutoPermissionReview,
);
let stage_one = raw_completion(
provider,
&stage_one_models.primary_model,
stage_one_models.fallback_model.as_deref(),
REVIEWER_PROMPT,
stage_one_prompt,
Some(8),
)
.await
.context("auto permission review stage-1 review")?;
let stage_one_decision = first_upper_token(&stage_one);
tracing::trace!(
tool = %tool_name,
stage = "stage1",
decision = %stage_one_decision,
"auto permission review reviewer completed"
);
if stage_one_decision == "ALLOW" {
return Ok(AutoPermissionReviewDecision::Allow { stage: "stage1" });
}
let script_context = prior_script_context(workspace_root, history, permission_request, tool_args);
let stage_two_prompt = review_prompt(
permissions,
workspace_root,
&transcript,
&pending_action,
tool_name,
STAGE_TWO_RESPONSE_FORMAT,
script_context.as_deref(),
)
.await;
let stage_two = raw_completion(
provider,
&stage_one_models.primary_model,
stage_one_models.fallback_model.as_deref(),
REVIEWER_PROMPT,
stage_two_prompt,
Some(300),
)
.await
.context("auto permission review stage-2 review")?;
let parsed = parse_stage_two_decision(&stage_two)?;
let allow = parsed.decision.trim().eq_ignore_ascii_case("allow");
tracing::trace!(
tool = %tool_name,
stage = "stage2",
decision = if allow { "allow" } else { "block" },
matched_rule = parsed.matched_rule.as_deref().unwrap_or(""),
matched_exception = parsed.matched_exception.as_deref().unwrap_or(""),
"auto permission review reviewer completed"
);
if allow {
return Ok(AutoPermissionReviewDecision::Allow { stage: "stage2" });
}
Ok(AutoPermissionReviewDecision::Block(AutoPermissionDenial {
stage: "stage2",
reason: parsed.reason,
matched_rule: parsed.matched_rule,
matched_exception: parsed.matched_exception,
}))
}
async fn review_prompt(
permissions: &PermissionsConfig,
workspace_root: &Path,
transcript: &[String],
pending_action: &str,
tool_name: &str,
response_format: &str,
script_context: Option<&str>,
) -> String {
let environment = render_environment(permissions, workspace_root).await;
let block_rules = numbered_lines(&permissions.auto_permission.block_rules);
let allow_exceptions = numbered_lines(&permissions.auto_permission.allow_exceptions);
let transcript = if transcript.is_empty() {
"<empty>".to_string()
} else {
transcript.join("\n")
};
let mut prompt = format!(
"Environment:\n{environment}\n\nBlock rules:\n{block_rules}\n\nAllow exceptions:\n{allow_exceptions}\n\nTranscript:\n{transcript}\n\nPending tool: {tool_name}\nPending action:\n{pending_action}\n\n{response_format}",
);
if let Some(script_context) = script_context {
prompt.push_str("\n\nEarlier in-session script content related to this action:\n");
prompt.push_str(script_context);
}
prompt
}
async fn render_environment(permissions: &PermissionsConfig, workspace_root: &Path) -> String {
let mut trusted_paths = BTreeSet::new();
trusted_paths.insert(workspace_root.display().to_string());
trusted_paths.extend(
permissions
.auto_permission
.environment
.trusted_paths
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
);
let mut trusted_domains = BTreeSet::new();
trusted_domains.extend(
permissions
.auto_permission
.environment
.trusted_domains
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
);
let mut trusted_git_hosts = BTreeSet::new();
let mut trusted_git_orgs = BTreeSet::new();
trusted_git_hosts.extend(
permissions
.auto_permission
.environment
.trusted_git_hosts
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
);
trusted_git_orgs.extend(
permissions
.auto_permission
.environment
.trusted_git_orgs
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
);
if let Ok(remotes) = get_git_remote_urls_async(workspace_root.to_path_buf()).await {
for remote in remotes.values() {
if let Some((host, org)) = extract_git_host_and_org(remote) {
trusted_domains.insert(host.clone());
trusted_git_hosts.insert(host);
if let Some(org) = org {
trusted_git_orgs.insert(org);
}
}
}
}
let trusted_services = permissions
.auto_permission
.environment
.trusted_services
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
format!(
"- trusted_paths: {}\n- trusted_domains: {}\n- trusted_git_hosts: {}\n- trusted_git_orgs: {}\n- trusted_services: {}",
joined_or_none(trusted_paths),
joined_or_none(trusted_domains),
joined_or_none(trusted_git_hosts),
joined_or_none(trusted_git_orgs),
if trusted_services.is_empty() {
"<none>".to_string()
} else {
trusted_services.join(", ")
},
)
}
fn build_classifier_transcript(workspace_root: &Path, history: &[uni::Message]) -> Vec<String> {
let mut entries = Vec::new();
for message in history.iter().rev() {
match message.role {
uni::MessageRole::User => {
let text = message.content.as_text();
let trimmed = text.trim();
if !trimmed.is_empty() {
entries.push(format!("USER: {}", truncate_text(trimmed, MAX_ENTRY_CHARS)));
}
}
uni::MessageRole::Assistant => {
for tool_call in message.tool_calls.as_deref().unwrap_or(&[]) {
let tool_name = tool_call
.function
.as_ref()
.map(|function| function.name.as_str())
.unwrap_or(tool_call.call_type.as_str());
let args = tool_call
.function
.as_ref()
.and_then(|function| serde_json::from_str::<Value>(&function.arguments).ok());
let payload = normalized_tool_payload(workspace_root, tool_name, args.as_ref());
entries.push(format!("ACTION: {}", truncate_text(payload.as_str(), MAX_ENTRY_CHARS)));
}
}
uni::MessageRole::System | uni::MessageRole::Tool => {}
}
if entries.len() >= MAX_TRANSCRIPT_ENTRIES {
break;
}
}
entries.reverse();
entries
}
fn normalized_tool_payload(workspace_root: &Path, tool_name: &str, tool_args: Option<&Value>) -> String {
let current_dir = workspace_root;
let permission_request = build_permission_request(workspace_root, current_dir, tool_name, tool_args);
match &permission_request.kind {
vtcode_core::permissions::PermissionRequestKind::Bash { command } => {
normalize_shell_payload(command, tool_args)
}
vtcode_core::permissions::PermissionRequestKind::Read { paths } => {
format!("{} {}", tool_name, render_paths(paths))
}
vtcode_core::permissions::PermissionRequestKind::Edit { paths } => {
format!("{} {}", tool_name, render_paths(paths))
}
vtcode_core::permissions::PermissionRequestKind::Write { paths } => {
format!("{} {}", tool_name, render_paths(paths))
}
vtcode_core::permissions::PermissionRequestKind::WebFetch { domains } => {
format!(
"{} domains={}",
tool_name,
if domains.is_empty() {
"<unknown>".to_string()
} else {
domains.join(", ")
}
)
}
vtcode_core::permissions::PermissionRequestKind::Mcp { server, tool } => {
format!("mcp {server}::{tool}")
}
vtcode_core::permissions::PermissionRequestKind::Other => {
let rendered_args = tool_args
.map(|args| truncate_text(&args.to_string(), 600))
.unwrap_or_else(|| "<none>".to_string());
format!("{tool_name} {rendered_args}")
}
}
}
fn normalize_shell_payload(command: &str, tool_args: Option<&Value>) -> String {
if let Some(command_words) = tool_args.and_then(|args| command_args::command_words(args).ok()).flatten()
&& let Some(segments) = parse_bash_lc_commands(&command_words)
{
let rendered = segments
.into_iter()
.map(|segment| shell_words::join(segment.iter().map(String::as_str)))
.collect::<Vec<_>>();
if !rendered.is_empty() {
return rendered.join(" && ");
}
}
vtcode_core::command_safety::shell_parser::parse_shell_commands(command)
.ok()
.filter(|segments| !segments.is_empty())
.map(|segments| {
segments
.into_iter()
.map(|segment| shell_words::join(segment.iter().map(String::as_str)))
.collect::<Vec<_>>()
.join(" && ")
})
.filter(|rendered| !rendered.is_empty())
.unwrap_or_else(|| command.to_string())
}
fn render_paths(paths: &[PathBuf]) -> String {
if paths.is_empty() {
return "<none>".to_string();
}
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn prior_script_context(
workspace_root: &Path,
history: &[uni::Message],
permission_request: &PermissionRequest,
tool_args: Option<&Value>,
) -> Option<String> {
let script_path = referenced_script_path(workspace_root, permission_request, tool_args)?;
for message in history.iter().rev() {
for tool_call in message.tool_calls.as_deref().unwrap_or(&[]) {
let tool_name = tool_call
.function
.as_ref()
.map(|function| function.name.as_str())
.unwrap_or(tool_call.call_type.as_str());
let args = tool_call
.function
.as_ref()
.and_then(|function| serde_json::from_str::<Value>(&function.arguments).ok());
let request = build_permission_request(workspace_root, workspace_root, tool_name, args.as_ref());
let writes_path = match &request.kind {
vtcode_core::permissions::PermissionRequestKind::Edit { paths }
| vtcode_core::permissions::PermissionRequestKind::Write { paths } => paths
.iter()
.any(|path| normalize_review_path(workspace_root, path) == script_path),
_ => false,
};
if !writes_path {
continue;
}
let payload = args
.as_ref()
.and_then(|value| extract_written_content(tool_name, value))
.map(|content| truncate_text(content.as_str(), MAX_ENTRY_CHARS))
.unwrap_or_else(|| "<content unavailable>".to_string());
return Some(format!("path: {}\n{}", script_path.display(), payload));
}
}
None
}
fn referenced_script_path(
workspace_root: &Path,
permission_request: &PermissionRequest,
tool_args: Option<&Value>,
) -> Option<PathBuf> {
let vtcode_core::permissions::PermissionRequestKind::Bash { command } = &permission_request.kind else {
return None;
};
normalized_shell_segments(command, tool_args)
.into_iter()
.find_map(|segment| first_script_path_in_segment(segment.as_slice()))
.map(|path| normalize_review_path(workspace_root, path.as_path()))
}
fn normalized_shell_segments(command: &str, tool_args: Option<&Value>) -> Vec<Vec<String>> {
if let Some(command_words) = tool_args.and_then(|args| command_args::command_words(args).ok()).flatten()
&& let Some(segments) = parse_bash_lc_commands(&command_words)
{
return segments;
}
vtcode_core::command_safety::shell_parser::parse_shell_commands(command).unwrap_or_default()
}
fn first_script_path_in_segment(segment: &[String]) -> Option<PathBuf> {
if segment.is_empty() {
return None;
}
let start_index = if matches!(
segment.first().map(String::as_str),
Some("bash" | "sh" | "zsh" | "python" | "python3" | "node" | "ruby")
) {
1
} else {
0
};
segment.iter().skip(start_index).find_map(|token| script_like_path(token))
}
fn script_like_path(token: &str) -> Option<PathBuf> {
let trimmed = token.trim();
if trimmed.is_empty() || trimmed.starts_with('-') {
return None;
}
let looks_like_script = trimmed.contains('/')
|| trimmed.starts_with("./")
|| trimmed.starts_with("../")
|| [".sh", ".py", ".js", ".rb"].iter().any(|suffix| trimmed.ends_with(suffix));
looks_like_script.then(|| PathBuf::from(trimmed))
}
fn normalize_review_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
}
}
fn extract_written_content(tool_name: &str, args: &Value) -> Option<String> {
for key in ["content", "new_content", "text"] {
if let Some(content) = args.get(key).and_then(Value::as_str) {
return Some(content.to_string());
}
}
if tool_name == tool_names::APPLY_PATCH {
return args
.get("patch")
.or_else(|| args.get("input"))
.and_then(Value::as_str)
.map(ToString::to_string);
}
None
}
async fn raw_completion(
provider: &mut dyn uni::LLMProvider,
model: &str,
fallback_model: Option<&str>,
system_prompt: &str,
user_prompt: String,
max_tokens: Option<u32>,
) -> Result<String> {
let request = uni::LLMRequest {
messages: Arc::new(vec![uni::Message::user(user_prompt)]),
system_prompt: Some(Arc::from(system_prompt)),
model: model.to_string(),
max_tokens,
temperature: Some(0.0),
stream: false,
..Default::default()
};
match provider.generate(request.clone()).await.map_err(|err| anyhow!(err)) {
Ok(response) => Ok(response.content_text().trim().to_string()),
Err(primary_err) => {
let Some(fallback_model) = fallback_model.filter(|candidate| *candidate != model) else {
return Err(primary_err);
};
tracing::warn!(
model,
fallback_model,
error = %primary_err,
"auto permission review lightweight request failed; retrying with main model"
);
let fallback_request = uni::LLMRequest { model: fallback_model.to_string(), ..request };
let response = provider.generate(fallback_request).await.map_err(|err| anyhow!(err))?;
Ok(response.content_text().trim().to_string())
}
}
}
fn parse_stage_two_decision(raw: &str) -> Result<StageTwoDecision> {
if let Ok(parsed) = serde_json::from_str(raw) {
return Ok(parsed);
}
let start = raw.find('{').ok_or_else(|| anyhow!("missing JSON object"))?;
let end = raw.rfind('}').ok_or_else(|| anyhow!("missing JSON object"))?;
serde_json::from_str(&raw[start..=end]).context("parse auto permission review stage-2 JSON")
}
struct AutoPermissionModels {
primary_model: String,
fallback_model: Option<String>,
}
fn selected_models(
agent_config: &CoreAgentConfig,
vt_cfg: Option<&VTCodeConfig>,
configured_model: &str,
feature: LightweightFeature,
) -> AutoPermissionModels {
let resolution = resolve_lightweight_route(agent_config, vt_cfg, feature, Some(configured_model));
if let Some(warning) = &resolution.warning {
tracing::warn!(warning = %warning, "auto permission review route adjusted");
}
AutoPermissionModels {
primary_model: resolution.primary.model,
fallback_model: resolution.fallback.map(|route| route.model),
}
}
fn numbered_lines(lines: &[String]) -> String {
if lines.is_empty() {
return "1. <none>".to_string();
}
lines
.iter()
.enumerate()
.map(|(index, line)| format!("{}. {}", index + 1, line))
.collect::<Vec<_>>()
.join("\n")
}
fn truncate_text(value: &str, max_chars: usize) -> String {
let char_count = value.chars().count();
if char_count <= max_chars {
return value.to_string();
}
let keep = max_chars.saturating_sub(14);
let head = value.chars().take(keep).collect::<String>();
format!("{head} [truncated]")
}
fn first_upper_token(value: &str) -> String {
value
.split_whitespace()
.next()
.unwrap_or("BLOCK")
.trim_matches(|c: char| !c.is_ascii_alphabetic())
.to_ascii_uppercase()
}
fn joined_or_none(values: BTreeSet<String>) -> String {
if values.is_empty() {
"<none>".to_string()
} else {
values.into_iter().collect::<Vec<_>>().join(", ")
}
}
fn extract_git_host_and_org(remote: &str) -> Option<(String, Option<String>)> {
if let Ok(parsed) = Url::parse(remote) {
let host = parsed.host_str()?.to_string();
let org = parsed
.path_segments()
.and_then(|mut segments| segments.next().map(str::to_string));
return Some((host, org));
}
let remote = remote.strip_prefix("git@")?;
let (host, rest) = remote.split_once(':')?;
let org = rest.split('/').next().map(str::to_string);
Some((host.to_string(), org))
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use serde_json::json;
use std::collections::BTreeMap;
use vtcode_config::core::PromptCachingConfig;
use vtcode_core::config::constants::models;
use vtcode_core::config::types::{ModelSelectionSource, ReasoningEffortLevel, UiSurfacePreference};
use vtcode_core::core::agent::snapshots::{
DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS,
};
use vtcode_core::llm::provider::{FinishReason, LLMError, LLMRequest, LLMResponse};
pub(super) fn runtime_config() -> CoreAgentConfig {
CoreAgentConfig {
model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
api_key: "test-key".to_string(),
provider: "gemini".to_string(),
api_key_env: "GEMINI_API_KEY".to_string(),
workspace: std::env::current_dir().expect("current_dir"),
verbose: false,
quiet: false,
theme: vtcode_core::ui::theme::DEFAULT_THEME_ID.to_string(),
reasoning_effort: ReasoningEffortLevel::default(),
ui_surface: UiSurfacePreference::default(),
prompt_cache: PromptCachingConfig::default(),
model_source: ModelSelectionSource::WorkspaceConfig,
custom_api_keys: BTreeMap::new(),
checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
checkpointing_storage_dir: None,
checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
max_conversation_turns: 1000,
model_behavior: None,
openai_chatgpt_auth: None,
}
}
#[derive(Clone)]
struct StaticProvider {
response: String,
}
#[async_trait]
impl uni::LLMProvider for StaticProvider {
fn name(&self) -> &str {
"test"
}
async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
Ok(LLMResponse {
content: Some(self.response.clone()),
model: "test-model".to_string(),
tool_calls: None,
usage: None,
finish_reason: FinishReason::Stop,
reasoning: None,
reasoning_details: None,
organization_id: None,
request_id: None,
tool_references: Vec::new(),
compaction: None,
})
}
fn supported_models(&self) -> Vec<String> {
vec!["test-model".to_string()]
}
fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
Ok(())
}
}
#[test]
fn transcript_excludes_assistant_text_and_tool_outputs() {
let history = vec![
uni::Message::system("system".to_string()),
uni::Message::user("clean up old branches".to_string()),
uni::Message::assistant("thinking".to_string()),
uni::Message::assistant_with_tools(
String::new(),
vec![uni::ToolCall::function(
"call_1".to_string(),
"command_session".to_string(),
json!({"action":"run","command":"git push --force"}).to_string(),
)],
),
uni::Message::tool_response("call_1".to_string(), "{\"ok\":true}".to_string()),
];
let transcript = build_classifier_transcript(Path::new("."), &history);
assert!(transcript.iter().any(|entry| entry.contains("USER: clean up old branches")));
assert!(transcript.iter().any(|entry| entry.contains("git push --force")));
assert!(!transcript.iter().any(|entry| entry.contains("thinking")));
assert!(!transcript.iter().any(|entry| entry.contains("{\"ok\":true}")));
}
#[tokio::test]
async fn probe_reviews_non_heuristic_tool_output() {
let mut provider = StaticProvider { response: "SUSPECT".to_string() };
let user_context = recent_user_context(&[uni::Message::user("check the tool output".to_string())]);
let mut stats = crate::agent::runloop::unified::state::SessionStats::default();
let stop = crate::agent::runloop::unified::state::CtrlCState::new();
let notify = tokio::sync::Notify::new();
let warning = probe_tool_output(
&mut provider,
&runtime_config(),
None,
&PermissionsConfig::default(),
&user_context,
r#"{"error":"tool failed unexpectedly"}"#,
&mut ProbeRuntime { stats: &mut stats, stop: &stop, notify: ¬ify },
)
.await
.expect("probe warning");
assert!(warning.is_some());
}
#[test]
fn recent_user_context_keeps_only_the_last_two_user_messages() {
let history = vec![
uni::Message::user("first request".to_string()),
uni::Message::assistant("assistant text".to_string()),
uni::Message::user("second request".to_string()),
uni::Message::user("third request".to_string()),
];
let context = recent_user_context(&history);
assert!(context.contains("second request"));
assert!(context.contains("third request"));
assert!(!context.contains("first request"));
assert!(!context.contains("assistant text"));
}
#[test]
fn reviewer_prompt_describes_both_stage_response_formats() {
assert!(STAGE_ONE_RESPONSE_FORMAT.contains("ALLOW or BLOCK"));
assert!(STAGE_TWO_RESPONSE_FORMAT.contains("\"decision\""));
assert!(REVIEWER_PROMPT.contains("`ALLOW` or `BLOCK`"));
assert!(REVIEWER_PROMPT.contains("JSON object"));
for field in ["decision", "reason", "matched_rule", "matched_exception"] {
assert!(REVIEWER_PROMPT.contains(field), "reviewer prompt must name `{field}`");
}
assert!(!REVIEWER_PROMPT.contains("ccVersion"));
}
#[test]
fn stage_two_parser_accepts_null_matches_and_surrounding_text() {
let parsed = parse_stage_two_decision(
r#"{"decision":"block","reason":"Force-push to a shared branch.","matched_rule":"1","matched_exception":null}"#,
)
.expect("strict JSON");
assert_eq!(parsed.decision, "block");
assert_eq!(parsed.matched_rule.as_deref(), Some("1"));
assert!(parsed.matched_exception.is_none());
let wrapped = parse_stage_two_decision(
"Decision:\n{\"decision\":\"allow\",\"reason\":\"Read-only.\",\"matched_rule\":null,\"matched_exception\":null}",
)
.expect("JSON embedded in text");
assert!(wrapped.decision.eq_ignore_ascii_case("allow"));
assert!(wrapped.matched_rule.is_none());
assert!(parse_stage_two_decision("ALLOW").is_err());
}
#[test]
fn prior_script_context_inlines_written_script_content() {
let workspace_root = Path::new("/workspace");
let history = vec![uni::Message::assistant_with_tools(
String::new(),
vec![uni::ToolCall::function(
"call_1".to_string(),
tool_names::UNIFIED_FILE.to_string(),
json!({
"action": "write",
"path": "scripts/cleanup.sh",
"content": "#!/bin/sh\nrm -rf /tmp/demo\n",
})
.to_string(),
)],
)];
let args = json!({
"action": "run",
"command": ["/bin/zsh", "-lc", "./scripts/cleanup.sh"],
});
let permission_request =
build_permission_request(workspace_root, workspace_root, tool_names::UNIFIED_EXEC, Some(&args));
let context =
prior_script_context(workspace_root, &history, &permission_request, Some(&args)).expect("script context");
assert!(context.contains("scripts/cleanup.sh"));
assert!(context.contains("rm -rf /tmp/demo"));
}
}