diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 943e9e456..2c3b92842 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -13,6 +13,7 @@ use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::agent::task::TaskId; use crate::ai::agent::todos::AIAgentTodoList; use crate::ai::agent::{ + decode_command_policy_denied_reason, decode_file_edits_policy_denied_reason, AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputStatus, CallMCPToolResult, CancellationReason, CloneRepositoryURL, CreateDocumentsResult, @@ -25,10 +26,15 @@ use crate::ai::agent::{ Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, + WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + WRITE_TO_SHELL_POLICY_DENIED_PREFIX, }; use crate::ai::block_context::BlockContext; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; use crate::ai::llms::LLMId; +use crate::ai::policy_hooks::redaction::{ + redact_command_for_policy, redact_sensitive_text_for_policy, +}; use crate::ai_assistant::execution_context::{WarpAiExecutionContext, WarpAiOsContext}; use crate::terminal::model::block::BlockId; use crate::terminal::model::terminal_model::BlockIndex; @@ -591,9 +597,31 @@ pub(crate) fn convert_tool_call_result_to_input( is_alt_screen_active: snapshot.is_alt_screen_active, }, Some(api::run_shell_command_result::Result::PermissionDenied( - api::PermissionDenied { .. }, - )) - | None => { + api::PermissionDenied { reason }, + )) => match reason { + Some(api::permission_denied::Reason::DenylistedCommand(())) => { + RequestCommandOutputResult::Denylisted { + command: result.command.clone(), + } + } + None => { + #[allow(deprecated)] + let output = result.output.as_str(); + if let Some(reason) = decode_command_policy_denied_reason(output) { + if reason.is_empty() { + RequestCommandOutputResult::CancelledBeforeExecution + } else { + RequestCommandOutputResult::PolicyDenied { + command: redact_command_for_policy(&result.command), + reason: redact_sensitive_text_for_policy(&reason), + } + } + } else { + RequestCommandOutputResult::CancelledBeforeExecution + } + } + }, + None => { // If no result is present, treat as cancelled RequestCommandOutputResult::CancelledBeforeExecution } @@ -621,11 +649,29 @@ pub(crate) fn convert_tool_call_result_to_input( }, Some(api::write_to_long_running_shell_command_result::Result::CommandFinished( finished, - )) => WriteToLongRunningShellCommandResult::CommandFinished { - block_id: finished.command_id.clone().into(), - output: finished.output.clone(), - exit_code: ExitCode::from(finished.exit_code), - }, + )) => { + let is_policy_denial_marker = + finished.command_id == WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + && finished.exit_code == WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE; + let policy_denial_reason = if is_policy_denial_marker { + finished + .output + .strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) + } else { + None + }; + if let Some(reason) = policy_denial_reason { + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: redact_sensitive_text_for_policy(reason), + } + } else { + WriteToLongRunningShellCommandResult::CommandFinished { + block_id: finished.command_id.clone().into(), + output: finished.output.clone(), + exit_code: ExitCode::from(finished.exit_code), + } + } + } Some(api::write_to_long_running_shell_command_result::Result::Error(api::ShellCommandError{ r#type: Some(api::shell_command_error::Type::CommandNotFound(())) })) => WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound), @@ -760,8 +806,14 @@ pub(crate) fn convert_tool_call_result_to_input( } } Some(api::apply_file_diffs_result::Result::Error(error)) => { - RequestFileEditsResult::DiffApplicationFailed { - error: error.message.clone(), + if let Some(reason) = decode_file_edits_policy_denied_reason(&error.message) { + RequestFileEditsResult::PolicyDenied { + reason: redact_sensitive_text_for_policy(&reason), + } + } else { + RequestFileEditsResult::DiffApplicationFailed { + error: error.message.clone(), + } } } None => RequestFileEditsResult::Cancelled, diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index c49e742a3..2bbd13f86 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -79,6 +79,477 @@ fn test_convert_tool_call_result_to_input_transfer_control_snapshot() { } } +#[test] +fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + #[allow(deprecated)] + let run_shell_result = api::RunShellCommandResult { + command: "rm -rf target".to_string(), + output: crate::ai::agent::encode_command_policy_denied_message("blocked by org policy"), + exit_code: 0, + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }; + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::RunShellCommand( + run_shell_result, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestCommandOutput( + crate::ai::agent::RequestCommandOutputResult::PolicyDenied { command, reason }, + ) => { + assert_eq!(command, "rm -rf target"); + assert_eq!(reason, "blocked by org policy"); + } + other => panic!("Expected policy-denied command result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_redacts_host_policy_denial() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + #[allow(deprecated)] + let run_shell_result = api::RunShellCommandResult { + command: "OPENAI_API_KEY=sk-secretsecretsecret guard --token raw-token".to_string(), + output: crate::ai::agent::encode_command_policy_denied_message( + "blocked PASSWORD=hunter2 --token raw-token", + ), + exit_code: 0, + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }; + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::RunShellCommand( + run_shell_result, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestCommandOutput( + crate::ai::agent::RequestCommandOutputResult::PolicyDenied { command, reason }, + ) => { + assert!(command.contains("OPENAI_API_KEY=")); + assert!(command.contains("--token ")); + assert!(reason.contains("PASSWORD=")); + assert!(reason.contains("--token ")); + assert!(!command.contains("sk-secretsecretsecret")); + assert!(!command.contains("raw-token")); + assert!(!reason.contains("hunter2")); + assert!(!reason.contains("raw-token")); + } + other => panic!("Expected policy-denied command result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_treats_unmarked_permission_denied_as_cancelled() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + #[allow(deprecated)] + let run_shell_result = api::RunShellCommandResult { + command: "rm -rf target".to_string(), + output: format!( + "{}EACCES while spawning pty", + crate::ai::agent::COMMAND_POLICY_DENIED_PREFIX + ), + exit_code: 0, + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }; + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::RunShellCommand( + run_shell_result, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestCommandOutput( + crate::ai::agent::RequestCommandOutputResult::CancelledBeforeExecution, + ) => {} + other => panic!("Expected cancelled command result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_preserves_file_edit_policy_denial() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let policy_reason = "protected path"; + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::ApplyFileDiffs( + api::ApplyFileDiffsResult { + result: Some(api::apply_file_diffs_result::Result::Error( + api::apply_file_diffs_result::Error { + message: crate::ai::agent::encode_file_edits_policy_denied_message( + policy_reason, + ), + }, + )), + }, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestFileEdits( + crate::ai::agent::RequestFileEditsResult::PolicyDenied { reason }, + ) => { + assert_eq!(reason, policy_reason); + } + other => panic!("Expected policy-denied file edit result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_redacts_file_edit_policy_denial_reason() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::ApplyFileDiffs( + api::ApplyFileDiffsResult { + result: Some(api::apply_file_diffs_result::Result::Error( + api::apply_file_diffs_result::Error { + message: crate::ai::agent::encode_file_edits_policy_denied_message( + "blocked PASSWORD=hunter2 --token raw-token", + ), + }, + )), + }, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestFileEdits( + crate::ai::agent::RequestFileEditsResult::PolicyDenied { reason }, + ) => { + assert!(reason.contains("PASSWORD=")); + assert!(reason.contains("--token ")); + assert!(!reason.contains("hunter2")); + assert!(!reason.contains("raw-token")); + } + other => panic!("Expected policy-denied file edit result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_does_not_reclassify_prefixed_file_edit_error() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let error = format!( + "{}protected path", + crate::ai::agent::FILE_EDITS_POLICY_DENIED_PREFIX + ); + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some(api::message::tool_call_result::Result::ApplyFileDiffs( + api::ApplyFileDiffsResult { + result: Some(api::apply_file_diffs_result::Result::Error( + api::apply_file_diffs_result::Error { + message: error.clone(), + }, + )), + }, + )), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::RequestFileEdits( + crate::ai::agent::RequestFileEditsResult::DiffApplicationFailed { + error: actual_error, + }, + ) => { + assert_eq!(actual_error, error); + } + other => panic!("Expected diff-application failure result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let policy_reason = "interactive write blocked"; + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some( + api::message::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + .to_string(), + output: format!( + "{}{}", + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + policy_reason + ), + exit_code: crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }, + ), + ), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::WriteToLongRunningShellCommand( + crate::ai::agent::WriteToLongRunningShellCommandResult::PolicyDenied { reason }, + ) => { + assert_eq!(reason, policy_reason); + } + other => panic!("Expected policy-denied shell write result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_redacts_write_to_shell_policy_denial_reason() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some( + api::message::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + .to_string(), + output: format!( + "{}blocked PASSWORD=hunter2 --token raw-token", + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_PREFIX + ), + exit_code: crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }, + ), + ), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::WriteToLongRunningShellCommand( + crate::ai::agent::WriteToLongRunningShellCommandResult::PolicyDenied { reason }, + ) => { + assert!(reason.contains("PASSWORD=")); + assert!(reason.contains("--token ")); + assert!(!reason.contains("hunter2")); + assert!(!reason.contains("raw-token")); + } + other => panic!("Expected policy-denied shell write result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_does_not_reclassify_prefixed_write_output_without_marker() +{ + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let output = format!( + "{}legitimate command output", + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_PREFIX + ); + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some( + api::message::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: "block_1".to_string(), + output: output.clone(), + exit_code: crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }, + ), + ), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::WriteToLongRunningShellCommand( + crate::ai::agent::WriteToLongRunningShellCommandResult::CommandFinished { + block_id, + output: actual_output, + exit_code, + }, + ) => { + assert_eq!(block_id.to_string(), "block_1"); + assert_eq!(actual_output, output); + assert_eq!( + exit_code.value(), + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE + ); + } + other => panic!("Expected finished shell write result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + +#[test] +fn test_convert_tool_call_result_to_input_treats_unlabeled_write_to_shell_error_as_cancelled() { + let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); + let mut document_versions = HashMap::new(); + let tool_call_result = api::message::ToolCallResult { + tool_call_id: "tool_call".to_string(), + context: None, + result: Some( + api::message::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::Error( + api::ShellCommandError { r#type: None }, + ), + ), + }, + ), + ), + }; + + let input = convert_tool_call_result_to_input( + &task_id, + &tool_call_result, + &HashMap::new(), + &mut document_versions, + ) + .unwrap(); + + match input { + AIAgentInput::ActionResult { result, .. } => match result.result { + crate::ai::agent::AIAgentActionResultType::WriteToLongRunningShellCommand( + crate::ai::agent::WriteToLongRunningShellCommandResult::Cancelled, + ) => {} + other => panic!("Expected cancelled shell write result, got {other:?}"), + }, + other => panic!("Expected action-result input, got {other:?}"), + } +} + #[test] fn test_convert_tool_call_result_to_input_upload_artifact_success() { let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); diff --git a/app/src/ai/agent/conversation_yaml.rs b/app/src/ai/agent/conversation_yaml.rs index c5cf04cb8..1c82c2023 100644 --- a/app/src/ai/agent/conversation_yaml.rs +++ b/app/src/ai/agent/conversation_yaml.rs @@ -15,7 +15,14 @@ use api::message::tool_call::Tool; use api::message::tool_call_result::Result as ToolCallResultType; use api::message::Message; +use crate::ai::policy_hooks::redaction::redact_sensitive_text_for_policy; + use super::task::helper::{SubagentExt, ToolExt}; +use super::{ + decode_command_policy_denied_reason, decode_file_edits_policy_denied_reason, + WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + WRITE_TO_SHELL_POLICY_DENIED_PREFIX, +}; const BASE_DIR_NAME: &str = "warp_conversation_search"; @@ -551,6 +558,14 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) } Result::PermissionDenied(_) => { out.push_str("status: permission_denied\n"); + #[allow(deprecated)] + let output = &r.output; + if !output.is_empty() { + let output = decode_command_policy_denied_reason(output) + .unwrap_or_else(|| output.to_string()); + out.push_str("reason: |\n"); + write_block_scalar(out, &redact_sensitive_text_for_policy(&output)); + } } } } @@ -674,7 +689,8 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) } } Result::Error(e) => { - out.push_str(&format!("is_error: true\nerror: {}\n", e.message)); + out.push_str("is_error: true\nerror: |\n"); + write_block_scalar(out, &yaml_safe_apply_file_diffs_error(&e.message)); } } } @@ -741,7 +757,10 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) Result::CommandFinished(c) => { out.push_str(&format!("exit_code: {}\n", c.exit_code)); out.push_str("output: |\n"); - write_block_scalar(out, truncate_content(&c.output, 4096)); + write_block_scalar( + out, + truncate_content(&yaml_safe_shell_command_output(c), 4096), + ); } Result::Error(_) => { out.push_str("status: error\n"); @@ -1042,6 +1061,37 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) } } +fn yaml_safe_apply_file_diffs_error(message: &str) -> String { + if let Some(reason) = decode_file_edits_policy_denied_reason(message) { + return format!( + "File edits blocked by host policy: {}", + redact_sensitive_text_for_policy(&reason) + ); + } + + redact_sensitive_text_for_policy(message) +} + +fn yaml_safe_shell_command_output(command: &api::ShellCommandFinished) -> String { + let is_policy_denial_marker = command.command_id == WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + && command.exit_code == WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE; + if let Some(reason) = is_policy_denial_marker + .then(|| { + command + .output + .strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) + }) + .flatten() + { + return format!( + "{WRITE_TO_SHELL_POLICY_DENIED_PREFIX}{}", + redact_sensitive_text_for_policy(reason) + ); + } + + command.output.clone() +} + /// Looks up the subtask_id for a given tool_call_id by scanning the task's messages for a /// matching Subagent tool call. fn find_subtask_id_for_tool_call(task: &api::Task, tool_call_id: &str) -> Option { diff --git a/app/src/ai/agent/conversation_yaml_tests.rs b/app/src/ai/agent/conversation_yaml_tests.rs index 22aa4002e..a287f39cd 100644 --- a/app/src/ai/agent/conversation_yaml_tests.rs +++ b/app/src/ai/agent/conversation_yaml_tests.rs @@ -3,6 +3,11 @@ use std::path::Path; use warp_multi_agent_api as api; +use crate::ai::agent::{ + encode_command_policy_denied_message, encode_file_edits_policy_denied_message, + WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + WRITE_TO_SHELL_POLICY_DENIED_PREFIX, +}; use crate::test_util::ai_agent_tasks::{ create_api_subtask, create_api_task, create_message, create_subagent_tool_call_message, }; @@ -283,6 +288,152 @@ fn tool_call_result_resolves_tool_name_from_matching_call() { cleanup_dir(&dir); } +#[test] +fn permission_denied_tool_call_result_redacts_deprecated_output() { + let task_id = "root"; + #[allow(deprecated)] + let result = api::RunShellCommandResult { + command: "dangerous".to_string(), + output: encode_command_policy_denied_message("blocked PASSWORD=hunter2 --token raw-token"), + exit_code: 1, + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }; + let tasks = vec![create_api_task( + task_id, + vec![make_tool_call_result_message( + "m1", + task_id, + "tc1", + api::message::tool_call_result::Result::RunShellCommand(result), + )], + )]; + + let dir = materialize_tasks_to_yaml(&tasks).unwrap(); + let files = list_dir_sorted(Path::new(&dir)); + let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap(); + + assert!(content.contains("status: permission_denied")); + assert!(content.contains("PASSWORD=")); + assert!(content.contains("--token ")); + assert!(!content.contains("hunter2")); + assert!(!content.contains("raw-token")); + + cleanup_dir(&dir); +} + +#[test] +fn file_edit_policy_denial_marker_result_redacts_yaml_output() { + let task_id = "root"; + let result = api::ApplyFileDiffsResult { + result: Some(api::apply_file_diffs_result::Result::Error( + api::apply_file_diffs_result::Error { + message: encode_file_edits_policy_denied_message( + "blocked PASSWORD=hunter2 --token raw-token\nstatus: success", + ), + }, + )), + }; + let tasks = vec![create_api_task( + task_id, + vec![make_tool_call_result_message( + "m1", + task_id, + "tc1", + api::message::tool_call_result::Result::ApplyFileDiffs(result), + )], + )]; + + let dir = materialize_tasks_to_yaml(&tasks).unwrap(); + let files = list_dir_sorted(Path::new(&dir)); + let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap(); + + assert!(content.contains("File edits blocked by host policy")); + assert!(content.contains("error: |\n")); + assert!(content.contains("PASSWORD=")); + assert!(content.contains("--token ")); + assert!(content.contains("\n status: success\n")); + assert!(!content.contains("\nstatus: success\n")); + assert!(!content.contains("hunter2")); + assert!(!content.contains("raw-token")); + + cleanup_dir(&dir); +} + +#[test] +fn write_to_shell_policy_denial_marker_result_redacts_yaml_output() { + let task_id = "root"; + let result = api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID.to_string(), + output: format!( + "{WRITE_TO_SHELL_POLICY_DENIED_PREFIX}blocked PASSWORD=hunter2 --token raw-token" + ), + exit_code: WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }; + let tasks = vec![create_api_task( + task_id, + vec![make_tool_call_result_message( + "m1", + task_id, + "tc1", + api::message::tool_call_result::Result::WriteToLongRunningShellCommand(result), + )], + )]; + + let dir = materialize_tasks_to_yaml(&tasks).unwrap(); + let files = list_dir_sorted(Path::new(&dir)); + let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap(); + + assert!(content.contains(WRITE_TO_SHELL_POLICY_DENIED_PREFIX)); + assert!(content.contains("PASSWORD=")); + assert!(content.contains("--token ")); + assert!(!content.contains("hunter2")); + assert!(!content.contains("raw-token")); + + cleanup_dir(&dir); +} + +#[test] +fn write_to_shell_reserved_id_without_policy_prefix_is_not_labeled_policy_denial_in_yaml() { + let task_id = "root"; + let result = api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID.to_string(), + output: "permission denied writing to pty".to_string(), + exit_code: WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }; + let tasks = vec![create_api_task( + task_id, + vec![make_tool_call_result_message( + "m1", + task_id, + "tc1", + api::message::tool_call_result::Result::WriteToLongRunningShellCommand(result), + )], + )]; + + let dir = materialize_tasks_to_yaml(&tasks).unwrap(); + let files = list_dir_sorted(Path::new(&dir)); + let content = fs::read_to_string(Path::new(&dir).join(&files[0])).unwrap(); + + assert!(content.contains("permission denied writing to pty")); + assert!(!content.contains(WRITE_TO_SHELL_POLICY_DENIED_PREFIX)); + + cleanup_dir(&dir); +} + #[test] fn server_tool_calls_are_skipped() { let task_id = "root"; diff --git a/app/src/ai/agent/mod.rs b/app/src/ai/agent/mod.rs index e84dda499..423c094ed 100644 --- a/app/src/ai/agent/mod.rs +++ b/app/src/ai/agent/mod.rs @@ -1000,6 +1000,12 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> { "\nCommand ({command}) was on denylist and so was not allowed to run" ) } + RequestCommandOutputResult::PolicyDenied { command, reason } => { + write!( + f, + "\nCommand ({command}) was blocked by host policy before execution: {reason}" + ) + } }, AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { WriteToLongRunningShellCommandResult::CommandFinished { output, .. } => { @@ -1014,6 +1020,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> { WriteToLongRunningShellCommandResult::Error(e) => { write!(f, "\n_Write to command failed: {e:?}") } + WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { + write!(f, "\n_Write to command blocked by host policy: {reason}_") + } }, AIAgentActionResultType::RequestFileEdits(result) => match result { RequestFileEditsResult::Success { diff, .. } => { @@ -1023,6 +1032,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> { RequestFileEditsResult::DiffApplicationFailed { error } => { write!(f, "\n_File edits failed: {error} _") } + RequestFileEditsResult::PolicyDenied { reason } => { + write!(f, "\n_File edits blocked by host policy: {reason}_") + } }, AIAgentActionResultType::ReadFiles(result) => match result { ReadFilesResult::Success { files } => { diff --git a/app/src/ai/agent/redaction.rs b/app/src/ai/agent/redaction.rs index 11063cbf3..4729dbfe4 100644 --- a/app/src/ai/agent/redaction.rs +++ b/app/src/ai/agent/redaction.rs @@ -9,6 +9,7 @@ use crate::ai::agent::{ use super::super::blocklist::block::secret_redaction::{ find_secrets_in_text, SECRET_REDACTION_REPLACEMENT_CHARACTER, }; +use super::super::policy_hooks::redaction::redact_sensitive_text_for_policy; /// Redact all detected secrets in-place within the given string. pub(crate) fn redact_secrets(input: &mut String) { @@ -109,16 +110,34 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) { AIAgentInput::ActionResult { result, context } => { redact_context(Arc::make_mut(context)); match &mut result.result { - AIAgentActionResultType::RequestCommandOutput(output) => { - if let RequestCommandOutputResult::Completed { output, .. } = output { + AIAgentActionResultType::RequestCommandOutput(output) => match output { + RequestCommandOutputResult::Completed { output, .. } => { redact_secrets(output); } - } + RequestCommandOutputResult::PolicyDenied { command, reason } => { + *command = redact_sensitive_text_for_policy(command); + redact_secrets(command); + *reason = redact_sensitive_text_for_policy(reason); + redact_secrets(reason); + } + RequestCommandOutputResult::LongRunningCommandSnapshot { + grid_contents, + .. + } => redact_secrets(grid_contents), + RequestCommandOutputResult::Denylisted { command } => { + redact_secrets(command); + } + RequestCommandOutputResult::CancelledBeforeExecution => {} + }, AIAgentActionResultType::WriteToLongRunningShellCommand(result) => { use crate::ai::agent::WriteToLongRunningShellCommandResult::*; match result { Snapshot { grid_contents, .. } => redact_secrets(grid_contents), CommandFinished { output, .. } => redact_secrets(output), + PolicyDenied { reason } => { + *reason = redact_sensitive_text_for_policy(reason); + redact_secrets(reason); + } Error(_) | Cancelled => {} } } @@ -192,6 +211,12 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) { for file_path in deleted_files { redact_secrets(file_path); } + } else if let crate::ai::agent::RequestFileEditsResult::PolicyDenied { + reason, + } = request_file_edits_result + { + *reason = redact_sensitive_text_for_policy(reason); + redact_secrets(reason); } } AIAgentActionResultType::InsertReviewComments(result) => { @@ -407,3 +432,126 @@ fn redact_attachment(attachment: &mut AIAgentAttachment) { AIAgentAttachment::FilePathReference { .. } => {} } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::ai::agent::task::TaskId; + use crate::ai::agent::{ + AIAgentActionResult, AIAgentActionResultType, RequestFileEditsResult, + WriteToLongRunningShellCommandResult, + }; + + #[test] + fn redact_inputs_redacts_policy_denied_command_result_command() { + let secret = "sk-secretsecretsecret"; + let mut inputs = vec![AIAgentInput::ActionResult { + result: AIAgentActionResult { + id: "action_1".to_string().into(), + task_id: TaskId::new("task_1".to_string()), + result: AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { + command: format!( + "OPENAI_API_KEY={secret} guard --client-secret raw-client-secret" + ), + reason: format!("blocked token {secret}"), + }, + ), + }, + context: Arc::from([]), + }]; + + redact_inputs(&mut inputs); + + let AIAgentInput::ActionResult { result, .. } = &inputs[0] else { + panic!("expected action result"); + }; + let AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { command, reason }, + ) = &result.result + else { + panic!("expected policy denied command result"); + }; + + assert!(command.contains("OPENAI_API_KEY=")); + assert!(command.contains("--client-secret ")); + assert!(!command.contains(secret)); + assert!(!command.contains("raw-client-secret")); + assert!(!reason.contains(secret)); + } + + #[test] + fn redact_inputs_redacts_policy_denied_write_to_shell_reason() { + let mut inputs = vec![AIAgentInput::ActionResult { + result: AIAgentActionResult { + id: "action_1".to_string().into(), + task_id: TaskId::new("task_1".to_string()), + result: AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: "blocked PASSWORD=hunter2 --token raw-token Authorization: Bearer rawbearer" + .to_string(), + }, + ), + }, + context: Arc::from([]), + }]; + + redact_inputs(&mut inputs); + + let AIAgentInput::ActionResult { result, .. } = &inputs[0] else { + panic!("expected action result"); + }; + let AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { reason }, + ) = &result.result + else { + panic!("expected policy denied shell write result"); + }; + + assert!(reason.contains("PASSWORD=")); + assert!(reason.contains("--token ")); + assert!(reason.contains("Authorization: Bearer ")); + assert!(!reason.contains("hunter2")); + assert!(!reason.contains("raw-token")); + assert!(!reason.contains("rawbearer")); + } + + #[test] + fn redact_inputs_redacts_policy_denied_file_edit_reason() { + let mut inputs = vec![AIAgentInput::ActionResult { + result: AIAgentActionResult { + id: "action_1".to_string().into(), + task_id: TaskId::new("task_1".to_string()), + result: AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::PolicyDenied { + reason: + "blocked PASSWORD=hunter2 --token raw-token Authorization: Bearer rawbearer" + .to_string(), + }, + ), + }, + context: Arc::from([]), + }]; + + redact_inputs(&mut inputs); + + let AIAgentInput::ActionResult { result, .. } = &inputs[0] else { + panic!("expected action result"); + }; + let AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::PolicyDenied { + reason, + }) = &result.result + else { + panic!("expected policy denied file-edit result"); + }; + + assert!(reason.contains("PASSWORD=")); + assert!(reason.contains("--token ")); + assert!(reason.contains("Authorization: Bearer ")); + assert!(!reason.contains("hunter2")); + assert!(!reason.contains("raw-token")); + assert!(!reason.contains("rawbearer")); + } +} diff --git a/app/src/ai/agent_sdk/driver/output.rs b/app/src/ai/agent_sdk/driver/output.rs index f7e879a7f..f97431412 100644 --- a/app/src/ai/agent_sdk/driver/output.rs +++ b/app/src/ai/agent_sdk/driver/output.rs @@ -69,6 +69,9 @@ pub mod text { "Command was not allowed to run due to presence on denylist" ) } + RequestCommandOutputResult::PolicyDenied { reason, .. } => { + writeln!(w, "Command was blocked by host policy: {reason}") + } }, AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { WriteToLongRunningShellCommandResult::Snapshot { .. } => { @@ -85,6 +88,9 @@ pub mod text { WriteToLongRunningShellCommandResult::Error(_) => { writeln!(w, "Failed to write to command.") } + WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { + writeln!(w, "Writing to command blocked by host policy: {reason}") + } }, AIAgentActionResultType::RequestFileEdits(result) => match result { RequestFileEditsResult::Success { @@ -106,6 +112,9 @@ pub mod text { RequestFileEditsResult::DiffApplicationFailed { error } => { writeln!(w, "Editing files failed: {error}") } + RequestFileEditsResult::PolicyDenied { reason } => { + writeln!(w, "Editing files blocked by host policy: {reason}") + } }, AIAgentActionResultType::ReadFiles(result) => match result { ReadFilesResult::Success { .. } => Ok(()), @@ -825,6 +834,13 @@ pub mod json { "Command was not allowed to run due to presence on denylist", ), }), + RequestCommandOutputResult::PolicyDenied { reason, .. } => { + Some(JsonMessage::ToolError { + error: Cow::Owned(format!( + "Command was blocked by host policy: {reason}" + )), + }) + } }, AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { WriteToLongRunningShellCommandResult::Snapshot { .. } => { @@ -847,6 +863,13 @@ pub mod json { error: "Failed to write to command.".into(), }) } + WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { + Some(JsonMessage::ToolError { + error: Cow::Owned(format!( + "Writing to command blocked by host policy: {reason}" + )), + }) + } WriteToLongRunningShellCommandResult::Cancelled => { Some(JsonMessage::ToolCanceled) } @@ -860,6 +883,13 @@ pub mod json { error: Cow::Borrowed(error.as_str()), }) } + RequestFileEditsResult::PolicyDenied { reason } => { + Some(JsonMessage::ToolError { + error: Cow::Owned(format!( + "File edits blocked by host policy: {reason}" + )), + }) + } RequestFileEditsResult::Cancelled => Some(JsonMessage::ToolCanceled), }, AIAgentActionResultType::ReadFiles(result) => match result { @@ -1284,6 +1314,46 @@ pub mod json { let message = JsonMessage::System(JsonSystemEvent::SharedSessionEstablished { join_url }); write_message(&message, w) } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn json_command_policy_denial_preserves_host_policy_signal() { + let result = AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { + command: "rm -rf target".to_string(), + reason: "blocked by guard".to_string(), + }, + ); + let message = JsonMessage::from_action_result(&result).unwrap(); + let value = serde_json::to_value(message).unwrap(); + + assert_eq!(value["type"], "tool_error"); + assert_eq!( + value["error"], + "Command was blocked by host policy: blocked by guard" + ); + } + + #[test] + fn json_write_to_shell_policy_denial_preserves_host_policy_signal() { + let result = AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: "interactive write blocked".to_string(), + }, + ); + let message = JsonMessage::from_action_result(&result).unwrap(); + let value = serde_json::to_value(message).unwrap(); + + assert_eq!(value["type"], "tool_error"); + assert_eq!( + value["error"], + "Writing to command blocked by host policy: interactive write blocked" + ); + } + } } use crate::ai::agent::{AIAgentText, AIAgentTextSection}; diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 486152841..3107d92bb 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -285,6 +285,9 @@ impl BlocklistAIActionModel { } => { me.handle_action_result(*conversation_id, result.clone(), *cancellation_reason, ctx) } + BlocklistAIActionExecutorEvent::PolicyPreflightFinished { conversation_id } => { + me.try_to_execute_available_actions(*conversation_id, ctx); + } BlocklistAIActionExecutorEvent::InitProject(id) => { ctx.emit(BlocklistAIActionEvent::InitProject(id.clone())) } @@ -747,12 +750,16 @@ impl BlocklistAIActionModel { )); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { let blocked_action_user_friendly_str = action.action.user_friendly_name(); + let blocked_action = match reason.policy_reason() { + Some(policy_reason) => { + format!("{blocked_action_user_friendly_str:?}: {policy_reason}") + } + None => format!("{blocked_action_user_friendly_str:?}"), + }; history_model.update_conversation_status( self.terminal_view_id, conversation_id, - ConversationStatus::Blocked { - blocked_action: format!("{blocked_action_user_friendly_str:?}"), - }, + ConversationStatus::Blocked { blocked_action }, ctx, ); }); @@ -984,6 +991,9 @@ impl BlocklistAIActionModel { .find_position(|action| action.id == *action_id) { if let Some(action) = pending_actions_for_conversation.remove(idx) { + self.executor.update(ctx, |executor, _ctx| { + executor.cancel_policy_preflight_for_action(conversation_id, &action.id); + }); self.cancel_pending_action(conversation_id, action, Some(reason), ctx); } } @@ -1004,6 +1014,9 @@ impl BlocklistAIActionModel { return; }; for action in actions_to_cancel.drain(..).collect_vec() { + self.executor.update(ctx, |executor, _ctx| { + executor.cancel_policy_preflight_for_action(conversation_id, &action.id); + }); self.cancel_pending_action(conversation_id, action, reason, ctx); } } diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 0ef183d53..30c6028bd 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -20,6 +20,11 @@ pub(super) mod suggest_prompt; pub(super) mod upload_artifact; pub(super) mod use_computer; +#[cfg(not(target_family = "wasm"))] +use ai::agent::action_result::{ + CallMCPToolResult, ReadFilesResult, ReadMCPResourceResult, RequestFileEditsResult, + WriteToLongRunningShellCommandResult, +}; use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult}; pub use ask_user_question::AskUserQuestionExecutor; pub(crate) use call_mcp_tool::coerce_integer_args; @@ -28,7 +33,7 @@ use create_documents::CreateDocumentsExecutor; use edit_documents::EditDocumentsExecutor; use fetch_conversation::FetchConversationExecutor; use file_glob::FileGlobExecutor; -use futures::{future::BoxFuture, FutureExt}; +use futures::{channel::oneshot, future::BoxFuture, FutureExt}; use grep::GrepExecutor; use parking_lot::FairMutex; use read_documents::ReadDocumentsExecutor; @@ -58,13 +63,17 @@ use warp_core::{execution_mode::AppExecutionMode, features::FeatureFlag}; use crate::util::openable_file_type::is_binary_file; #[cfg(feature = "local_fs")] use futures::AsyncReadExt; -use std::{any::Any, path::PathBuf, pin::Pin, sync::Arc}; +#[cfg(not(target_family = "wasm"))] +use std::collections::HashSet; +use std::{any::Any, collections::HashMap, path::PathBuf, pin::Pin, sync::Arc}; #[cfg(feature = "local_fs")] use warp_files::{FileModel, TextFileReadResult}; #[cfg(feature = "local_fs")] use warp_util::file::FileLoadError; #[cfg(feature = "local_fs")] use warp_util::file_type::is_buffer_binary; +#[cfg(not(target_family = "wasm"))] +use warp_util::path::{EscapeChar, ShellFamily}; use warpui::{ r#async::{Spawnable, SpawnableOutput}, AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, @@ -79,13 +88,25 @@ use mime_guess::from_path; use self::search_codebase::SearchCodebaseExecutor; #[cfg(feature = "local_fs")] -use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path}; +use crate::ai::agent::AnyFileContent; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::{ + permissions::{ + CommandExecutionPermission, CommandExecutionPermissionDeniedReason, FileWritePermission, + FileWritePermissionDeniedReason, + }, + BlocklistAIPermissions, +}; +#[cfg(not(target_family = "wasm"))] +use crate::ai::mcp::TemplatableMCPServerManager; +#[cfg(any(feature = "local_fs", not(target_family = "wasm")))] +use crate::ai::paths::host_native_absolute_path; use crate::{ ai::{ agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult, - AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext, - FileLocations, ServerOutputId, + AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode, CancellationReason, + FileContext, FileEdit, FileLocations, ServerOutputId, }, ambient_agents::AmbientAgentTaskId, get_relevant_files::controller::GetRelevantFilesController, @@ -98,6 +119,20 @@ use crate::{ }, BlocklistAIHistoryModel, }; +#[cfg(not(target_family = "wasm"))] +use ai::diff_validation::ParsedDiff; + +#[cfg(not(target_family = "wasm"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +#[cfg(not(target_family = "wasm"))] +use crate::ai::policy_hooks::{ + decision::{compose_policy_decisions, WarpPermissionDecisionKind}, + redaction::{redact_command_for_policy, redact_sensitive_text_for_policy}, + AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, + AgentPolicyHookConfig, AgentPolicyHookEngine, PolicyCallMcpToolAction, + PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, + PolicyWriteFilesAction, PolicyWriteToLongRunningShellCommandAction, WarpPermissionSnapshot, +}; /// Types of actions that can be executed in parallel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -128,6 +163,51 @@ struct PreprocessActionInput<'a> { conversation_id: AIConversationId, } +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PolicyPreflightKey { + conversation_id: AIConversationId, + action_id: AIAgentActionId, + working_directory: Option, + run_until_completion: bool, + active_profile_id: Option, + raw_action: String, + policy_action: AgentPolicyAction, + warp_permission: WarpPermissionSnapshot, + hook_config: AgentPolicyHookConfig, +} + +#[cfg(not(target_family = "wasm"))] +impl PolicyPreflightKey { + fn new( + conversation_id: AIConversationId, + action_id: AIAgentActionId, + action: &AIAgentAction, + event: &AgentPolicyEvent, + hook_config: &AgentPolicyHookConfig, + ) -> Self { + Self { + conversation_id, + action_id, + working_directory: event.working_directory.clone(), + run_until_completion: event.run_until_completion, + active_profile_id: event.active_profile_id.clone(), + raw_action: raw_policy_action_key(action), + policy_action: event.action.clone(), + warp_permission: event.warp_permission.clone(), + hook_config: hook_config.clone(), + } + } + + fn matches_action( + &self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) -> bool { + self.conversation_id == conversation_id && self.action_id == *action_id + } +} + type AsyncExecuteActionFn = Pin>>; type OnCompleteFn = Box AIAgentActionResultType>; @@ -170,6 +250,15 @@ enum AnyActionExecution { InvalidAction, } +#[cfg(not(target_family = "wasm"))] +#[derive(Debug, PartialEq)] +enum PolicyPreflightState { + Pending, + Allowed { skip_confirmation: bool }, + NeedsConfirmation(Option), + Denied(AIAgentActionResultType), +} + impl From> for AnyActionExecution where T: Send + 'static, @@ -195,16 +284,25 @@ where } } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub enum NotExecutedReason { NotReady, - NeedsConfirmation, + NeedsConfirmation { policy_reason: Option }, WaitingOnSharer, } impl NotExecutedReason { pub fn needs_confirmation(&self) -> bool { - matches!(self, Self::NeedsConfirmation) + matches!(self, Self::NeedsConfirmation { .. }) + } + + pub fn policy_reason(&self) -> Option<&str> { + match self { + Self::NeedsConfirmation { + policy_reason: Some(reason), + } => Some(reason.as_str()), + _ => None, + } } } @@ -239,6 +337,8 @@ impl AsyncExecutingAction { } pub struct BlocklistAIActionExecutor { + active_session: ModelHandle, + terminal_view_id: EntityId, shell_command_executor: ModelHandle, read_files_executor: ModelHandle, upload_artifact_executor: ModelHandle, @@ -263,7 +363,13 @@ pub struct BlocklistAIActionExecutor { /// The actions currently executing asynchronously, keyed by action ID. /// We track them per action rather than as a single slot so multiple actions from the same /// parallel phase can complete independently. - async_executing_actions: std::collections::HashMap, + async_executing_actions: HashMap, + #[cfg(not(target_family = "wasm"))] + pending_policy_preflights: HashSet, + #[cfg(not(target_family = "wasm"))] + completed_policy_preflights: HashMap, + #[cfg(not(target_family = "wasm"))] + confirmed_file_edit_policy_preprocesses: HashSet<(AIConversationId, AIAgentActionId)>, /// Reference to the terminal model for checking session sharing state. terminal_model: Arc>, @@ -327,6 +433,8 @@ impl BlocklistAIActionExecutor { let ask_user_question_executor = ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id)); Self { + active_session, + terminal_view_id, shell_command_executor, read_files_executor, upload_artifact_executor, @@ -344,6 +452,12 @@ impl BlocklistAIActionExecutor { use_computer_executor, request_computer_use_executor, async_executing_actions: Default::default(), + #[cfg(not(target_family = "wasm"))] + pending_policy_preflights: Default::default(), + #[cfg(not(target_family = "wasm"))] + completed_policy_preflights: Default::default(), + #[cfg(not(target_family = "wasm"))] + confirmed_file_edit_policy_preprocesses: Default::default(), terminal_model, read_skill_executor, fetch_conversation_executor, @@ -428,7 +542,7 @@ impl BlocklistAIActionExecutor { } pub fn preprocess_action( - &self, + &mut self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, @@ -443,6 +557,11 @@ impl BlocklistAIActionExecutor { conversation_id, }; + #[cfg(not(target_family = "wasm"))] + if let Some(preprocess) = self.preprocess_request_file_edits_after_policy(&input, ctx) { + return preprocess; + } + match &action.action { AIAgentActionType::RequestCommandOutput { .. } | AIAgentActionType::WriteToLongRunningShellCommand { .. } @@ -544,6 +663,10 @@ impl BlocklistAIActionExecutor { }; let can_auto_execute = self.should_autoexecute(input, ctx); let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous(); + let autonomous_shell_command_denied = !is_user_initiated + && !can_auto_execute + && is_agent_autonomous + && action.action.is_request_command_output(); // The agent cannot auto execute and either: // - the agent is interactive, OR @@ -551,12 +674,72 @@ impl BlocklistAIActionExecutor { let needs_confirmation = !(is_user_initiated || can_auto_execute || (is_agent_autonomous && action.action.is_request_command_output())); - if needs_confirmation { + let mut skip_confirmation = false; + #[cfg(not(target_family = "wasm"))] + if let Some(preflight_state) = self.start_policy_preflight_if_needed( + &action, + conversation_id, + is_user_initiated, + can_auto_execute, + needs_confirmation, + autonomous_shell_command_denied, + ctx, + ) { + match preflight_state { + PolicyPreflightState::Pending => { + return TryExecuteResult::NotExecuted { + action: Box::new(action), + reason: NotExecutedReason::NotReady, + }; + } + PolicyPreflightState::NeedsConfirmation(policy_reason) => { + return TryExecuteResult::NotExecuted { + action: Box::new(action), + reason: NotExecutedReason::NeedsConfirmation { policy_reason }, + }; + } + PolicyPreflightState::Denied(result) => { + let action_id = action.id.clone(); + ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { + action_id: action_id.clone(), + }); + ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { + result: Arc::new(AIAgentActionResult { + id: action_id, + task_id: action.task_id, + result, + }), + conversation_id, + cancellation_reason: None, + }); + + return TryExecuteResult::ExecutedSync; + } + PolicyPreflightState::Allowed { + skip_confirmation: policy_skip_confirmation, + } => { + skip_confirmation = policy_skip_confirmation; + } + } + } + if needs_confirmation && !skip_confirmation { + return TryExecuteResult::NotExecuted { + action: Box::new(action), + reason: NotExecutedReason::NeedsConfirmation { + policy_reason: None, + }, + }; + } + + #[cfg(not(target_family = "wasm"))] + if self.start_request_file_edits_preprocess_if_needed(&action, conversation_id, ctx) { return TryExecuteResult::NotExecuted { action: Box::new(action), - reason: NotExecutedReason::NeedsConfirmation, + reason: NotExecutedReason::NotReady, }; - } else if !is_user_initiated && !can_auto_execute && is_agent_autonomous { + } + + if !is_user_initiated && !can_auto_execute && is_agent_autonomous { // It must be the case that the autonomous agent is requesting a denylisted command. if let AIAgentActionType::RequestCommandOutput { command, .. } = &action.action { let action_id = action.id.clone(); @@ -781,6 +964,495 @@ impl BlocklistAIActionExecutor { ) } + #[cfg(not(target_family = "wasm"))] + #[allow(clippy::too_many_arguments)] + fn warp_permission_snapshot_for_action( + &self, + action: &AIAgentAction, + conversation_id: AIConversationId, + is_user_initiated: bool, + can_auto_execute: bool, + needs_confirmation: bool, + autonomous_shell_command_denied: bool, + ctx: &mut ModelContext, + ) -> WarpPermissionSnapshot { + let terminal_denial_reason = self.terminal_warp_denial_reason_for_action( + action, + conversation_id, + is_user_initiated, + ctx, + ); + + warp_permission_snapshot_for_policy( + is_user_initiated, + can_auto_execute, + needs_confirmation, + autonomous_shell_command_denied, + terminal_denial_reason, + ) + } + + #[cfg(not(target_family = "wasm"))] + fn terminal_warp_denial_reason_for_action( + &self, + action: &AIAgentAction, + conversation_id: AIConversationId, + is_user_initiated: bool, + ctx: &mut ModelContext, + ) -> Option { + match &action.action { + AIAgentActionType::RequestCommandOutput { + command, + is_read_only, + is_risky, + .. + } => { + if is_user_initiated { + return None; + } + + let shell_type = self.active_session.as_ref(ctx).shell_type(ctx); + terminal_command_denial_reason_for_policy(shell_type, |escape_char| { + BlocklistAIPermissions::as_ref(ctx).can_autoexecute_command( + &conversation_id, + command, + escape_char, + is_read_only.unwrap_or(false), + *is_risky, + Some(self.terminal_view_id), + ctx, + ) + }) + } + AIAgentActionType::RequestFileEdits { file_edits, .. } => { + let paths = file_edit_paths(file_edits) + .into_iter() + .map(PathBuf::from) + .collect::>(); + match BlocklistAIPermissions::as_ref(ctx).can_write_files( + &conversation_id, + &paths, + Some(self.terminal_view_id), + ctx, + ) { + FileWritePermission::Denied(FileWritePermissionDeniedReason::ProtectedPath) => { + Some("file path is protected by Warp permissions".to_string()) + } + _ => None, + } + } + AIAgentActionType::CallMCPTool { + server_id, name, .. + } => self + .resolve_mcp_tool_template_uuid(server_id.as_ref(), name, ctx) + .and_then(|server_uuid| self.mcp_denylist_denial_reason(server_uuid, ctx)), + AIAgentActionType::ReadMCPResource { + server_id, + name, + uri, + } => self + .resolve_mcp_resource_template_uuid(server_id.as_ref(), name, uri.as_deref(), ctx) + .and_then(|server_uuid| self.mcp_denylist_denial_reason(server_uuid, ctx)), + _ => None, + } + } + + #[cfg(not(target_family = "wasm"))] + fn resolve_mcp_tool_template_uuid( + &self, + server_id: Option<&uuid::Uuid>, + name: &str, + ctx: &AppContext, + ) -> Option { + let templatable_manager = TemplatableMCPServerManager::as_ref(ctx); + server_id + .and_then(|id| templatable_manager.get_template_uuid(*id)) + .or_else(|| { + templatable_manager + .server_from_tool(name.to_string()) + .copied() + .and_then(|installation_uuid| { + templatable_manager.get_template_uuid(installation_uuid) + }) + }) + } + + #[cfg(not(target_family = "wasm"))] + fn resolve_mcp_resource_template_uuid( + &self, + server_id: Option<&uuid::Uuid>, + name: &str, + uri: Option<&str>, + ctx: &AppContext, + ) -> Option { + let templatable_manager = TemplatableMCPServerManager::as_ref(ctx); + server_id + .and_then(|id| templatable_manager.get_template_uuid(*id)) + .or_else(|| { + templatable_manager + .server_from_resource(name, uri) + .copied() + .and_then(|installation_uuid| { + templatable_manager.get_template_uuid(installation_uuid) + }) + }) + } + + #[cfg(not(target_family = "wasm"))] + fn mcp_denylist_denial_reason( + &self, + server_uuid: uuid::Uuid, + ctx: &AppContext, + ) -> Option { + BlocklistAIPermissions::as_ref(ctx) + .get_mcp_denylist(ctx, Some(self.terminal_view_id)) + .contains(&server_uuid) + .then(|| "MCP server is denylisted by Warp permissions".to_string()) + } + + #[cfg(not(target_family = "wasm"))] + fn preprocess_request_file_edits_after_policy( + &mut self, + input: &PreprocessActionInput<'_>, + ctx: &mut ModelContext, + ) -> Option> { + if !matches!( + input.action.action, + AIAgentActionType::RequestFileEdits { .. } + ) { + return None; + } + + let active_profile = + AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(self.terminal_view_id), ctx); + let permissions_profile = crate::ai::blocklist::BlocklistAIPermissions::as_ref(ctx) + .permissions_profile_for_id(ctx, *active_profile.id()); + let config = permissions_profile.agent_policy_hooks; + if !config.is_active() { + self.remove_policy_preflights_for_action(input.conversation_id, &input.action.id); + return None; + } + + let can_auto_execute = self.should_autoexecute( + ExecuteActionInput { + action: input.action, + conversation_id: input.conversation_id, + }, + ctx, + ); + let warp_permission = self.warp_permission_snapshot_for_action( + input.action, + input.conversation_id, + false, + can_auto_execute, + !can_auto_execute, + false, + ctx, + ); + let event = self.agent_policy_event( + input.action, + input.conversation_id, + Some(active_profile.id().to_string()), + warp_permission.clone(), + config.allow_autoapproval_for_all_hooks(), + ctx, + )?; + + let action = (*input.action).clone(); + let conversation_id = input.conversation_id; + let preflight_key = + PolicyPreflightKey::new(conversation_id, action.id.clone(), &action, &event, &config); + let (done_tx, done_rx) = oneshot::channel(); + let engine = AgentPolicyHookEngine::new(config); + self.remove_policy_preflights_for_action(conversation_id, &action.id); + self.pending_policy_preflights.insert(preflight_key.clone()); + + ctx.spawn( + async move { engine.preflight(event, warp_permission).await }, + move |me, decision, ctx| { + let should_preprocess = + should_preprocess_file_edits_after_policy_decision(&action, &decision); + if !complete_policy_preflight_if_pending( + &mut me.pending_policy_preflights, + &mut me.completed_policy_preflights, + preflight_key.clone(), + decision, + ) { + let _ = done_tx.send(()); + return; + } + + if !should_preprocess { + let _ = done_tx.send(()); + return; + } + + let preprocess = me.request_file_edits_executor.update(ctx, |executor, ctx| { + executor.preprocess_action( + PreprocessActionInput { + action: &action, + conversation_id, + }, + ctx, + ) + }); + ctx.spawn(preprocess, move |_me, _, _ctx| { + let _ = done_tx.send(()); + }); + }, + ); + + Some( + async { + let _ = done_rx.await; + } + .boxed(), + ) + } + + #[cfg(not(target_family = "wasm"))] + fn start_request_file_edits_preprocess_if_needed( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> bool { + if !matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) { + return false; + } + + let already_preprocessed = self + .request_file_edits_executor + .update(ctx, |executor, _ctx| { + executor.has_preprocessed_action(conversation_id, action, _ctx) + }); + if already_preprocessed { + return false; + } + + let active_profile = + AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(self.terminal_view_id), ctx); + let policy_hooks_active = crate::ai::blocklist::BlocklistAIPermissions::as_ref(ctx) + .permissions_profile_for_id(ctx, *active_profile.id()) + .agent_policy_hooks + .is_active(); + if policy_hooks_active { + self.confirmed_file_edit_policy_preprocesses + .insert((conversation_id, action.id.clone())); + } + + let action = action.clone(); + let preprocess = self + .request_file_edits_executor + .update(ctx, |executor, ctx| { + executor.preprocess_action( + PreprocessActionInput { + action: &action, + conversation_id, + }, + ctx, + ) + }); + ctx.spawn(preprocess, move |_me, _, ctx| { + ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { conversation_id }); + }); + + true + } + + #[cfg(not(target_family = "wasm"))] + #[allow(clippy::too_many_arguments)] + fn start_policy_preflight_if_needed( + &mut self, + action: &AIAgentAction, + conversation_id: AIConversationId, + is_user_initiated: bool, + can_auto_execute: bool, + needs_confirmation: bool, + autonomous_shell_command_denied: bool, + ctx: &mut ModelContext, + ) -> Option { + let active_profile = + AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(self.terminal_view_id), ctx); + let permissions_profile = crate::ai::blocklist::BlocklistAIPermissions::as_ref(ctx) + .permissions_profile_for_id(ctx, *active_profile.id()); + let config = permissions_profile.agent_policy_hooks; + + if !config.is_active() { + self.remove_policy_preflights_for_action(conversation_id, &action.id); + return None; + } + + let warp_permission = self.warp_permission_snapshot_for_action( + action, + conversation_id, + is_user_initiated, + can_auto_execute, + needs_confirmation, + autonomous_shell_command_denied, + ctx, + ); + let event = self.agent_policy_event( + action, + conversation_id, + Some(active_profile.id().to_string()), + warp_permission.clone(), + config.allow_autoapproval_for_all_hooks(), + ctx, + )?; + let preflight_key = + PolicyPreflightKey::new(conversation_id, action.id.clone(), action, &event, &config); + let confirmed_file_edit_policy_preprocess = + matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) + && self + .confirmed_file_edit_policy_preprocesses + .remove(&(conversation_id, action.id.clone())); + + if confirmed_file_edit_policy_preprocess { + if let Some(decision) = self + .completed_policy_preflights + .get(&preflight_key) + .cloned() + { + let state = confirmed_file_edit_policy_preprocess_state_from_cached_decision( + action, + &decision, + warp_permission.clone(), + config.allow_autoapproval_for_all_hooks(), + ); + if should_consume_completed_policy_preflight(&state) { + self.completed_policy_preflights.remove(&preflight_key); + } + return Some(state); + } + } + + if let Some(decision) = self + .completed_policy_preflights + .get(&preflight_key) + .cloned() + { + let decision = recompose_completed_policy_decision( + &decision, + warp_permission, + config.allow_autoapproval_for_all_hooks(), + ); + let state = policy_preflight_state_from_decision(action, &decision, is_user_initiated); + let already_preprocessed_file_edit = matches!( + (&action.action, &state), + ( + AIAgentActionType::RequestFileEdits { .. }, + PolicyPreflightState::Allowed { .. } + ) + ) && self + .request_file_edits_executor + .update(ctx, |executor, _ctx| { + executor.has_preprocessed_action(conversation_id, action, _ctx) + }); + let should_preserve_for_file_edit_preprocess = + should_preserve_completed_policy_preflight_for_file_edit_preprocess( + action, + &state, + already_preprocessed_file_edit, + ); + if should_consume_completed_policy_preflight(&state) + && !should_preserve_for_file_edit_preprocess + { + self.completed_policy_preflights.remove(&preflight_key); + } + return Some(state); + } + + if self.pending_policy_preflights.contains(&preflight_key) { + return Some(PolicyPreflightState::Pending); + } + + self.remove_policy_preflights_for_action(conversation_id, &action.id); + self.pending_policy_preflights.insert(preflight_key.clone()); + let engine = AgentPolicyHookEngine::new(config); + ctx.spawn( + async move { engine.preflight(event, warp_permission).await }, + move |me, decision, ctx| { + if !complete_policy_preflight_if_pending( + &mut me.pending_policy_preflights, + &mut me.completed_policy_preflights, + preflight_key.clone(), + decision, + ) { + return; + } + ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { + conversation_id, + }); + }, + ); + + Some(PolicyPreflightState::Pending) + } + + pub fn cancel_policy_preflight_for_action( + &mut self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) { + #[cfg(not(target_family = "wasm"))] + { + self.remove_policy_preflights_for_action(conversation_id, action_id); + } + } + + #[cfg(not(target_family = "wasm"))] + fn remove_policy_preflights_for_action( + &mut self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) { + self.pending_policy_preflights + .retain(|key| !key.matches_action(conversation_id, action_id)); + self.completed_policy_preflights + .retain(|key, _| !key.matches_action(conversation_id, action_id)); + self.confirmed_file_edit_policy_preprocesses + .remove(&(conversation_id, action_id.clone())); + } + + #[cfg(not(target_family = "wasm"))] + fn agent_policy_event( + &self, + action: &AIAgentAction, + conversation_id: AIConversationId, + active_profile_id: Option, + warp_permission: WarpPermissionSnapshot, + hook_autoapproval_enabled: bool, + ctx: &mut ModelContext, + ) -> Option { + let current_working_directory = self + .active_session + .as_ref(ctx) + .current_working_directory() + .cloned(); + let shell = self.active_session.as_ref(ctx).shell_launch_data(ctx); + let shell_type = self.active_session.as_ref(ctx).shell_type(ctx); + let working_directory = current_working_directory.as_ref().map(PathBuf::from); + let run_until_completion = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .is_some_and(|conversation| conversation.autoexecute_any_action()); + let policy_action = + agent_policy_action(action, shell_type, &shell, ¤t_working_directory)?; + + Some( + AgentPolicyEvent::new( + conversation_id.to_string(), + action.id.to_string(), + working_directory, + run_until_completion, + active_profile_id, + warp_permission, + policy_action, + ) + .with_hook_autoapproval_enabled(hook_autoapproval_enabled), + ) + } + pub fn cancel_running_async_action( &mut self, action_id: &AIAgentActionId, @@ -909,6 +1581,452 @@ impl BlocklistAIActionExecutor { self.terminal_model.lock().is_shared_session_viewer() } } + +#[cfg(not(target_family = "wasm"))] +fn terminal_command_denial_reason_for_policy( + shell_type: Option, + permission_for_escape_char: impl FnOnce(EscapeChar) -> CommandExecutionPermission, +) -> Option { + let Some(shell_type) = shell_type else { + return Some( + "command permissions could not be verified because shell type is unavailable" + .to_string(), + ); + }; + let escape_char = ShellFamily::from(shell_type).escape_char(); + + match permission_for_escape_char(escape_char) { + CommandExecutionPermission::Denied( + CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted, + ) => Some("command is explicitly denylisted by Warp permissions".to_string()), + _ => None, + } +} + +#[cfg(not(target_family = "wasm"))] +fn warp_permission_snapshot_for_policy( + is_user_initiated: bool, + can_auto_execute: bool, + needs_confirmation: bool, + autonomous_shell_command_denied: bool, + terminal_denial_reason: Option, +) -> WarpPermissionSnapshot { + if let Some(reason) = terminal_denial_reason { + return WarpPermissionSnapshot::deny(Some(reason)); + } + + if autonomous_shell_command_denied { + return WarpPermissionSnapshot::deny(Some( + "autonomous command execution was not allowed by Warp permissions".to_string(), + )); + } + + if needs_confirmation { + return WarpPermissionSnapshot::ask(Some( + "Warp requires user confirmation before this action can run".to_string(), + )); + } + + if is_user_initiated { + return WarpPermissionSnapshot::allow(Some("the user initiated this action".to_string())); + } + + if can_auto_execute { + return WarpPermissionSnapshot::allow(Some( + "Warp permissions allow this action to auto-execute".to_string(), + )); + } + + WarpPermissionSnapshot::ask(Some( + "Warp permissions did not allow auto-execution".to_string(), + )) +} + +#[cfg(not(target_family = "wasm"))] +fn agent_policy_action( + action: &AIAgentAction, + shell_type: Option, + shell: &Option, + current_working_directory: &Option, +) -> Option { + match &action.action { + AIAgentActionType::RequestCommandOutput { + command, + is_read_only, + is_risky, + .. + } => Some(AgentPolicyAction::ExecuteCommand( + PolicyExecuteCommandAction::new( + command.clone(), + normalize_command_for_policy(command, shell_type), + *is_read_only, + *is_risky, + ) + .redacted(), + )), + AIAgentActionType::WriteToLongRunningShellCommand { + block_id, + input, + mode, + } => Some(AgentPolicyAction::WriteToLongRunningShellCommand( + PolicyWriteToLongRunningShellCommandAction::new( + block_id.to_string(), + input.as_ref(), + policy_pty_write_mode(*mode), + ), + )), + AIAgentActionType::ReadFiles(read_files) => { + Some(AgentPolicyAction::ReadFiles(PolicyReadFilesAction::new( + read_files + .locations + .iter() + .map(|file| policy_path(&file.name, shell, current_working_directory)), + ))) + } + AIAgentActionType::RequestFileEdits { file_edits, .. } => { + let paths = file_edit_paths(file_edits) + .into_iter() + .map(|file| policy_path(file, shell, current_working_directory)); + Some(AgentPolicyAction::WriteFiles(PolicyWriteFilesAction::new( + paths, None, + ))) + } + AIAgentActionType::CallMCPTool { + server_id, + name, + input, + } => Some(AgentPolicyAction::CallMcpTool( + PolicyCallMcpToolAction::new(*server_id, name.clone(), input), + )), + AIAgentActionType::ReadMCPResource { + server_id, + name, + uri, + } => Some(AgentPolicyAction::ReadMcpResource( + PolicyReadMcpResourceAction::new(*server_id, name.clone(), uri.clone()), + )), + _ => None, + } +} + +#[cfg(not(target_family = "wasm"))] +fn file_edit_paths(file_edits: &[FileEdit]) -> Vec<&str> { + file_edits + .iter() + .flat_map(|edit| match edit { + FileEdit::Edit(ParsedDiff::V4AEdit { file, move_to, .. }) => { + [file.as_deref(), move_to.as_deref()] + .into_iter() + .flatten() + .collect::>() + } + _ => edit.file().into_iter().collect(), + }) + .collect() +} + +#[cfg(not(target_family = "wasm"))] +fn policy_pty_write_mode(mode: AIAgentPtyWriteMode) -> &'static str { + match mode { + AIAgentPtyWriteMode::Raw => "raw", + AIAgentPtyWriteMode::Line => "line", + AIAgentPtyWriteMode::Block => "block", + } +} + +#[cfg(not(target_family = "wasm"))] +fn policy_path( + path: &str, + shell: &Option, + current_working_directory: &Option, +) -> PathBuf { + PathBuf::from(host_native_absolute_path( + path, + shell, + current_working_directory, + )) +} + +#[cfg(not(target_family = "wasm"))] +fn raw_policy_action_key(action: &AIAgentAction) -> String { + match &action.action { + AIAgentActionType::RequestCommandOutput { + command, + is_read_only, + is_risky, + wait_until_completion, + uses_pager, + rationale, + citations, + } => format!( + "execute_command:{command:?}:{is_read_only:?}:{is_risky:?}:{wait_until_completion:?}:{uses_pager:?}:{rationale:?}:{citations:?}" + ), + AIAgentActionType::WriteToLongRunningShellCommand { + block_id, + input, + mode, + } => format!( + "write_to_shell:{block_id:?}:{:?}:{mode:?}", + input.as_ref() + ), + AIAgentActionType::ReadFiles(read_files) => { + format!("read_files:{:?}", read_files.locations) + } + AIAgentActionType::RequestFileEdits { file_edits, title } => { + format!("write_files:{file_edits:?}:{title:?}") + } + AIAgentActionType::CallMCPTool { + server_id, + name, + input, + } => format!( + "call_mcp_tool:{server_id:?}:{name:?}:{}", + serde_json::to_string(input).unwrap_or_else(|_| format!("{input:?}")) + ), + AIAgentActionType::ReadMCPResource { + server_id, + name, + uri, + } => format!("read_mcp_resource:{server_id:?}:{name:?}:{uri:?}"), + _ => format!("{:?}", action.action), + } +} + +#[cfg(not(target_family = "wasm"))] +fn normalize_command_for_policy(command: &str, shell_type: Option) -> String { + let Some(shell_type) = shell_type else { + return command.to_string(); + }; + + match ShellFamily::from(shell_type).escape_char() { + EscapeChar::Backslash => command.replace("\\\n", " "), + EscapeChar::Backtick => command.replace("`\n", " "), + } +} + +#[cfg(not(target_family = "wasm"))] +fn policy_denied_action_result( + action: &AIAgentAction, + decision: &AgentPolicyEffectiveDecision, +) -> AIAgentActionResultType { + if let Some(result) = warp_denied_action_result(action, decision) { + return result; + } + + let reason = redact_sensitive_text_for_policy(&policy_denied_message(decision)); + match &action.action { + AIAgentActionType::RequestCommandOutput { command, .. } => { + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { + command: redact_command_for_policy(command), + reason, + }, + ) + } + AIAgentActionType::ReadFiles(_) => AIAgentActionResultType::ReadFiles( + ReadFilesResult::Error(format!("Blocked by host policy: {reason}")), + ), + AIAgentActionType::RequestFileEdits { .. } => { + AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::PolicyDenied { + reason, + }) + } + AIAgentActionType::WriteToLongRunningShellCommand { .. } => { + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { reason }, + ) + } + AIAgentActionType::CallMCPTool { .. } => AIAgentActionResultType::CallMCPTool( + CallMCPToolResult::Error(format!("Blocked by host policy: {reason}")), + ), + AIAgentActionType::ReadMCPResource { .. } => AIAgentActionResultType::ReadMCPResource( + ReadMCPResourceResult::Error(format!("Blocked by host policy: {reason}")), + ), + _ => action.action.cancelled_result(), + } +} + +#[cfg(not(target_family = "wasm"))] +fn warp_denied_action_result( + action: &AIAgentAction, + decision: &AgentPolicyEffectiveDecision, +) -> Option { + let is_hook_denial = decision + .hook_results + .iter() + .any(|result| result.decision == AgentPolicyDecisionKind::Deny); + if decision.decision != AgentPolicyDecisionKind::Deny + || decision.warp_permission.decision != WarpPermissionDecisionKind::Deny + || is_hook_denial + { + return None; + } + + let reason = redact_sensitive_text_for_policy( + decision + .warp_permission + .reason + .as_deref() + .unwrap_or("Warp permissions denied the action"), + ); + let warp_denied_message = format!("Blocked by Warp permissions: {reason}"); + + Some(match &action.action { + AIAgentActionType::RequestCommandOutput { command, .. } => { + AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Denylisted { + command: redact_command_for_policy(command), + }) + } + AIAgentActionType::ReadFiles(_) => { + AIAgentActionResultType::ReadFiles(ReadFilesResult::Error(warp_denied_message)) + } + AIAgentActionType::RequestFileEdits { .. } => AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { + error: warp_denied_message, + }, + ), + AIAgentActionType::WriteToLongRunningShellCommand { .. } => { + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Cancelled, + ) + } + AIAgentActionType::CallMCPTool { .. } => { + AIAgentActionResultType::CallMCPTool(CallMCPToolResult::Error(warp_denied_message)) + } + AIAgentActionType::ReadMCPResource { .. } => AIAgentActionResultType::ReadMCPResource( + ReadMCPResourceResult::Error(warp_denied_message), + ), + _ => action.action.cancelled_result(), + }) +} + +#[cfg(not(target_family = "wasm"))] +fn recompose_completed_policy_decision( + decision: &AgentPolicyEffectiveDecision, + warp_permission: WarpPermissionSnapshot, + allow_hook_autoapproval: bool, +) -> AgentPolicyEffectiveDecision { + let allow_hook_autoapproval = + allow_hook_autoapproval && decision.warp_permission == warp_permission; + compose_policy_decisions( + warp_permission, + decision.hook_results.clone(), + allow_hook_autoapproval, + ) +} + +#[cfg(not(target_family = "wasm"))] +fn policy_preflight_state_from_decision( + action: &AIAgentAction, + decision: &AgentPolicyEffectiveDecision, + is_user_initiated: bool, +) -> PolicyPreflightState { + match decision.decision { + AgentPolicyDecisionKind::Allow => PolicyPreflightState::Allowed { + skip_confirmation: decision.warp_permission.decision == WarpPermissionDecisionKind::Ask, + }, + AgentPolicyDecisionKind::Ask if is_user_initiated => PolicyPreflightState::Allowed { + skip_confirmation: false, + }, + AgentPolicyDecisionKind::Ask => { + PolicyPreflightState::NeedsConfirmation(decision.reason.clone()) + } + AgentPolicyDecisionKind::Deny | AgentPolicyDecisionKind::Unknown => { + PolicyPreflightState::Denied(policy_denied_action_result(action, decision)) + } + } +} + +#[cfg(not(target_family = "wasm"))] +fn should_preprocess_file_edits_after_policy_decision( + action: &AIAgentAction, + decision: &AgentPolicyEffectiveDecision, +) -> bool { + matches!( + policy_preflight_state_from_decision(action, decision, false), + PolicyPreflightState::Allowed { .. } + ) +} + +#[cfg(not(target_family = "wasm"))] +fn confirmed_file_edit_policy_preprocess_state() -> PolicyPreflightState { + PolicyPreflightState::Allowed { + skip_confirmation: true, + } +} + +#[cfg(not(target_family = "wasm"))] +fn confirmed_file_edit_policy_preprocess_state_from_cached_decision( + action: &AIAgentAction, + cached_decision: &AgentPolicyEffectiveDecision, + warp_permission: WarpPermissionSnapshot, + allow_hook_autoapproval: bool, +) -> PolicyPreflightState { + let warp_permission_unchanged = cached_decision.warp_permission == warp_permission; + let decision = recompose_completed_policy_decision( + cached_decision, + warp_permission, + allow_hook_autoapproval, + ); + if warp_permission_unchanged + && cached_decision.decision == AgentPolicyDecisionKind::Ask + && decision.decision == AgentPolicyDecisionKind::Ask + { + return confirmed_file_edit_policy_preprocess_state(); + } + + policy_preflight_state_from_decision(action, &decision, false) +} + +#[cfg(not(target_family = "wasm"))] +fn should_preserve_completed_policy_preflight_for_file_edit_preprocess( + action: &AIAgentAction, + state: &PolicyPreflightState, + already_preprocessed: bool, +) -> bool { + matches!(&action.action, AIAgentActionType::RequestFileEdits { .. }) + && matches!(state, PolicyPreflightState::Allowed { .. }) + && !already_preprocessed +} + +#[cfg(not(target_family = "wasm"))] +fn should_consume_completed_policy_preflight(state: &PolicyPreflightState) -> bool { + !matches!(state, PolicyPreflightState::NeedsConfirmation(_)) +} + +#[cfg(not(target_family = "wasm"))] +fn complete_policy_preflight_if_pending( + pending_policy_preflights: &mut HashSet, + completed_policy_preflights: &mut HashMap, + preflight_key: PolicyPreflightKey, + decision: AgentPolicyEffectiveDecision, +) -> bool { + if !pending_policy_preflights.remove(&preflight_key) { + return false; + } + completed_policy_preflights.insert(preflight_key, decision); + true +} + +#[cfg(not(target_family = "wasm"))] +fn policy_denied_message(decision: &AgentPolicyEffectiveDecision) -> String { + if let Some(denial) = decision + .hook_results + .iter() + .find(|result| result.decision == AgentPolicyDecisionKind::Deny) + { + return match denial.reason.as_deref() { + Some(reason) => format!("{} denied the action: {reason}", denial.hook_name), + None => format!("{} denied the action", denial.hook_name), + }; + } + + decision + .reason + .clone() + .unwrap_or_else(|| "host policy denied the action".to_string()) +} + impl Entity for BlocklistAIActionExecutor { type Event = BlocklistAIActionExecutorEvent; } @@ -927,6 +2045,11 @@ pub enum BlocklistAIActionExecutorEvent { cancellation_reason: Option, }, + /// Emitted when an out-of-process policy preflight has completed and pending actions can retry. + PolicyPreflightFinished { + conversation_id: AIConversationId, + }, + InitProject(AIAgentActionId), OpenCodeReview(AIAgentActionId), InsertCodeReviewComments { diff --git a/app/src/ai/blocklist/action_model/execute/request_file_edits.rs b/app/src/ai/blocklist/action_model/execute/request_file_edits.rs index 9798496c9..2506201df 100644 --- a/app/src/ai/blocklist/action_model/execute/request_file_edits.rs +++ b/app/src/ai/blocklist/action_model/execute/request_file_edits.rs @@ -7,7 +7,7 @@ use warp_util::file::FileSaveError; use std::collections::HashMap; use std::path::PathBuf; -use ai::diff_validation::AIRequestedCodeDiff; +use ai::diff_validation::{AIRequestedCodeDiff, ParsedDiff}; use futures::{channel::oneshot, future::BoxFuture, FutureExt}; use itertools::Itertools; use vec1::{vec1, Vec1}; @@ -31,7 +31,8 @@ use crate::{ agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentOutputMessage, - AIAgentOutputMessageType, AIIdentifiers, RequestFileEditsResult, UpdatedFileContext, + AIAgentOutputMessageType, AIIdentifiers, FileEdit, RequestFileEditsResult, + UpdatedFileContext, }, blocklist::{ inline_action::code_diff_view::{ @@ -42,7 +43,10 @@ use crate::{ paths::host_native_absolute_path, }, safe_warn, - terminal::model::session::{active_session::ActiveSession, SessionType}, + terminal::{ + model::session::{active_session::ActiveSession, SessionType}, + ShellLaunchData, + }, BlocklistAIHistoryModel, }; @@ -52,11 +56,162 @@ pub struct RequestFileEditsExecutor { active_session: ModelHandle, apply_diff_model: ModelHandle, diff_views: HashMap>, - /// Set of action IDs where diff application failed. - diff_application_failures: HashMap>, + /// Failed diff applications scoped to the exact action payload that was preprocessed. + diff_application_failures: HashMap, + preprocessed_actions: HashMap, terminal_view_id: EntityId, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileEditPreprocessFingerprint { + conversation_id: AIConversationId, + action_payload: String, + session_context: String, +} + +#[derive(Debug, Clone)] +struct FileEditPreprocessContext { + session_type: Option, + shell: Option, + current_working_directory: Option, +} + +impl FileEditPreprocessContext { + fn fingerprint_key(&self) -> String { + format!( + "session_type={:?};shell={:?};cwd={:?}", + self.session_type, self.shell, self.current_working_directory + ) + } +} + +struct FileEditPreprocessFailure { + fingerprint: FileEditPreprocessFingerprint, + errors: Vec1, +} + +fn file_edit_preprocess_failure_matches( + failure: &FileEditPreprocessFailure, + fingerprint: &FileEditPreprocessFingerprint, +) -> bool { + &failure.fingerprint == fingerprint +} + +fn file_edit_preprocess_fingerprint( + conversation_id: AIConversationId, + action: &AIAgentAction, + session_context: &FileEditPreprocessContext, +) -> FileEditPreprocessFingerprint { + FileEditPreprocessFingerprint { + conversation_id, + action_payload: format!("{:?}", action.action), + session_context: session_context.fingerprint_key(), + } +} + +#[cfg(test)] +fn file_edit_preprocess_context_for_test( + current_working_directory: Option<&str>, +) -> FileEditPreprocessContext { + FileEditPreprocessContext { + session_type: None, + shell: None, + current_working_directory: current_working_directory.map(str::to_string), + } +} + +fn file_edit_paths_for_permissions(file_edits: &[FileEdit]) -> Vec<&str> { + file_edits + .iter() + .flat_map(|edit| match edit { + FileEdit::Edit(ParsedDiff::V4AEdit { file, move_to, .. }) => { + [file.as_deref(), move_to.as_deref()] + .into_iter() + .flatten() + .collect::>() + } + _ => edit.file().into_iter().collect(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::agent::task::TaskId; + + fn file_edit_action(action_id: &str, file: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(action_id.to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestFileEdits { + file_edits: vec![crate::ai::agent::FileEdit::Create { + file: Some(file.to_string()), + content: Some("content\n".to_string()), + }], + title: None, + }, + requires_result: true, + } + } + + #[test] + fn file_edit_preprocess_fingerprint_scopes_conversation_and_payload() { + let conversation_one = AIConversationId::new(); + let conversation_two = AIConversationId::new(); + let old_action = file_edit_action("action_1", "src/old.rs"); + let new_action = file_edit_action("action_1", "src/new.rs"); + let context = file_edit_preprocess_context_for_test(Some("/repo")); + + assert_ne!( + file_edit_preprocess_fingerprint(conversation_one, &old_action, &context), + file_edit_preprocess_fingerprint(conversation_two, &old_action, &context) + ); + assert_ne!( + file_edit_preprocess_fingerprint(conversation_one, &old_action, &context), + file_edit_preprocess_fingerprint(conversation_one, &new_action, &context) + ); + } + + #[test] + fn file_edit_preprocess_fingerprint_scopes_working_directory() { + let conversation_id = AIConversationId::new(); + let action = file_edit_action("action_1", "src/config.rs"); + let old_context = file_edit_preprocess_context_for_test(Some("/repo-a")); + let new_context = file_edit_preprocess_context_for_test(Some("/repo-b")); + + assert_ne!( + file_edit_preprocess_fingerprint(conversation_id, &action, &old_context), + file_edit_preprocess_fingerprint(conversation_id, &action, &new_context) + ); + } + + #[test] + fn file_edit_preprocess_failure_is_fingerprinted() { + let conversation_id = AIConversationId::new(); + let old_action = file_edit_action("action_1", "src/old.rs"); + let new_action = file_edit_action("action_1", "src/new.rs"); + let context = file_edit_preprocess_context_for_test(Some("/repo")); + let old_fingerprint = + file_edit_preprocess_fingerprint(conversation_id, &old_action, &context); + let new_fingerprint = + file_edit_preprocess_fingerprint(conversation_id, &new_action, &context); + let failure = FileEditPreprocessFailure { + fingerprint: old_fingerprint.clone(), + errors: vec1![DiffApplicationError::EmptyDiff], + }; + + assert!(file_edit_preprocess_failure_matches( + &failure, + &old_fingerprint + )); + assert!(!file_edit_preprocess_failure_matches( + &failure, + &new_fingerprint + )); + } +} + impl RequestFileEditsExecutor { pub fn new( active_session: ModelHandle, @@ -69,6 +224,7 @@ impl RequestFileEditsExecutor { apply_diff_model, diff_views: HashMap::new(), diff_application_failures: HashMap::new(), + preprocessed_actions: HashMap::new(), terminal_view_id, } } @@ -79,21 +235,17 @@ impl RequestFileEditsExecutor { ctx: &mut ModelContext, ) -> bool { let ExecuteActionInput { - action: - AIAgentAction { - action: AIAgentActionType::RequestFileEdits { file_edits, .. }, - .. - }, + action, conversation_id, - } = input - else { + } = input; + let AIAgentActionType::RequestFileEdits { file_edits, .. } = &action.action else { return false; }; - let paths: Vec = file_edits - .iter() - .filter_map(|edit| edit.file().map(PathBuf::from)) - .collect(); + let paths = file_edit_paths_for_permissions(file_edits) + .into_iter() + .map(PathBuf::from) + .collect::>(); // Don't allow autoexecution if the diff was generated passively. let Some(latest_exchange) = BlocklistAIHistoryModel::as_ref(ctx) @@ -111,9 +263,13 @@ impl RequestFileEditsExecutor { // from the LLM. // If we don't do this, a failed diff application will block execution of the entire AI conversation // without any possibility of recovery. + let session_context = self.file_edit_preprocess_context(ctx); + let fingerprint = + file_edit_preprocess_fingerprint(conversation_id, action, &session_context); if self .diff_application_failures - .contains_key(&input.action.id) + .get(&action.id) + .is_some_and(|failure| file_edit_preprocess_failure_matches(failure, &fingerprint)) { return true; } @@ -134,20 +290,41 @@ impl RequestFileEditsExecutor { self.diff_views.insert(action_id.clone(), view.clone()); } + pub(super) fn has_preprocessed_action( + &self, + conversation_id: AIConversationId, + action: &AIAgentAction, + ctx: &mut ModelContext, + ) -> bool { + let session_context = self.file_edit_preprocess_context(ctx); + let fingerprint = + file_edit_preprocess_fingerprint(conversation_id, action, &session_context); + self.preprocessed_actions + .get(&action.id) + .is_some_and(|stored| stored == &fingerprint) + && (self.diff_views.contains_key(&action.id) + || self + .diff_application_failures + .get(&action.id) + .is_some_and(|failure| { + file_edit_preprocess_failure_matches(failure, &fingerprint) + })) + } + pub(super) fn execute( &mut self, input: ExecuteActionInput, ctx: &mut ModelContext, ) -> impl Into { let ExecuteActionInput { - action: - AIAgentAction { - id, - action: AIAgentActionType::RequestFileEdits { .. }, - .. - }, + action, + conversation_id, + } = input; + let AIAgentAction { + id, + action: AIAgentActionType::RequestFileEdits { .. }, .. - } = input + } = action else { return ActionExecution::InvalidAction; }; @@ -158,7 +335,19 @@ impl RequestFileEditsExecutor { }; // If diff application failed, early exit. - if let Some(errors) = self.diff_application_failures.remove(id) { + let session_context = self.file_edit_preprocess_context(ctx); + let fingerprint = + file_edit_preprocess_fingerprint(conversation_id, action, &session_context); + let matching_failure = self + .diff_application_failures + .get(id) + .is_some_and(|failure| file_edit_preprocess_failure_matches(failure, &fingerprint)); + if matching_failure { + let errors = self + .diff_application_failures + .remove(id) + .expect("matching diff failure should exist") + .errors; return ActionExecution::Sync(AIAgentActionResultType::RequestFileEdits( RequestFileEditsResult::DiffApplicationFailed { error: DiffApplicationError::error_for_conversation(&errors), @@ -167,9 +356,9 @@ impl RequestFileEditsExecutor { } let identifiers = self - .generate_ai_identifiers(&input.conversation_id, id, ctx) + .generate_ai_identifiers(&conversation_id, id, ctx) .unwrap_or_else(|| AIIdentifiers { - client_conversation_id: Some(input.conversation_id), + client_conversation_id: Some(conversation_id), ..Default::default() }); @@ -212,7 +401,7 @@ impl RequestFileEditsExecutor { } let passive_diff = BlocklistAIHistoryModel::as_ref(ctx) - .is_entirely_passive_conversation(&input.conversation_id); + .is_entirely_passive_conversation(&conversation_id); send_telemetry_from_ctx!( RequestFileEditsTelemetryEvent::EditResolved(EditResolvedEvent { identifiers: identifiers.clone(), @@ -316,6 +505,9 @@ impl RequestFileEditsExecutor { let (tx, rx) = oneshot::channel(); let files = file_edits.clone(); let id = id.clone(); + let session_context = self.file_edit_preprocess_context(ctx); + let fingerprint = + file_edit_preprocess_fingerprint(input.conversation_id, input.action, &session_context); let apply_future = self.apply_diff_model.update(ctx, |model, ctx| { model.apply_diffs(files, &ai_identifiers, passive_diff, ctx) @@ -324,10 +516,10 @@ impl RequestFileEditsExecutor { ctx.spawn( async move { let applied_diffs = apply_future.await; - (applied_diffs, id, tx) + (applied_diffs, id, fingerprint, session_context, tx) }, - |me, (diffs, id, tx), ctx| { - me.on_diffs_applied(diffs, id, tx, ctx); + |me, (diffs, id, fingerprint, session_context, tx), ctx| { + me.on_diffs_applied(diffs, id, fingerprint, session_context, tx, ctx); }, ); @@ -341,10 +533,14 @@ impl RequestFileEditsExecutor { &mut self, applied_diffs: Result, Vec1>, id: AIAgentActionId, + fingerprint: FileEditPreprocessFingerprint, + session_context: FileEditPreprocessContext, tx: oneshot::Sender<()>, ctx: &mut ModelContext, ) { tx.send(()).ok(); + self.preprocessed_actions + .insert(id.clone(), fingerprint.clone()); let Some(diff_view) = self.diff_views.get(&id) else { log::warn!( @@ -358,8 +554,13 @@ impl RequestFileEditsExecutor { Ok(_) => { // We didn't generate any diffs--consider this a failure. log::warn!("No diffs generated"); - self.diff_application_failures - .insert(id, vec1![DiffApplicationError::EmptyDiff]); + self.diff_application_failures.insert( + id, + FileEditPreprocessFailure { + fingerprint, + errors: vec1![DiffApplicationError::EmptyDiff], + }, + ); return; } Err(err) => { @@ -367,25 +568,24 @@ impl RequestFileEditsExecutor { safe: ("Failed to generate diffs"), full: ("Failed to generate diffs {err:?}") ); - self.diff_application_failures.insert(id, err); + self.diff_application_failures.insert( + id, + FileEditPreprocessFailure { + fingerprint, + errors: err, + }, + ); return; } }; - - let current_working_directory = self - .active_session - .as_ref(ctx) - .current_working_directory() - .cloned(); - - let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx); + self.diff_application_failures.remove(&id); let mut diffs = Vec::with_capacity(applied_diffs.len()); for diff in applied_diffs { let path = host_native_absolute_path( diff.file_name.as_str(), - &shell_launch_data, - ¤t_working_directory, + &session_context.shell, + &session_context.current_working_directory, ); let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type); diffs.push(file_diff); @@ -393,7 +593,7 @@ impl RequestFileEditsExecutor { // Set the session type on the diff view so save/delete/create routes // through the correct FileModel backend. - let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) { + let diff_session_type = match &session_context.session_type { Some(SessionType::WarpifiedRemote { host_id: Some(host_id), }) => DiffSessionType::Remote(host_id.clone()), @@ -406,6 +606,18 @@ impl RequestFileEditsExecutor { }); } + fn file_edit_preprocess_context( + &self, + ctx: &mut ModelContext, + ) -> FileEditPreprocessContext { + let active_session = self.active_session.as_ref(ctx); + FileEditPreprocessContext { + session_type: active_session.session_type(ctx), + shell: active_session.shell_launch_data(ctx), + current_working_directory: active_session.current_working_directory().cloned(), + } + } + fn generate_ai_identifiers( &self, conversation_id: &AIConversationId, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index ddc6e5c04..d55aa4ed9 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -97,3 +97,1049 @@ mod binary_detection { assert!(block_on(is_file_content_binary_async(&missing))); } } + +#[cfg(not(target_family = "wasm"))] +mod policy_hooks { + use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + }; + + use ai::diff_validation::{ParsedDiff, V4AHunk}; + + use crate::{ + ai::{ + agent::task::TaskId, + agent::{ + conversation::AIConversationId, AIAgentAction, AIAgentActionId, + AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode, FileEdit, + RequestCommandOutputResult, RequestFileEditsResult, + WriteToLongRunningShellCommandResult, + }, + blocklist::permissions::{ + CommandExecutionPermission, CommandExecutionPermissionDeniedReason, + }, + policy_hooks::{ + decision::{ + compose_policy_decisions, AgentPolicyHookEvaluation, + WarpPermissionDecisionKind, WarpPermissionSnapshot, + }, + AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + AgentPolicyEvent, AgentPolicyHookConfig, + }, + }, + terminal::shell::ShellType, + }; + + use super::super::{ + agent_policy_action, complete_policy_preflight_if_pending, + confirmed_file_edit_policy_preprocess_state_from_cached_decision, file_edit_paths, + normalize_command_for_policy, policy_denied_action_result, + policy_preflight_state_from_decision, recompose_completed_policy_decision, + should_consume_completed_policy_preflight, + should_preprocess_file_edits_after_policy_decision, + should_preserve_completed_policy_preflight_for_file_edit_preprocess, + terminal_command_denial_reason_for_policy, warp_permission_snapshot_for_policy, + PolicyPreflightKey, PolicyPreflightState, + }; + + fn command_action(command: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestCommandOutput { + command: command.to_string(), + is_read_only: Some(false), + is_risky: Some(true), + wait_until_completion: false, + uses_pager: None, + rationale: None, + citations: Vec::new(), + }, + requires_result: true, + } + } + + fn policy_preflight_key( + conversation_id: AIConversationId, + action_id: AIAgentActionId, + action: AIAgentAction, + ) -> PolicyPreflightKey { + let policy_action = agent_policy_action(&action, None, &None, &None) + .expect("action should build a policy action"); + let event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + None, + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action, + ); + PolicyPreflightKey::new( + conversation_id, + action_id, + &action, + &event, + &AgentPolicyHookConfig::default(), + ) + } + + fn write_to_shell_action(input: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::WriteToLongRunningShellCommand { + block_id: "block_1".to_string().into(), + input: bytes::Bytes::from(input.to_string()), + mode: AIAgentPtyWriteMode::Line, + }, + requires_result: true, + } + } + + fn file_edit_action() -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestFileEdits { + file_edits: vec![FileEdit::Create { + file: Some("src/lib.rs".to_string()), + content: Some("fn main() {}\n".to_string()), + }], + title: None, + }, + requires_result: true, + } + } + + fn v4a_move_file_edit_action(source: &str, target: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestFileEdits { + file_edits: vec![FileEdit::Edit(ParsedDiff::V4AEdit { + file: Some(source.to_string()), + move_to: Some(target.to_string()), + hunks: vec![V4AHunk { + change_context: Vec::new(), + pre_context: String::new(), + old: String::new(), + new: "content\n".to_string(), + post_context: String::new(), + }], + })], + title: None, + }, + requires_result: true, + } + } + + fn mcp_tool_action(input: serde_json::Value) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::CallMCPTool { + server_id: None, + name: "dangerous_tool".to_string(), + input, + }, + requires_result: true, + } + } + + #[test] + fn policy_denied_result_preserves_command_and_policy_reason() { + let action = command_action("OPENAI_API_KEY=sk-secretsecretsecret rm -rf target"); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Deny, + reason: Some("blocked".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some("dangerous command".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + let result = policy_denied_action_result(&action, &decision); + + assert_eq!( + result, + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { + command: "OPENAI_API_KEY= rm -rf target".to_string(), + reason: "guard denied the action: dangerous command".to_string(), + } + ) + ); + } + + #[test] + fn policy_denied_file_edit_result_uses_stable_policy_variant() { + let action = AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestFileEdits { + file_edits: vec![FileEdit::Create { + file: Some("src/lib.rs".to_string()), + content: Some("fn main() {}\n".to_string()), + }], + title: None, + }, + requires_result: true, + }; + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Deny, + reason: Some("blocked".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some("protected path".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + let result = policy_denied_action_result(&action, &decision); + + assert_eq!( + result, + AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::PolicyDenied { + reason: "guard denied the action: protected path".to_string(), + }) + ); + } + + #[test] + fn policy_denied_write_to_shell_result_uses_stable_policy_variant() { + let action = write_to_shell_action("q\n"); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Deny, + reason: Some("blocked".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some("interactive write blocked".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + let result = policy_denied_action_result(&action, &decision); + + assert_eq!( + result, + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: "guard denied the action: interactive write blocked".to_string(), + } + ) + ); + } + + #[test] + fn warp_denied_command_result_preserves_denylisted_variant() { + let action = command_action("OPENAI_API_KEY=sk-secretsecretsecret rm -rf target"); + let decision = compose_policy_decisions( + WarpPermissionSnapshot::deny(Some( + "command is explicitly denylisted by Warp permissions".to_string(), + )), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let result = policy_denied_action_result(&action, &decision); + + assert_eq!( + result, + AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Denylisted { + command: "OPENAI_API_KEY= rm -rf target".to_string(), + }) + ); + } + + #[test] + fn warp_denied_file_edit_result_does_not_use_host_policy_variant() { + let action = file_edit_action(); + let decision = compose_policy_decisions( + WarpPermissionSnapshot::deny(Some( + "file path is protected by Warp permissions".to_string(), + )), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let result = policy_denied_action_result(&action, &decision); + + assert_eq!( + result, + AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { + error: + "Blocked by Warp permissions: file path is protected by Warp permissions" + .to_string(), + } + ) + ); + } + + #[test] + fn warp_permission_snapshot_marks_autonomous_denials_terminal() { + let snapshot = warp_permission_snapshot_for_policy(false, false, false, true, None); + + assert_eq!(snapshot.decision, WarpPermissionDecisionKind::Deny); + } + + #[test] + fn warp_permission_snapshot_preserves_terminal_denial_before_hook_autoapproval() { + let snapshot = warp_permission_snapshot_for_policy( + false, + false, + true, + false, + Some("file path is protected by Warp permissions".to_string()), + ); + + assert_eq!(snapshot.decision, WarpPermissionDecisionKind::Deny); + + let decision = compose_policy_decisions( + snapshot, + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: None, + error: None, + }], + true, + ); + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.reason.as_deref(), + Some("file path is protected by Warp permissions") + ); + } + + #[test] + fn unknown_shell_type_is_terminal_for_policy_autoapproval() { + let reason = terminal_command_denial_reason_for_policy(None, |_| { + panic!("permission check should not run without shell type") + }) + .expect("missing shell type should produce a terminal policy denial"); + let snapshot = warp_permission_snapshot_for_policy(false, false, true, false, Some(reason)); + + let decision = compose_policy_decisions( + snapshot, + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: None, + error: None, + }], + true, + ); + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.reason.as_deref(), + Some("command permissions could not be verified because shell type is unavailable") + ); + } + + #[test] + fn terminal_command_denial_reason_preserves_explicit_denylist() { + let reason = terminal_command_denial_reason_for_policy(Some(ShellType::Bash), |_| { + CommandExecutionPermission::Denied( + CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted, + ) + }); + + assert_eq!( + reason.as_deref(), + Some("command is explicitly denylisted by Warp permissions") + ); + } + + #[test] + fn cached_ask_policy_decision_is_retained_until_user_confirmation() { + let action = command_action("rm -rf target"); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + let unconfirmed = policy_preflight_state_from_decision(&action, &decision, false); + assert!(matches!( + unconfirmed, + PolicyPreflightState::NeedsConfirmation(_) + )); + assert!(!should_consume_completed_policy_preflight(&unconfirmed)); + + let confirmed = policy_preflight_state_from_decision(&action, &decision, true); + assert_eq!( + confirmed, + PolicyPreflightState::Allowed { + skip_confirmation: false + } + ); + assert!(should_consume_completed_policy_preflight(&confirmed)); + } + + #[test] + fn hook_autoapproval_skips_warp_confirmation() { + let action = command_action("rm -rf target"); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + warp_permission: WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + let state = policy_preflight_state_from_decision(&action, &decision, false); + + assert_eq!( + state, + PolicyPreflightState::Allowed { + skip_confirmation: true + } + ); + } + + #[test] + fn file_edit_policy_ask_defers_diff_preprocessing_until_confirmation() { + let action = file_edit_action(); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + assert!(!should_preprocess_file_edits_after_policy_decision( + &action, &decision + )); + } + + #[test] + fn confirmed_file_edit_policy_preprocess_retry_skips_confirmation() { + let action = file_edit_action(); + let cached_decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Ask, + reason: Some("requires approval".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + }; + + assert_eq!( + confirmed_file_edit_policy_preprocess_state_from_cached_decision( + &action, + &cached_decision, + WarpPermissionSnapshot::allow(None), + true + ), + PolicyPreflightState::Allowed { + skip_confirmation: true + } + ); + } + + #[test] + fn confirmed_file_edit_policy_preprocess_retry_recomposes_changed_warp_denial() { + let action = file_edit_action(); + let cached_decision = compose_policy_decisions( + WarpPermissionSnapshot::allow(Some("initial allow".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let state = confirmed_file_edit_policy_preprocess_state_from_cached_decision( + &action, + &cached_decision, + WarpPermissionSnapshot::deny(Some("managed policy changed".to_string())), + true, + ); + + assert_eq!( + state, + PolicyPreflightState::Denied(AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { + error: "Blocked by Warp permissions: managed policy changed".to_string() + } + )) + ); + } + + #[test] + fn confirmed_file_edit_policy_preprocess_retry_reprompts_changed_warp_ask() { + let action = file_edit_action(); + let cached_decision = compose_policy_decisions( + WarpPermissionSnapshot::allow(Some("initial allow".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let state = confirmed_file_edit_policy_preprocess_state_from_cached_decision( + &action, + &cached_decision, + WarpPermissionSnapshot::ask(Some("permission changed".to_string())), + true, + ); + + assert_eq!( + state, + PolicyPreflightState::NeedsConfirmation(Some("permission changed".to_string())) + ); + } + + #[test] + fn completed_file_edit_policy_preflight_is_preserved_until_preprocessed() { + let action = file_edit_action(); + let state = PolicyPreflightState::Allowed { + skip_confirmation: false, + }; + + assert!( + should_preserve_completed_policy_preflight_for_file_edit_preprocess( + &action, &state, false + ) + ); + assert!( + !should_preserve_completed_policy_preflight_for_file_edit_preprocess( + &action, &state, true + ) + ); + assert!( + !should_preserve_completed_policy_preflight_for_file_edit_preprocess( + &action, + &PolicyPreflightState::NeedsConfirmation(Some("requires approval".to_string())), + false + ) + ); + } + + #[test] + fn cached_policy_decision_recomposes_against_current_warp_denial() { + let cached_decision = compose_policy_decisions( + WarpPermissionSnapshot::allow(Some("initial allow".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let recomposed = recompose_completed_policy_decision( + &cached_decision, + WarpPermissionSnapshot::deny(Some("managed policy changed".to_string())), + true, + ); + + assert_eq!(recomposed.decision, AgentPolicyDecisionKind::Deny); + assert_eq!(recomposed.reason.as_deref(), Some("managed policy changed")); + assert_eq!( + recomposed.warp_permission.decision, + WarpPermissionDecisionKind::Deny + ); + assert_eq!(recomposed.hook_results, cached_decision.hook_results); + } + + #[test] + fn cached_policy_decision_does_not_autoapprove_changed_warp_ask() { + let cached_decision = compose_policy_decisions( + WarpPermissionSnapshot::allow(Some("initial allow".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let recomposed = recompose_completed_policy_decision( + &cached_decision, + WarpPermissionSnapshot::ask(Some("permission changed".to_string())), + true, + ); + + assert_eq!(recomposed.decision, AgentPolicyDecisionKind::Ask); + assert_eq!(recomposed.reason.as_deref(), Some("permission changed")); + assert_eq!( + recomposed.warp_permission.decision, + WarpPermissionDecisionKind::Ask + ); + } + + #[test] + fn cached_policy_decision_does_not_autoapprove_when_config_disables_hook_autoapproval() { + let cached_decision = compose_policy_decisions( + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("approved by hook".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + true, + ); + + let recomposed = recompose_completed_policy_decision( + &cached_decision, + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + false, + ); + + assert_eq!(recomposed.decision, AgentPolicyDecisionKind::Ask); + assert_eq!(recomposed.reason.as_deref(), Some("AlwaysAsk")); + assert_eq!( + recomposed.warp_permission.decision, + WarpPermissionDecisionKind::Ask + ); + } + + #[test] + fn file_edit_policy_paths_include_v4a_move_to_target() { + let action = v4a_move_file_edit_action("src/old.rs", "src/new.rs"); + let AIAgentActionType::RequestFileEdits { file_edits, .. } = &action.action else { + panic!("expected file edit action"); + }; + + assert_eq!( + file_edit_paths(file_edits), + vec!["src/old.rs", "src/new.rs"] + ); + + let policy_action = agent_policy_action(&action, None, &None, &None).unwrap(); + let AgentPolicyAction::WriteFiles(write_files) = policy_action else { + panic!("expected write-files policy action"); + }; + assert_eq!( + write_files.paths, + vec![PathBuf::from("src/old.rs"), PathBuf::from("src/new.rs")] + ); + } + + #[test] + fn policy_preflight_key_scopes_same_action_id_by_conversation() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let action = command_action("ls"); + let conversation_one = AIConversationId::new(); + let conversation_two = AIConversationId::new(); + let key_one = policy_preflight_key(conversation_one, action_id.clone(), action.clone()); + let key_two = policy_preflight_key(conversation_two, action_id, action); + + assert_ne!(key_one, key_two); + + let mut pending = HashSet::new(); + pending.insert(key_one); + assert!(!pending.contains(&key_two)); + } + + #[test] + fn policy_preflight_key_scopes_same_action_id_by_action_payload() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let conversation_id = AIConversationId::new(); + let old_action = command_action("echo old"); + let new_action = command_action("echo new"); + + let old_key = policy_preflight_key(conversation_id, action_id.clone(), old_action); + let new_key = policy_preflight_key(conversation_id, action_id.clone(), new_action); + + assert_ne!(old_key, new_key); + assert!(old_key.matches_action(conversation_id, &action_id)); + } + + #[test] + fn policy_preflight_key_uses_raw_command_when_redaction_collides() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let conversation_id = AIConversationId::new(); + let old_action = command_action("echo sk-aaaaaaaaaaaa"); + let new_action = command_action("echo sk-bbbbbbbbbbbb"); + + let old_policy_action = agent_policy_action(&old_action, None, &None, &None).unwrap(); + let new_policy_action = agent_policy_action(&new_action, None, &None, &None).unwrap(); + assert_eq!(old_policy_action, new_policy_action); + + let old_key = policy_preflight_key(conversation_id, action_id.clone(), old_action); + let new_key = policy_preflight_key(conversation_id, action_id, new_action); + + assert_ne!(old_key, new_key); + } + + #[test] + fn policy_preflight_key_uses_raw_working_directory_when_redaction_collides() { + let conversation_id = AIConversationId::new(); + let action_id = AIAgentActionId::from("action_1".to_string()); + let action = command_action("ls"); + let policy_action = agent_policy_action(&action, None, &None, &None).unwrap(); + let config = AgentPolicyHookConfig::default(); + let old_event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/tmp/sk-aaaaaaaaaaaa")), + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action.clone(), + ); + let new_event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/tmp/sk-bbbbbbbbbbbb")), + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action, + ); + + assert_ne!( + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &old_event, + &config + ), + PolicyPreflightKey::new(conversation_id, action_id, &action, &new_event, &config) + ); + } + + #[test] + fn policy_preflight_key_uses_raw_mcp_input_when_argument_keys_are_capped() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let conversation_id = AIConversationId::new(); + let mut old_arguments = serde_json::Map::new(); + let mut new_arguments = serde_json::Map::new(); + for index in 0..258 { + old_arguments.insert(format!("key_{index:03}"), serde_json::json!(index)); + } + for index in 0..256 { + new_arguments.insert(format!("key_{index:03}"), serde_json::json!(index)); + } + new_arguments.insert("key_900".to_string(), serde_json::json!(900)); + new_arguments.insert("key_901".to_string(), serde_json::json!(901)); + + let old_action = mcp_tool_action(serde_json::Value::Object(old_arguments)); + let new_action = mcp_tool_action(serde_json::Value::Object(new_arguments)); + let old_policy_action = agent_policy_action(&old_action, None, &None, &None).unwrap(); + let new_policy_action = agent_policy_action(&new_action, None, &None, &None).unwrap(); + assert_eq!(old_policy_action, new_policy_action); + + let old_key = policy_preflight_key(conversation_id, action_id.clone(), old_action); + let new_key = policy_preflight_key(conversation_id, action_id, new_action); + + assert_ne!(old_key, new_key); + } + + #[test] + fn policy_preflight_key_scopes_policy_event_context() { + let conversation_id = AIConversationId::new(); + let action_id = AIAgentActionId::from("action_1".to_string()); + let action = command_action("ls"); + let policy_action = agent_policy_action(&action, None, &None, &None).unwrap(); + let config = AgentPolicyHookConfig::default(); + let base_event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/repo")), + false, + Some("profile_a".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action.clone(), + ); + let changed_cwd = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/other")), + false, + Some("profile_a".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action.clone(), + ); + let changed_run_mode = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/repo")), + true, + Some("profile_a".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action.clone(), + ); + let changed_profile = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/repo")), + false, + Some("profile_b".to_string()), + WarpPermissionSnapshot::allow(None), + policy_action.clone(), + ); + let changed_policy_action = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/repo")), + false, + Some("profile_a".to_string()), + WarpPermissionSnapshot::allow(None), + agent_policy_action( + &command_action("echo one`\n+two"), + Some(ShellType::PowerShell), + &None, + &None, + ) + .unwrap(), + ); + let changed_warp_permission = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + Some(PathBuf::from("/repo")), + false, + Some("profile_a".to_string()), + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + policy_action, + ); + + let base_key = PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &base_event, + &config, + ); + + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &changed_cwd, + &config + ) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &changed_run_mode, + &config + ) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &changed_profile, + &config + ) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &changed_policy_action, + &config + ) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id, + &action, + &changed_warp_permission, + &config + ) + ); + } + + #[test] + fn policy_preflight_key_scopes_hook_config() { + let conversation_id = AIConversationId::new(); + let action_id = AIAgentActionId::from("action_1".to_string()); + let action = command_action("ls"); + let event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + None, + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + agent_policy_action(&action, None, &None, &None).unwrap(), + ); + let old_config = AgentPolicyHookConfig { + enabled: true, + timeout_ms: 5_000, + ..Default::default() + }; + let new_config = AgentPolicyHookConfig { + enabled: true, + timeout_ms: 10_000, + ..Default::default() + }; + + assert_ne!( + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &action, + &event, + &old_config + ), + PolicyPreflightKey::new(conversation_id, action_id, &action, &event, &new_config) + ); + } + + #[test] + fn cancelled_policy_preflight_completion_is_not_cached() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let preflight_key = + policy_preflight_key(AIConversationId::new(), action_id, command_action("ls")); + let decision = AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Allow, + reason: None, + warp_permission: WarpPermissionSnapshot::allow(None), + hook_results: Vec::new(), + }; + let mut pending = HashSet::new(); + let mut completed = HashMap::new(); + + assert!(!complete_policy_preflight_if_pending( + &mut pending, + &mut completed, + preflight_key.clone(), + decision.clone() + )); + assert!(!completed.contains_key(&preflight_key)); + + pending.insert(preflight_key.clone()); + assert!(complete_policy_preflight_if_pending( + &mut pending, + &mut completed, + preflight_key.clone(), + decision + )); + assert!(pending.is_empty()); + assert!(completed.contains_key(&preflight_key)); + } + + #[test] + fn write_file_policy_action_omits_unavailable_diff_stats() { + let action = AIAgentAction { + id: AIAgentActionId::from("action_1".to_string()), + task_id: TaskId::new("task_1".to_string()), + action: AIAgentActionType::RequestFileEdits { + file_edits: vec![FileEdit::Create { + file: Some("src/lib.rs".to_string()), + content: Some("fn main() {}\n".to_string()), + }], + title: None, + }, + requires_result: true, + }; + + let Some(AgentPolicyAction::WriteFiles(write_files)) = + agent_policy_action(&action, None, &None, &None) + else { + panic!("expected write-files policy action"); + }; + + assert_eq!(write_files.paths.len(), 1); + assert_eq!(write_files.diff_stats, None); + } + + #[test] + fn write_to_shell_policy_action_is_governed_and_redacted() { + let action = write_to_shell_action("Authorization: Bearer secret-token\n:q\n"); + + let Some(AgentPolicyAction::WriteToLongRunningShellCommand(write)) = + agent_policy_action(&action, None, &None, &None) + else { + panic!("expected write-to-long-running-shell-command policy action"); + }; + + assert_eq!(write.block_id, "block_1"); + assert_eq!(write.mode, "line"); + assert_eq!(write.input, "Authorization: Bearer \n:q\n"); + } + + #[test] + fn command_normalization_matches_shell_escape_style() { + assert_eq!( + normalize_command_for_policy("echo one\\\n+two", Some(ShellType::Bash)), + "echo one +two" + ); + assert_eq!( + normalize_command_for_policy("echo one`\n+two", Some(ShellType::PowerShell)), + "echo one +two" + ); + } +} diff --git a/app/src/ai/blocklist/permissions.rs b/app/src/ai/blocklist/permissions.rs index b2fed85fe..72f18bc02 100644 --- a/app/src/ai/blocklist/permissions.rs +++ b/app/src/ai/blocklist/permissions.rs @@ -209,6 +209,7 @@ impl BlocklistAIPermissions { context_window_limit: profile_data.context_window_limit, autosync_plans_to_warp_drive: profile_data.autosync_plans_to_warp_drive, web_search_enabled: profile_data.web_search_enabled, + agent_policy_hooks: profile_data.agent_policy_hooks.clone(), } } diff --git a/app/src/ai/execution_profiles/mod.rs b/app/src/ai/execution_profiles/mod.rs index be7c66a26..d63c67fa9 100644 --- a/app/src/ai/execution_profiles/mod.rs +++ b/app/src/ai/execution_profiles/mod.rs @@ -24,6 +24,7 @@ use warp_core::features::FeatureFlag; use warpui::{AppContext, SingletonEntity}; use super::llms::{LLMContextWindow, LLMId, LLMPreferences}; +use super::policy_hooks::AgentPolicyHookConfig; pub const PROFILE_NAME_MAX_LENGTH: usize = 50; @@ -255,6 +256,9 @@ pub struct AIExecutionProfile { /// Whether the agent may use web search when helpful for completing tasks pub web_search_enabled: bool, + + /// Optional host-enforced policy hooks for governed agent actions. + pub(crate) agent_policy_hooks: AgentPolicyHookConfig, } impl Default for AIExecutionProfile { @@ -281,6 +285,7 @@ impl Default for AIExecutionProfile { context_window_limit: None, autosync_plans_to_warp_drive: true, web_search_enabled: true, + agent_policy_hooks: AgentPolicyHookConfig::default(), } } } @@ -333,6 +338,7 @@ impl AIExecutionProfile { context_window_limit: None, autosync_plans_to_warp_drive: false, web_search_enabled: true, + agent_policy_hooks: AgentPolicyHookConfig::default(), } } @@ -388,6 +394,7 @@ impl AIExecutionProfile { context_window_limit: None, autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(), web_search_enabled: true, + agent_policy_hooks: AgentPolicyHookConfig::default(), } } } diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 5f1cb3511..37c755405 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -48,6 +48,7 @@ pub(crate) mod generate_code_review_content; pub(crate) mod loading; pub mod mcp; pub mod outline; +pub(crate) mod policy_hooks; pub(crate) use ai::paths; diff --git a/app/src/ai/policy_hooks/audit.rs b/app/src/ai/policy_hooks/audit.rs new file mode 100644 index 000000000..8ba480b41 --- /dev/null +++ b/app/src/ai/policy_hooks/audit.rs @@ -0,0 +1,197 @@ +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Serialize, Serializer}; + +use super::{ + decision::AgentPolicyEffectiveDecision, + event::{redact_policy_path, AgentPolicyAction, AgentPolicyActionKind, AgentPolicyEvent}, +}; + +#[cfg(not(test))] +const AUDIT_DIR_NAME: &str = "agent_policy_hooks"; +#[cfg(not(test))] +const AUDIT_FILE_NAME: &str = "audit.jsonl"; + +#[derive(Debug, Serialize)] +struct AgentPolicyAuditRecord<'a> { + schema_version: &'a str, + timestamp: DateTime, + event_id: uuid::Uuid, + conversation_id: &'a str, + action_id: &'a str, + action_kind: AgentPolicyActionKind, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_audit_path_option")] + working_directory: Option<&'a PathBuf>, + run_until_completion: bool, + #[serde(skip_serializing_if = "Option::is_none")] + active_profile_id: Option<&'a str>, + action: &'a AgentPolicyAction, + effective_decision: &'a AgentPolicyEffectiveDecision, + redaction: AgentPolicyAuditRedaction, +} + +#[derive(Debug, Serialize)] +struct AgentPolicyAuditRedaction { + command_secrets_redacted: bool, + mcp_argument_values_omitted: bool, +} + +pub(crate) fn write_audit_record( + event: &AgentPolicyEvent, + decision: &AgentPolicyEffectiveDecision, +) -> Result<()> { + let Some(path) = audit_log_path() else { + return Ok(()); + }; + let parent = path + .parent() + .context("agent policy audit path has no parent directory")?; + + create_private_directory_all(parent) + .with_context(|| format!("create agent policy audit directory {}", parent.display()))?; + + let line = audit_record_json_line(event, decision)?; + + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + + let mut file = options + .open(&path) + .with_context(|| format!("open agent policy audit log {}", path.display()))?; + file.write_all(line.as_bytes()) + .with_context(|| format!("write agent policy audit log {}", path.display()))?; + file.write_all(b"\n") + .with_context(|| format!("terminate agent policy audit log {}", path.display()))?; + set_private_file_permissions(&path); + + Ok(()) +} + +fn create_private_directory_all(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700).create(path)?; + set_private_directory_permissions(path); + Ok(()) + } + + #[cfg(not(unix))] + { + fs::create_dir_all(path) + } +} + +pub(crate) fn audit_record_json_line( + event: &AgentPolicyEvent, + decision: &AgentPolicyEffectiveDecision, +) -> Result { + let record = AgentPolicyAuditRecord { + schema_version: event.schema_version.as_str(), + timestamp: Utc::now(), + event_id: event.event_id, + conversation_id: event.conversation_id.as_str(), + action_id: event.action_id.as_str(), + action_kind: event.action_kind, + working_directory: event.working_directory.as_ref(), + run_until_completion: event.run_until_completion, + active_profile_id: event.active_profile_id.as_deref(), + action: &event.action, + effective_decision: decision, + redaction: AgentPolicyAuditRedaction { + command_secrets_redacted: true, + mcp_argument_values_omitted: true, + }, + }; + + serde_json::to_string(&record).context("serialize agent policy audit record") +} + +fn serialize_audit_path_option(path: &Option<&PathBuf>, serializer: S) -> Result +where + S: Serializer, +{ + match path { + Some(path) => serializer.serialize_some(&redact_policy_path(path)), + None => serializer.serialize_none(), + } +} + +fn audit_log_path() -> Option { + #[cfg(test)] + { + None + } + + #[cfg(not(test))] + { + Some( + warp_core::paths::secure_state_dir() + .unwrap_or_else(warp_core::paths::state_dir) + .join(AUDIT_DIR_NAME) + .join(AUDIT_FILE_NAME), + ) + } +} + +#[cfg(unix)] +fn set_private_directory_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o700)) { + log::warn!( + "Failed to set private permissions on agent policy audit directory {}: {err}", + path.display() + ); + } +} + +#[cfg(not(unix))] +fn set_private_directory_permissions(_path: &Path) {} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { + log::warn!( + "Failed to set private permissions on agent policy audit log {}: {err}", + path.display() + ); + } +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) {} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::PermissionsExt as _; + + use super::create_private_directory_all; + + #[test] + fn create_private_directory_all_uses_private_permissions() { + let root = tempfile::tempdir().unwrap(); + let audit_dir = root.path().join("agent_policy_hooks"); + + create_private_directory_all(&audit_dir).unwrap(); + + let mode = audit_dir.metadata().unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } +} diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs new file mode 100644 index 000000000..6d69a1f5c --- /dev/null +++ b/app/src/ai/policy_hooks/config.rs @@ -0,0 +1,760 @@ +use std::{ + collections::BTreeMap, + fmt, + path::{Path, PathBuf}, +}; + +use http::header::HeaderName; +use serde::{ser::SerializeStruct, Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + decision::AgentPolicyUnavailableDecision, redaction::redact_sensitive_text_for_policy, +}; + +pub(crate) const DEFAULT_AGENT_POLICY_HOOK_TIMEOUT_MS: u64 = 5_000; +pub(crate) const MAX_AGENT_POLICY_HOOK_TIMEOUT_MS: u64 = 60_000; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)] +#[serde(default)] +pub(crate) struct AgentPolicyHookConfig { + pub enabled: bool, + pub before_action: Vec, + pub timeout_ms: u64, + pub on_unavailable: AgentPolicyUnavailableDecision, + pub allow_hook_autoapproval: bool, +} + +impl Default for AgentPolicyHookConfig { + fn default() -> Self { + Self { + enabled: false, + before_action: Vec::new(), + timeout_ms: DEFAULT_AGENT_POLICY_HOOK_TIMEOUT_MS, + on_unavailable: AgentPolicyUnavailableDecision::Ask, + allow_hook_autoapproval: false, + } + } +} + +impl AgentPolicyHookConfig { + pub(crate) fn is_active(&self) -> bool { + self.enabled + } + + fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { + for hook in &self.before_action { + hook.validate_safe_to_persist()?; + } + + Ok(()) + } + + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + self.validate_safe_to_persist()?; + + if !self.enabled { + return Ok(()); + } + + validate_timeout_ms(self.timeout_ms)?; + + if self.before_action.is_empty() { + return Err(AgentPolicyHookConfigError::NoBeforeActionHooks); + } + + for hook in &self.before_action { + hook.validate()?; + } + + Ok(()) + } + + pub(crate) fn hook_timeout_ms(&self, hook: &AgentPolicyHook) -> u64 { + hook.timeout_ms.unwrap_or(self.timeout_ms) + } + + pub(crate) fn hook_unavailable_decision( + &self, + hook: &AgentPolicyHook, + ) -> AgentPolicyUnavailableDecision { + if self.on_unavailable == AgentPolicyUnavailableDecision::Deny { + return AgentPolicyUnavailableDecision::Deny; + } + + hook.on_unavailable.unwrap_or(self.on_unavailable) + } + + pub(crate) fn allow_autoapproval_for_all_hooks(&self) -> bool { + self.allow_hook_autoapproval + && !self.before_action.is_empty() + && self + .before_action + .iter() + .all(|hook| hook.allow_autoapproval) + } +} + +impl Serialize for AgentPolicyHookConfig { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let sanitized_config; + let config = if self.validate_safe_to_persist().is_ok() { + self + } else { + sanitized_config = Self::default(); + &sanitized_config + }; + + let mut state = serializer.serialize_struct("AgentPolicyHookConfig", 5)?; + state.serialize_field("enabled", &config.enabled)?; + state.serialize_field("before_action", &config.before_action)?; + state.serialize_field("timeout_ms", &config.timeout_ms)?; + state.serialize_field("on_unavailable", &config.on_unavailable)?; + state.serialize_field("allow_hook_autoapproval", &config.allow_hook_autoapproval)?; + state.end() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct AgentPolicyHook { + pub name: String, + pub timeout_ms: Option, + pub on_unavailable: Option, + pub allow_autoapproval: bool, + #[serde(flatten)] + pub transport: AgentPolicyHookTransport, +} + +impl AgentPolicyHook { + fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { + if hook_name_contains_credentials(&self.name) { + return Err(AgentPolicyHookConfigError::HookNameContainsCredentials); + } + self.transport.validate_safe_to_persist() + } + + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + if self.name.trim().is_empty() { + return Err(AgentPolicyHookConfigError::MissingHookName); + } + if hook_name_contains_credentials(&self.name) { + return Err(AgentPolicyHookConfigError::HookNameContainsCredentials); + } + + if let Some(timeout_ms) = self.timeout_ms { + validate_timeout_ms(timeout_ms)?; + } + + self.transport.validate() + } +} + +impl Default for AgentPolicyHook { + fn default() -> Self { + Self { + name: String::new(), + timeout_ms: None, + on_unavailable: None, + allow_autoapproval: false, + transport: AgentPolicyHookTransport::Stdio { + command: String::new(), + args: Vec::new(), + env: BTreeMap::new(), + working_directory: None, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "transport", rename_all = "snake_case")] +pub(crate) enum AgentPolicyHookTransport { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + env: BTreeMap, + #[serde(default)] + working_directory: Option, + }, + Http { + url: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + headers: BTreeMap, + }, +} + +impl AgentPolicyHookTransport { + fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { + match self { + Self::Stdio { + command, + args, + env, + working_directory, + } => { + validate_stdio_command(command)?; + validate_stdio_args(args)?; + validate_stdio_secret_value_map(env)?; + validate_stdio_working_directory_safe_to_persist(working_directory)?; + } + Self::Http { url, headers } => { + validate_http_url(url)?; + validate_http_secret_value_map(headers)?; + } + } + + Ok(()) + } + + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + match self { + Self::Stdio { + command, + args, + env, + working_directory, + } => { + if command.trim().is_empty() { + return Err(AgentPolicyHookConfigError::MissingStdioCommand); + } + validate_stdio_command(command)?; + validate_stdio_args(args)?; + validate_stdio_secret_value_map(env)?; + + if working_directory + .as_deref() + .is_some_and(|path| path.as_os_str().is_empty()) + { + return Err(AgentPolicyHookConfigError::InvalidWorkingDirectory( + Path::new("").to_path_buf(), + )); + } + validate_stdio_working_directory_safe_to_persist(working_directory)?; + } + Self::Http { url, headers } => { + validate_http_url(url)?; + validate_http_secret_value_map(headers)?; + } + } + + Ok(()) + } +} + +/// Reference to a local environment variable that supplies a hook credential at runtime. +/// The profile persists only the environment variable name, never the credential value. +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentPolicyHookSecretValue { + env: String, +} + +impl AgentPolicyHookSecretValue { + #[cfg(not(target_family = "wasm"))] + pub(crate) fn resolved_value(&self) -> Result { + std::env::var(&self.env).map_err(|_| self.env.clone()) + } + + #[cfg(target_family = "wasm")] + pub(crate) fn resolved_value(&self) -> Result { + Err(self.env.clone()) + } + + fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + let env = self.env.trim(); + if env.is_empty() { + return Err(AgentPolicyHookConfigError::MissingSecretEnvironmentVariableName); + } + if env != self.env || !is_env_reference_name(env) || text_contains_common_token(env) { + return Err(AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName); + } + Ok(()) + } +} + +impl fmt::Debug for AgentPolicyHookSecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Env").field("env", &self.env).finish() + } +} + +fn validate_stdio_secret_value_map( + values: &BTreeMap, +) -> Result<(), AgentPolicyHookConfigError> { + for (name, value) in values { + if !is_env_reference_name(name) || text_contains_common_token(name) { + return Err(AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName); + } + value.validate()?; + } + Ok(()) +} + +fn validate_http_secret_value_map( + values: &BTreeMap, +) -> Result<(), AgentPolicyHookConfigError> { + for (name, value) in values { + if HeaderName::from_bytes(name.as_bytes()).is_err() || text_contains_common_token(name) { + return Err(AgentPolicyHookConfigError::InvalidHttpHeaderName( + name.clone(), + )); + } + value.validate()?; + } + Ok(()) +} + +fn validate_stdio_working_directory_safe_to_persist( + working_directory: &Option, +) -> Result<(), AgentPolicyHookConfigError> { + let Some(working_directory) = working_directory else { + return Ok(()); + }; + + let value = working_directory.to_string_lossy(); + if redact_sensitive_text_for_policy(&value) != value { + return Err(AgentPolicyHookConfigError::StdioWorkingDirectoryContainsCredentials); + } + + Ok(()) +} + +fn validate_http_url(url: &str) -> Result<(), AgentPolicyHookConfigError> { + if http_url_contains_credentials(url) { + return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); + } + + let parsed = + url::Url::parse(url).map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.into()))?; + + let host = parsed.host_str().unwrap_or_default(); + let is_localhost = matches!(host, "localhost" | "127.0.0.1" | "::1"); + let is_allowed_local_http = parsed.scheme() == "http" && is_localhost; + if parsed.scheme() != "https" && !is_allowed_local_http { + return Err(AgentPolicyHookConfigError::InsecureHttpUrl(url.into())); + } + + Ok(()) +} + +fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError> { + if args.iter().enumerate().any(|(index, arg)| { + !index + .checked_sub(1) + .is_some_and(|previous| stdio_arg_is_shell_fragment_flag(&args[previous])) + && stdio_arg_contains_credentials(arg) + }) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } + if args.windows(2).any(|args| { + stdio_arg_expects_secret_value(&args[0]) && stdio_arg_value_is_literal_secret(&args[1]) + }) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } + if args.windows(2).any(|args| { + stdio_arg_expects_header_value(&args[0]) + && stdio_header_value_contains_credentials(&args[1]) + }) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } + if args.windows(2).any(|args| { + stdio_arg_is_shell_fragment_flag(&args[0]) + && stdio_command_fragment_contains_credentials(&args[1], 0) + }) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } + Ok(()) +} + +const MAX_STDIO_COMMAND_FRAGMENT_DEPTH: usize = 8; + +fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigError> { + if stdio_command_fragment_contains_credentials(command, 0) { + return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + } + + Ok(()) +} + +fn stdio_command_fragment_contains_credentials(command: &str, depth: usize) -> bool { + let words = shell_words::split(command).unwrap_or_else(|_| { + command + .split_ascii_whitespace() + .map(ToString::to_string) + .collect() + }); + if words.iter().enumerate().any(|(index, word)| { + !index + .checked_sub(1) + .is_some_and(|previous| stdio_arg_is_shell_fragment_flag(&words[previous])) + && stdio_arg_contains_credentials(word) + }) { + return true; + } + if words.windows(2).any(|words| { + stdio_arg_expects_secret_value(&words[0]) && stdio_arg_value_is_literal_secret(&words[1]) + }) { + return true; + } + if words.windows(2).any(|words| { + stdio_arg_expects_header_value(&words[0]) + && stdio_header_value_contains_credentials(&words[1]) + }) { + return true; + } + if depth < MAX_STDIO_COMMAND_FRAGMENT_DEPTH { + for words in words.windows(2) { + if stdio_arg_is_shell_fragment_flag(&words[0]) + && stdio_command_fragment_contains_credentials(&words[1], depth + 1) + { + return true; + } + } + } else if words + .windows(2) + .any(|words| stdio_arg_is_shell_fragment_flag(&words[0])) + { + return true; + } + + false +} + +fn http_url_contains_credentials(url: &str) -> bool { + if let Ok(parsed) = url::Url::parse(url) { + return parsed_url_contains_credentials(&parsed); + } + + let url = url.trim_start(); + let Some(scheme_end) = url.find(':') else { + return false; + }; + let scheme = &url[..scheme_end]; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return false; + } + + let mut authority_start = scheme_end + 1; + if url[authority_start..].starts_with("//") { + authority_start += 2; + } else if url[authority_start..].starts_with('/') { + authority_start += 1; + } + + let authority_end = url[authority_start..] + .find(|ch| matches!(ch, '/' | '?' | '#')) + .map(|offset| authority_start + offset) + .unwrap_or(url.len()); + + if url[authority_start..authority_end].contains('@') { + return true; + } + + let suffix = &url[authority_end..]; + url_component_contains_credentials(suffix) +} + +fn stdio_http_url_contains_credentials(value: &str) -> bool { + if let Ok(parsed) = url::Url::parse(value) { + return matches!(parsed.scheme(), "http" | "https") + && parsed_url_contains_credentials(&parsed); + } + + http_url_contains_credentials(value) +} + +fn parsed_url_contains_credentials(parsed: &url::Url) -> bool { + !parsed.username().is_empty() + || parsed.password().is_some() + || url_component_contains_credentials(parsed.path()) + || parsed.query_pairs().any(|(key, value)| { + url_component_contains_credentials(&key) || url_component_contains_credentials(&value) + }) + || parsed + .fragment() + .is_some_and(url_component_contains_credentials) +} + +fn url_component_contains_credentials(value: &str) -> bool { + let mut current = std::borrow::Cow::Borrowed(value); + for _ in 0..=4 { + if text_contains_credentials(current.as_ref()) { + return true; + } + + let Ok(decoded) = urlencoding::decode(current.as_ref()) else { + return false; + }; + if decoded == current { + return false; + } + current = std::borrow::Cow::Owned(decoded.into_owned()); + } + + text_contains_credentials(current.as_ref()) +} + +fn text_contains_credentials(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if lower.contains("bearer ") || lower.contains("bearer%20") { + return true; + } + if lower.contains("basic ") || lower.contains("basic%20") { + return true; + } + + let normalized = lower.replace(['_', '-'], ""); + if normalized_contains_credential_marker(&normalized) { + return true; + } + + lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .any(|part| { + matches!( + part, + "token" | "secret" | "password" | "passwd" | "authorization" + ) + }) + || text_contains_common_token(value) +} + +fn normalized_contains_credential_marker(normalized: &str) -> bool { + normalized.contains("apikey") + || normalized.contains("accesskey") + || normalized.contains("token") + || normalized.contains("secret") + || normalized.contains("password") + || normalized.contains("passwd") + || normalized.contains("authorization") +} + +fn stdio_arg_contains_credentials(value: &str) -> bool { + if stdio_http_url_contains_credentials(value) { + return true; + } + + let lower = value.to_ascii_lowercase(); + if let Some((name, secret)) = value.split_once('=') { + let secret = secret.trim(); + if stdio_arg_expects_header_value(name) { + return stdio_header_value_contains_credentials(secret); + } + if (text_contains_credentials(name) || stdio_arg_expects_secret_value(name)) + && !secret.is_empty() + && !stdio_arg_value_uses_env_secret_reference(secret) + { + return true; + } + } + + if let Some(offset) = lower.find("authorization:") { + let value = value[offset + "authorization:".len()..].trim(); + if !value.is_empty() && !stdio_arg_value_uses_env_secret_reference(value) { + return true; + } + } + + if let Some((name, secret)) = value.split_once(':') { + let secret = secret.trim(); + if text_contains_credentials(name) + && !secret.is_empty() + && !stdio_arg_value_uses_env_secret_reference(secret) + { + return true; + } + } + + if (lower.contains("bearer ") || lower.contains("basic ")) + && !stdio_arg_value_uses_env_secret_reference(value) + { + return true; + } + + text_contains_common_token(value) +} + +fn stdio_arg_expects_header_value(value: &str) -> bool { + matches!( + value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(), + "-H" | "--header" | "--proxy-header" + ) +} + +fn stdio_arg_is_shell_fragment_flag(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(); + + if value == "-e" { + return true; + } + + let Some(flags) = value.strip_prefix('-') else { + return false; + }; + !flags.starts_with('-') + && !flags.is_empty() + && flags.len() <= 4 + && flags.contains('c') + && flags.chars().all(|ch| ch.is_ascii_alphabetic()) +} + +fn stdio_header_value_contains_credentials(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(); + let Some((name, secret)) = value.split_once(':') else { + return false; + }; + let secret = secret + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(); + text_contains_credentials(name) + && !secret.is_empty() + && !stdio_arg_value_uses_env_secret_reference(secret) +} + +fn text_contains_common_token(value: &str) -> bool { + value + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_') + .any(|part| { + part.strip_prefix("sk-") + .is_some_and(|token| token.len() >= 12) + || ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"] + .iter() + .any(|prefix| { + part.strip_prefix(prefix).is_some_and(|token| { + token.len() >= 12 + && token + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + }) + }) + }) +} + +fn stdio_arg_expects_secret_value(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(); + if value.contains('=') { + return false; + } + let is_flag = value.starts_with('-'); + let is_header_name = value.ends_with(':'); + if !is_flag && !is_header_name { + return false; + } + let value = value.trim_start_matches('-').trim_end_matches(':'); + let normalized = value.to_ascii_lowercase().replace(['_', '-'], ""); + + normalized_contains_credential_marker(&normalized) + || normalized == "u" + || normalized == "user" + || normalized == "proxyuser" + || normalized == "auth" +} + +fn hook_name_contains_credentials(name: &str) -> bool { + redact_sensitive_text_for_policy(name) != name +} + +fn stdio_arg_value_is_literal_secret(value: &str) -> bool { + let value = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); + !value.is_empty() && !stdio_arg_value_uses_env_secret_reference(value) +} + +fn stdio_arg_value_uses_env_secret_reference(value: &str) -> bool { + let value = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); + let value = strip_ascii_case_prefix(value, "authorization:") + .unwrap_or(value) + .trim(); + let value = strip_ascii_case_prefix(value, "bearer ") + .or_else(|| strip_ascii_case_prefix(value, "basic ")) + .unwrap_or(value) + .trim(); + + if let Some(name) = value + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + { + return is_env_reference_name(name); + } + + value.strip_prefix('$').is_some_and(is_env_reference_name) +} + +fn is_env_reference_name(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +fn strip_ascii_case_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + let head = value.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix) + .then_some(&value[prefix.len()..]) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum AgentPolicyHookConfigError { + #[error("agent policy hooks are enabled but no before-action hooks are configured")] + NoBeforeActionHooks, + #[error("agent policy hook name must not be empty")] + MissingHookName, + #[error("agent policy hook name must not include credentials")] + HookNameContainsCredentials, + #[error("agent policy hook stdio command must not be empty")] + MissingStdioCommand, + #[error( + "agent policy hook stdio command must not include credentials; use args with env secret references" + )] + StdioCommandContainsCredentials, + #[error( + "agent policy hook stdio args must not include credentials; use env secret references" + )] + StdioArgContainsCredentials, + #[error("agent policy hook stdio working directory must not include credentials")] + StdioWorkingDirectoryContainsCredentials, + #[error( + "agent policy hook timeout must be between 1 and {MAX_AGENT_POLICY_HOOK_TIMEOUT_MS} ms" + )] + InvalidTimeoutMs, + #[error("agent policy hook working directory is invalid: {0:?}")] + InvalidWorkingDirectory(PathBuf), + #[error("agent policy hook HTTP URL is invalid: {0}")] + InvalidHttpUrl(String), + #[error("agent policy hook HTTP URL must use HTTPS unless it targets localhost: {0}")] + InsecureHttpUrl(String), + #[error("agent policy hook HTTP URL must not include embedded credentials")] + HttpUrlContainsCredentials, + #[error("agent policy hook HTTP header name is invalid: {0}")] + InvalidHttpHeaderName(String), + #[error("agent policy hook secret environment variable name must not be empty")] + MissingSecretEnvironmentVariableName, + #[error("agent policy hook secret environment variable reference must be an environment variable name")] + InvalidSecretEnvironmentVariableName, +} + +fn validate_timeout_ms(timeout_ms: u64) -> Result<(), AgentPolicyHookConfigError> { + if !(1..=MAX_AGENT_POLICY_HOOK_TIMEOUT_MS).contains(&timeout_ms) { + return Err(AgentPolicyHookConfigError::InvalidTimeoutMs); + } + + Ok(()) +} diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs new file mode 100644 index 000000000..0d328e60d --- /dev/null +++ b/app/src/ai/policy_hooks/decision.rs @@ -0,0 +1,225 @@ +use serde::{Deserialize, Serialize}; + +use super::redaction::redact_sensitive_text_for_policy; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentPolicyDecisionKind { + Allow, + Deny, + Ask, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentPolicyUnavailableDecision { + Allow, + Deny, + #[default] + Ask, + #[serde(other)] + Unknown, +} + +impl AgentPolicyUnavailableDecision { + pub(crate) fn decision_kind(self) -> AgentPolicyDecisionKind { + match self { + Self::Allow => AgentPolicyDecisionKind::Allow, + Self::Deny => AgentPolicyDecisionKind::Deny, + Self::Ask | Self::Unknown => AgentPolicyDecisionKind::Ask, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentPolicyHookResponse { + pub schema_version: String, + pub decision: AgentPolicyDecisionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_audit_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WarpPermissionDecisionKind { + Allow, + Ask, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct WarpPermissionSnapshot { + pub decision: WarpPermissionDecisionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl WarpPermissionSnapshot { + pub(crate) fn allow(reason: Option) -> Self { + Self { + decision: WarpPermissionDecisionKind::Allow, + reason, + } + } + + pub(crate) fn ask(reason: Option) -> Self { + Self { + decision: WarpPermissionDecisionKind::Ask, + reason, + } + } + + pub(crate) fn deny(reason: Option) -> Self { + Self { + decision: WarpPermissionDecisionKind::Deny, + reason, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentPolicyHookErrorKind { + InvalidConfiguration, + Timeout, + SpawnFailed, + StdinWriteFailed, + NonZeroExit, + PayloadTooLarge, + MalformedResponse, + UnsupportedTransport, + HttpRequestFailed, + HttpStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentPolicyHookEvaluation { + pub hook_name: String, + pub decision: AgentPolicyDecisionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_audit_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl AgentPolicyHookEvaluation { + pub(crate) fn from_response( + hook_name: impl Into, + response: AgentPolicyHookResponse, + ) -> Self { + Self { + hook_name: sanitize_policy_string(hook_name.into()), + decision: response.decision, + reason: response.reason.map(sanitize_policy_string), + external_audit_id: response.external_audit_id.map(sanitize_policy_string), + error: None, + } + } + + pub(crate) fn unavailable( + hook_name: impl Into, + decision: AgentPolicyDecisionKind, + error: AgentPolicyHookErrorKind, + reason: impl Into, + ) -> Self { + Self { + hook_name: sanitize_policy_string(hook_name.into()), + decision, + reason: Some(sanitize_policy_string(reason.into())), + external_audit_id: None, + error: Some(error), + } + } +} + +fn sanitize_policy_string(value: String) -> String { + redact_sensitive_text_for_policy(&value) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentPolicyEffectiveDecision { + pub decision: AgentPolicyDecisionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub warp_permission: WarpPermissionSnapshot, + #[serde(default)] + pub hook_results: Vec, +} + +pub(crate) fn compose_policy_decisions( + warp_permission: WarpPermissionSnapshot, + hook_results: Vec, + allow_hook_autoapproval: bool, +) -> AgentPolicyEffectiveDecision { + let first_denial = hook_results + .iter() + .find(|result| result.decision == AgentPolicyDecisionKind::Deny); + if let Some(denial) = first_denial { + return AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Deny, + reason: denial.reason.clone(), + warp_permission, + hook_results, + }; + } + + if warp_permission.decision == WarpPermissionDecisionKind::Deny { + return AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Deny, + reason: warp_permission.reason.clone(), + warp_permission, + hook_results, + }; + } + + let first_ask = hook_results + .iter() + .find(|result| result.decision == AgentPolicyDecisionKind::Ask); + if let Some(ask) = first_ask { + return AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Ask, + reason: ask.reason.clone(), + warp_permission, + hook_results, + }; + } + + match warp_permission.decision { + WarpPermissionDecisionKind::Allow => AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Allow, + reason: warp_permission.reason.clone(), + warp_permission, + hook_results, + }, + WarpPermissionDecisionKind::Ask + if allow_hook_autoapproval && successful_hook_allows_autoapproval(&hook_results) => + { + AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Allow, + reason: hook_results.iter().find_map(|result| result.reason.clone()), + warp_permission, + hook_results, + } + } + WarpPermissionDecisionKind::Ask => AgentPolicyEffectiveDecision { + decision: AgentPolicyDecisionKind::Ask, + reason: warp_permission.reason.clone(), + warp_permission, + hook_results, + }, + WarpPermissionDecisionKind::Deny => unreachable!("warp deny is handled before this match"), + } +} + +fn successful_hook_allows_autoapproval(hook_results: &[AgentPolicyHookEvaluation]) -> bool { + !hook_results.is_empty() + && hook_results.iter().all(|result| { + result.decision == AgentPolicyDecisionKind::Allow && result.error.is_none() + }) +} diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs new file mode 100644 index 000000000..62bbb6813 --- /dev/null +++ b/app/src/ai/policy_hooks/engine.rs @@ -0,0 +1,563 @@ +use std::{collections::BTreeMap, io, process::ExitStatus, time::Duration}; + +use anyhow::{anyhow, Context, Result}; +use command::{r#async::Command, Stdio}; +use futures::StreamExt as _; +use futures_lite::{ + future, + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, +}; +use reqwest::header::CONTENT_TYPE; +use warpui::r#async::FutureExt as _; + +use super::{ + audit::write_audit_record, + config::{ + AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookSecretValue, + AgentPolicyHookTransport, + }, + decision::{ + compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + AgentPolicyHookErrorKind, AgentPolicyHookEvaluation, AgentPolicyHookResponse, + WarpPermissionSnapshot, + }, + event::{AgentPolicyEvent, AGENT_POLICY_SCHEMA_VERSION}, + redaction::redact_sensitive_text_for_policy, +}; + +const MAX_HOOK_OUTPUT_BYTES: usize = 64 * 1024; +const MAX_HOOK_EVENT_BYTES: usize = 128 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct AgentPolicyHookEngine { + config: AgentPolicyHookConfig, +} + +impl AgentPolicyHookEngine { + pub(crate) fn new(config: AgentPolicyHookConfig) -> Self { + Self { config } + } + + pub(crate) async fn preflight( + &self, + mut event: AgentPolicyEvent, + warp_permission: WarpPermissionSnapshot, + ) -> AgentPolicyEffectiveDecision { + event.warp_permission = warp_permission.clone(); + + if !self.config.is_active() { + return compose_policy_decisions(warp_permission, Vec::new(), false); + } + + if let Err(err) = self.config.validate() { + let decision = compose_policy_decisions( + warp_permission, + vec![AgentPolicyHookEvaluation::unavailable( + "agent_policy_hooks", + self.config.on_unavailable.decision_kind(), + AgentPolicyHookErrorKind::InvalidConfiguration, + format!("agent policy hook configuration is invalid: {err}"), + )], + false, + ); + audit_decision(&event, &decision); + return decision; + } + + let mut hook_results = Vec::new(); + for hook in &self.config.before_action { + let result = self.evaluate_hook(hook, &event).await; + let denied = result.decision == AgentPolicyDecisionKind::Deny; + hook_results.push(result); + + if denied { + break; + } + } + + let decision = compose_policy_decisions( + warp_permission, + hook_results, + self.config.allow_autoapproval_for_all_hooks(), + ); + audit_decision(&event, &decision); + decision + } + + async fn evaluate_hook( + &self, + hook: &AgentPolicyHook, + event: &AgentPolicyEvent, + ) -> AgentPolicyHookEvaluation { + let response = match &hook.transport { + AgentPolicyHookTransport::Stdio { .. } => self.run_stdio_hook(hook, event).await, + AgentPolicyHookTransport::Http { .. } => self.run_http_hook(hook, event).await, + }; + + match response { + Ok(response) => AgentPolicyHookEvaluation::from_response( + hook.name.clone(), + redact_hook_response_configured_secrets(response, hook), + ), + Err(failure) => { + let failure = redact_hook_failure_configured_secrets(failure, hook); + AgentPolicyHookEvaluation::unavailable( + hook.name.clone(), + self.config.hook_unavailable_decision(hook).decision_kind(), + failure.kind, + failure.detail, + ) + } + } + } + + async fn run_stdio_hook( + &self, + hook: &AgentPolicyHook, + event: &AgentPolicyEvent, + ) -> Result { + let AgentPolicyHookTransport::Stdio { + command, + args, + env, + working_directory, + } = &hook.transport + else { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::UnsupportedTransport, + detail: "hook transport is not stdio".to_string(), + }); + }; + + let mut command = Command::new(command); + command + .args(args) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + if let Some(working_directory) = working_directory { + command.current_dir(working_directory); + } + + for (key, value) in env { + command.env(key, resolve_hook_secret_value(value)?); + } + + let event_bytes = serialize_event(event)?; + + let mut child = command.spawn().map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: format!("failed to spawn policy hook: {source}"), + })?; + + let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); + let output = match async { + let Some(mut stdin) = child.stdin.take() else { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::StdinWriteFailed, + detail: "policy hook stdin was not available".to_string(), + }); + }; + + stdin + .write_all(&event_bytes) + .await + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::StdinWriteFailed, + detail: format!("failed to write policy event to hook stdin: {source}"), + })?; + stdin + .write_all(b"\n") + .await + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::StdinWriteFailed, + detail: format!("failed to terminate policy event on hook stdin: {source}"), + })?; + drop(stdin); + + let stdout = child.stdout.take().ok_or_else(|| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: "policy hook stdout was not available".to_string(), + })?; + let stderr = child.stderr.take().ok_or_else(|| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: "policy hook stderr was not available".to_string(), + })?; + + let (stdout, stderr) = future::try_zip( + read_capped_output(stdout, "stdout"), + read_capped_output(stderr, "stderr"), + ) + .await?; + let status = child + .status() + .await + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: format!("failed to wait for policy hook: {source}"), + })?; + + Ok::<_, AgentPolicyHookFailure>(HookProcessOutput { + status, + stdout, + stderr, + }) + } + .with_timeout(timeout) + .await + { + Err(_) => { + let _ = child.kill(); + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::Timeout, + detail: format!("policy hook timed out after {timeout:?}"), + }); + } + Ok(Err(failure)) => { + let _ = child.kill(); + return Err(failure); + } + Ok(Ok(output)) => output, + }; + + if !output.status.success() { + let stderr = redact_hook_stderr(&output.stderr, env); + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::NonZeroExit, + detail: format!( + "policy hook exited with {}; stderr={}", + output.status, stderr, + ), + }); + } + + let response = + parse_hook_response(&output.stdout).map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook returned malformed response: {source:#}"), + })?; + + Ok(response) + } + + async fn run_http_hook( + &self, + hook: &AgentPolicyHook, + event: &AgentPolicyEvent, + ) -> Result { + let AgentPolicyHookTransport::Http { url, headers } = &hook.transport else { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::UnsupportedTransport, + detail: "hook transport is not HTTP".to_string(), + }); + }; + + let event_bytes = serialize_event(event)?; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpRequestFailed, + detail: format!( + "failed to build HTTP policy hook client: {}", + source.without_url() + ), + })?; + let mut request = client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header("x-warp-agent-policy-event-id", event.event_id.to_string()) + .body(event_bytes); + for (key, value) in headers { + request = request.header(key.as_str(), resolve_hook_secret_value(value)?); + } + + let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); + let response_bytes = match async { + let response = request + .send() + .await + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpRequestFailed, + detail: format!("failed to call HTTP policy hook: {}", source.without_url()), + })?; + + let status = response.status(); + if !status.is_success() { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpStatus, + detail: format!("HTTP policy hook returned status {status}"), + }); + } + + read_capped_http_response(response).await + } + .with_timeout(timeout) + .await + { + Err(_) => { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::Timeout, + detail: format!("policy hook timed out after {timeout:?}"), + }); + } + Ok(result) => result?, + }; + + parse_hook_response(&response_bytes).map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook returned malformed response: {source:#}"), + }) + } +} + +fn audit_decision(event: &AgentPolicyEvent, decision: &AgentPolicyEffectiveDecision) { + if let Err(err) = write_audit_record(event, decision) { + log::warn!("Failed to write agent policy hook audit record: {err:#}"); + } +} + +#[derive(Debug, Clone)] +struct AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind, + detail: String, +} + +#[derive(Debug)] +struct HookProcessOutput { + status: ExitStatus, + stdout: Vec, + stderr: Vec, +} + +async fn read_capped_output( + mut reader: R, + stream_name: &'static str, +) -> Result, AgentPolicyHookFailure> +where + R: AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut chunk = [0_u8; 8192]; + + loop { + let read = reader + .read(&mut chunk) + .await + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: format!("failed to read policy hook {stream_name}: {source}"), + })?; + if read == 0 { + break; + } + + if output.len().saturating_add(read) > MAX_HOOK_OUTPUT_BYTES { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook {stream_name} exceeded {MAX_HOOK_OUTPUT_BYTES} bytes"), + }); + } + + output.extend_from_slice(&chunk[..read]); + } + + Ok(output) +} + +async fn read_capped_http_response( + response: reqwest::Response, +) -> Result, AgentPolicyHookFailure> { + if response + .content_length() + .is_some_and(|length| length > MAX_HOOK_OUTPUT_BYTES as u64) + { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook response exceeded {MAX_HOOK_OUTPUT_BYTES} bytes"), + }); + } + + let mut output = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpRequestFailed, + detail: format!( + "failed to read HTTP policy hook response: {}", + source.without_url() + ), + })?; + + if output.len().saturating_add(chunk.len()) > MAX_HOOK_OUTPUT_BYTES { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook response exceeded {MAX_HOOK_OUTPUT_BYTES} bytes"), + }); + } + + output.extend_from_slice(&chunk); + } + + Ok(output) +} + +fn redact_hook_stderr(stderr: &[u8], env: &BTreeMap) -> String { + let stderr = String::from_utf8_lossy(stderr); + let redacted = redact_configured_secret_values(stderr.trim(), env.values()); + redact_sensitive_text_for_policy(&redacted) +} + +fn redact_hook_response_configured_secrets( + response: AgentPolicyHookResponse, + hook: &AgentPolicyHook, +) -> AgentPolicyHookResponse { + match &hook.transport { + AgentPolicyHookTransport::Stdio { env, .. } => { + redact_hook_response_secret_values(response, env.values()) + } + AgentPolicyHookTransport::Http { headers, .. } => { + redact_hook_response_secret_values(response, headers.values()) + } + } +} + +fn redact_hook_failure_configured_secrets( + failure: AgentPolicyHookFailure, + hook: &AgentPolicyHook, +) -> AgentPolicyHookFailure { + let detail = match &hook.transport { + AgentPolicyHookTransport::Stdio { env, .. } => { + redact_configured_secret_values(&failure.detail, env.values()) + } + AgentPolicyHookTransport::Http { headers, .. } => { + redact_configured_secret_values(&failure.detail, headers.values()) + } + }; + + AgentPolicyHookFailure { detail, ..failure } +} + +fn redact_hook_response_secret_values<'a>( + response: AgentPolicyHookResponse, + secrets: impl IntoIterator + Clone, +) -> AgentPolicyHookResponse { + AgentPolicyHookResponse { + schema_version: response.schema_version, + decision: response.decision, + reason: response + .reason + .map(|reason| redact_configured_secret_values(&reason, secrets.clone())), + external_audit_id: response + .external_audit_id + .map(|audit_id| redact_configured_secret_values(&audit_id, secrets)), + } +} + +fn redact_configured_secret_values<'a>( + value: &str, + secrets: impl IntoIterator, +) -> String { + let mut redacted = value.to_string(); + for value in secrets { + let Ok(secret) = value.resolved_value() else { + continue; + }; + if !secret.is_empty() { + redacted = redacted.replace(&secret, ""); + } + if let Some((scheme, credential)) = secret.split_once(' ') { + if (scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("basic")) + && credential.len() >= 4 + { + redacted = redacted.replace(credential, ""); + } + } + } + redacted +} + +fn resolve_hook_secret_value( + value: &AgentPolicyHookSecretValue, +) -> Result { + value + .resolved_value() + .map_err(|env| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::InvalidConfiguration, + detail: format!("policy hook secret environment variable {env:?} is not set"), + }) +} + +struct CappedEventWriter { + bytes: Vec, + exceeded: bool, +} + +impl CappedEventWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + exceeded: false, + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl io::Write for CappedEventWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.bytes.len().saturating_add(buf.len()) > MAX_HOOK_EVENT_BYTES { + self.exceeded = true; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("policy event exceeded {MAX_HOOK_EVENT_BYTES} bytes"), + )); + } + + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn serialize_event(event: &AgentPolicyEvent) -> Result, AgentPolicyHookFailure> { + let mut writer = CappedEventWriter::new(); + if let Err(source) = serde_json::to_writer(&mut writer, event).context("serialize policy event") + { + let kind = if writer.exceeded { + AgentPolicyHookErrorKind::PayloadTooLarge + } else { + AgentPolicyHookErrorKind::MalformedResponse + }; + return Err(AgentPolicyHookFailure { + kind, + detail: format!("failed to serialize policy event: {source}"), + }); + } + + Ok(writer.into_inner()) +} + +fn parse_hook_response(stdout: &[u8]) -> Result { + let response: AgentPolicyHookResponse = + serde_json::from_slice(stdout).context("parse JSON response")?; + + if response.schema_version != AGENT_POLICY_SCHEMA_VERSION { + return Err(anyhow!("unsupported schema_version")); + } + + if response.decision == AgentPolicyDecisionKind::Unknown { + return Err(anyhow!("unknown policy hook decision")); + } + + Ok(response) +} diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs new file mode 100644 index 000000000..3b3cb0267 --- /dev/null +++ b/app/src/ai/policy_hooks/event.rs @@ -0,0 +1,348 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize, Serializer}; + +use super::{ + decision::WarpPermissionSnapshot, + redaction::{ + capped_policy_items, mcp_argument_keys, redact_command_for_policy, + redact_sensitive_text_for_policy, truncate_for_policy, + }, +}; + +pub(crate) const AGENT_POLICY_SCHEMA_VERSION: &str = "warp.agent_policy_hook.v1"; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct AgentPolicyEvent { + pub schema_version: String, + pub event_id: uuid::Uuid, + pub conversation_id: String, + pub action_id: String, + pub action_kind: AgentPolicyActionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_policy_path_option")] + pub working_directory: Option, + pub run_until_completion: bool, + pub hook_autoapproval_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_profile_id: Option, + pub warp_permission: WarpPermissionSnapshot, + pub action: AgentPolicyAction, +} + +impl AgentPolicyEvent { + pub(crate) fn new( + conversation_id: impl Into, + action_id: impl Into, + working_directory: Option, + run_until_completion: bool, + active_profile_id: Option, + warp_permission: WarpPermissionSnapshot, + action: AgentPolicyAction, + ) -> Self { + let action = action.redacted(); + Self { + schema_version: AGENT_POLICY_SCHEMA_VERSION.to_string(), + event_id: uuid::Uuid::new_v4(), + conversation_id: conversation_id.into(), + action_id: action_id.into(), + action_kind: action.kind(), + working_directory, + run_until_completion, + hook_autoapproval_enabled: false, + active_profile_id, + warp_permission, + action, + } + } + + pub(crate) fn with_hook_autoapproval_enabled(mut self, enabled: bool) -> Self { + self.hook_autoapproval_enabled = enabled; + self + } + + #[cfg(test)] + pub(crate) fn execute_command( + conversation_id: impl Into, + action_id: impl Into, + working_directory: Option, + run_until_completion: bool, + active_profile_id: Option, + warp_permission: WarpPermissionSnapshot, + action: PolicyExecuteCommandAction, + ) -> Self { + Self::new( + conversation_id, + action_id, + working_directory, + run_until_completion, + active_profile_id, + warp_permission, + AgentPolicyAction::ExecuteCommand(action.redacted()), + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentPolicyActionKind { + ExecuteCommand, + WriteToLongRunningShellCommand, + ReadFiles, + WriteFiles, + CallMcpTool, + ReadMcpResource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[allow(dead_code)] +pub(crate) enum AgentPolicyAction { + ExecuteCommand(PolicyExecuteCommandAction), + WriteToLongRunningShellCommand(PolicyWriteToLongRunningShellCommandAction), + ReadFiles(PolicyReadFilesAction), + WriteFiles(PolicyWriteFilesAction), + CallMcpTool(PolicyCallMcpToolAction), + ReadMcpResource(PolicyReadMcpResourceAction), +} + +impl AgentPolicyAction { + pub(crate) fn kind(&self) -> AgentPolicyActionKind { + match self { + Self::ExecuteCommand(_) => AgentPolicyActionKind::ExecuteCommand, + Self::WriteToLongRunningShellCommand(_) => { + AgentPolicyActionKind::WriteToLongRunningShellCommand + } + Self::ReadFiles(_) => AgentPolicyActionKind::ReadFiles, + Self::WriteFiles(_) => AgentPolicyActionKind::WriteFiles, + Self::CallMcpTool(_) => AgentPolicyActionKind::CallMcpTool, + Self::ReadMcpResource(_) => AgentPolicyActionKind::ReadMcpResource, + } + } + + fn redacted(self) -> Self { + match self { + Self::ExecuteCommand(action) => Self::ExecuteCommand(action.redacted()), + Self::WriteToLongRunningShellCommand(action) => Self::WriteToLongRunningShellCommand( + PolicyWriteToLongRunningShellCommandAction::new( + action.block_id, + action.input.as_bytes(), + action.mode, + ), + ), + Self::ReadFiles(action) => Self::ReadFiles(action), + Self::WriteFiles(action) => Self::WriteFiles(action), + Self::CallMcpTool(action) => Self::CallMcpTool(PolicyCallMcpToolAction { + server_id: action.server_id, + tool_name: redact_sensitive_text_for_policy(&action.tool_name), + argument_keys: action + .argument_keys + .into_iter() + .map(|key| redact_sensitive_text_for_policy(&key)) + .collect(), + omitted_argument_key_count: action.omitted_argument_key_count, + }), + Self::ReadMcpResource(action) => Self::ReadMcpResource( + PolicyReadMcpResourceAction::new(action.server_id, action.name, action.uri), + ), + } + } +} + +impl Serialize for AgentPolicyAction { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::ExecuteCommand(action) => action.serialize(serializer), + Self::WriteToLongRunningShellCommand(action) => action.serialize(serializer), + Self::ReadFiles(action) => action.serialize(serializer), + Self::WriteFiles(action) => action.serialize(serializer), + Self::CallMcpTool(action) => action.serialize(serializer), + Self::ReadMcpResource(action) => action.serialize(serializer), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyExecuteCommandAction { + pub command: String, + pub normalized_command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_read_only: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_risky: Option, +} + +impl PolicyExecuteCommandAction { + pub(crate) fn new( + command: impl Into, + normalized_command: impl Into, + is_read_only: Option, + is_risky: Option, + ) -> Self { + Self { + command: command.into(), + normalized_command: normalized_command.into(), + is_read_only, + is_risky, + } + } + + pub(crate) fn redacted(self) -> Self { + Self { + command: redact_command_for_policy(&self.command), + normalized_command: redact_command_for_policy(&self.normalized_command), + is_read_only: self.is_read_only, + is_risky: self.is_risky, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyWriteToLongRunningShellCommandAction { + pub block_id: String, + pub input: String, + pub mode: String, +} + +impl PolicyWriteToLongRunningShellCommandAction { + pub(crate) fn new( + block_id: impl Into, + input: impl AsRef<[u8]>, + mode: impl Into, + ) -> Self { + let input = String::from_utf8_lossy(input.as_ref()); + let block_id = block_id.into(); + let mode = mode.into(); + Self { + block_id: truncate_for_policy(&block_id), + input: redact_command_for_policy(&input), + mode: truncate_for_policy(&mode), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyReadFilesAction { + pub paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omitted_path_count: Option, +} + +impl PolicyReadFilesAction { + pub(crate) fn new(paths: impl IntoIterator) -> Self { + let (paths, omitted_path_count) = + capped_policy_items(paths.into_iter().map(truncate_policy_path)); + Self { + paths, + omitted_path_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyWriteFilesAction { + pub paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omitted_path_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_stats: Option, +} + +impl PolicyWriteFilesAction { + pub(crate) fn new( + paths: impl IntoIterator, + diff_stats: Option, + ) -> Self { + let (paths, omitted_path_count) = + capped_policy_items(paths.into_iter().map(truncate_policy_path)); + Self { + paths, + omitted_path_count, + diff_stats, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyDiffStats { + pub files_changed: usize, + pub additions: usize, + pub deletions: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyCallMcpToolAction { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + pub tool_name: String, + pub argument_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omitted_argument_key_count: Option, +} + +impl PolicyCallMcpToolAction { + pub(crate) fn new( + server_id: Option, + tool_name: impl Into, + arguments: &serde_json::Value, + ) -> Self { + let (argument_keys, omitted_argument_key_count) = mcp_argument_keys(arguments); + let tool_name = tool_name.into(); + Self { + server_id, + tool_name: redact_sensitive_text_for_policy(&tool_name), + argument_keys, + omitted_argument_key_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PolicyReadMcpResourceAction { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uri: Option, +} + +impl PolicyReadMcpResourceAction { + pub(crate) fn new( + server_id: Option, + name: impl Into, + uri: Option, + ) -> Self { + let name = name.into(); + Self { + server_id, + name: redact_sensitive_text_for_policy(&name), + uri: uri.map(|uri| redact_sensitive_text_for_policy(&uri)), + } + } +} + +fn serialize_policy_path_option(path: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match path { + Some(path) => serializer.serialize_some(&redact_policy_path(path)), + None => serializer.serialize_none(), + } +} + +fn truncate_policy_path(path: PathBuf) -> PathBuf { + redact_policy_path(&path) +} + +pub(super) fn redact_policy_path(path: &Path) -> PathBuf { + let path_text = path.to_string_lossy(); + let redacted_path = redact_sensitive_text_for_policy(&path_text); + if redacted_path == path_text && path_text.len() <= super::redaction::MAX_POLICY_STRING_BYTES { + return path.to_path_buf(); + } + + PathBuf::from(redacted_path) +} diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs new file mode 100644 index 000000000..e01218f0f --- /dev/null +++ b/app/src/ai/policy_hooks/mod.rs @@ -0,0 +1,23 @@ +#[cfg(not(target_family = "wasm"))] +mod audit; +pub(crate) mod config; +pub(crate) mod decision; +#[cfg(not(target_family = "wasm"))] +pub(crate) mod engine; +pub(crate) mod event; +pub(crate) mod redaction; + +pub(crate) use config::AgentPolicyHookConfig; +pub(crate) use decision::{ + AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, WarpPermissionSnapshot, +}; +#[cfg(not(target_family = "wasm"))] +pub(crate) use engine::AgentPolicyHookEngine; +pub(crate) use event::{ + AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + PolicyWriteToLongRunningShellCommandAction, +}; + +#[cfg(test)] +mod tests; diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs new file mode 100644 index 000000000..a10fd1eab --- /dev/null +++ b/app/src/ai/policy_hooks/redaction.rs @@ -0,0 +1,189 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +pub(crate) const MAX_POLICY_STRING_BYTES: usize = 8 * 1024; +pub(crate) const MAX_POLICY_COLLECTION_ITEMS: usize = 256; + +static SECRET_ASSIGNMENT_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)\b([A-Z0-9_.-]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY)[A-Z0-9_.-]*)=("(?:[^"\\]|\\.)*"|"(?:[^;&|]*)|'(?:[^'\\]|\\.)*'|'(?:[^;&|]*)|[^\s;&|]+)"#, + ) + .expect("secret assignment regex should compile") +}); + +static AUTHORIZATION_BEARER_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)(authorization:\s*bearer\s+)([^\s"']+)"#) + .expect("authorization header regex should compile") +}); + +static AUTHORIZATION_BASIC_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)(authorization:\s*basic\s+)([A-Za-z0-9+/=._-]+)"#) + .expect("authorization basic regex should compile") +}); + +static CREDENTIAL_HEADER_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)(^|[\s;&|'"`/\\?#,=])([a-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)[a-z0-9_-]*\s*:\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|'"`\r\n]+|basic\s+[^\s;&|'"`\r\n]+|[^\s;&|'"`\r\n]+)"#, + ) + .expect("credential header regex should compile") +}); + +static URL_USERINFO_RE: Lazy = Lazy::new(|| { + Regex::new(r#"(?i)\b([a-z][a-z0-9+.-]*://)([^/\s"'<>@]+(?::[^/\s"'<>@]*)?@)"#) + .expect("URL userinfo regex should compile") +}); + +static CURL_BASIC_AUTH_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)(\bcurl\b[^;&|\n]*?\s(?:-u\s*|--user(?:=|\s+)|--proxy-user(?:=|\s+)))("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s;&|]+)"#, + ) + .expect("curl basic auth regex should compile") +}); + +static SPLIT_SECRET_ARG_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)(^|[\s;&|])(-{1,2}[a-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)[a-z0-9_-]*\b\s+)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + ) + .expect("split secret arg regex should compile") +}); + +static INLINE_SECRET_ARG_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)(^|[\s;&|])(-{1,2}[a-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)[a-z0-9_-]*\b=)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + ) + .expect("inline secret arg regex should compile") +}); + +static COMMON_TOKEN_RE: Lazy = Lazy::new(|| { + Regex::new(r"\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})\b") + .expect("common token regex should compile") +}); + +pub(crate) fn redact_command_for_policy(command: &str) -> String { + redact_sensitive_text_for_policy(command) +} + +pub(crate) fn redact_sensitive_text_for_policy(value: &str) -> String { + let value = redact_literal_sensitive_text_for_policy(value); + let value = redact_percent_decoded_sensitive_text_for_policy(&value); + truncate_for_policy(&value) +} + +fn redact_literal_sensitive_text_for_policy(value: &str) -> String { + let value = SECRET_ASSIGNMENT_RE.replace_all(value, "$1="); + let value = AUTHORIZATION_BEARER_RE.replace_all(&value, "$1"); + let value = AUTHORIZATION_BASIC_RE.replace_all(&value, "$1"); + let value = CREDENTIAL_HEADER_RE.replace_all(&value, redact_credential_header_match); + let value = CURL_BASIC_AUTH_RE.replace_all(&value, "$1"); + let value = URL_USERINFO_RE.replace_all(&value, "$1@"); + let value = INLINE_SECRET_ARG_RE.replace_all(&value, "$1$2"); + let value = SPLIT_SECRET_ARG_RE.replace_all(&value, "$1$2"); + let value = COMMON_TOKEN_RE.replace_all(&value, ""); + value.into_owned() +} + +fn redact_percent_decoded_sensitive_text_for_policy(value: &str) -> String { + let mut current = std::borrow::Cow::Borrowed(value); + let mut redacted = None; + for _ in 0..=4 { + let Ok(decoded) = urlencoding::decode(current.as_ref()) else { + break; + }; + if decoded == current { + break; + } + + let decoded_redacted = redact_literal_sensitive_text_for_policy(&decoded); + if decoded_redacted != decoded { + redacted = Some(decoded_redacted.clone()); + current = std::borrow::Cow::Owned(decoded_redacted); + } else { + current = std::borrow::Cow::Owned(decoded.into_owned()); + } + } + + redacted.unwrap_or_else(|| value.to_string()) +} + +fn redact_credential_header_match(captures: ®ex::Captures<'_>) -> String { + let matched = captures.get(0).map_or("", |capture| capture.as_str()); + let prefix = captures.get(1).map_or("", |capture| capture.as_str()); + let header = captures.get(2).map_or("", |capture| capture.as_str()); + let value = captures.get(3).map_or("", |capture| capture.as_str()); + + let header_name = header.trim_end().trim_end_matches(':').trim(); + let value_lower = value.to_ascii_lowercase(); + if header_name.eq_ignore_ascii_case("authorization") + && (value_lower == "bearer " || value_lower == "basic ") + { + return matched.to_string(); + } + + format!("{prefix}{header}") +} + +pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> (Vec, Option) { + let serde_json::Value::Object(map) = arguments else { + return (Vec::new(), None); + }; + + let mut keys = map + .keys() + .take(MAX_POLICY_COLLECTION_ITEMS) + .map(|key| redact_sensitive_text_for_policy(key)) + .collect::>(); + keys.sort(); + let omitted_count = map.len().saturating_sub(keys.len()); + (keys, (omitted_count > 0).then_some(omitted_count)) +} + +pub(crate) fn capped_policy_items( + items: impl IntoIterator, +) -> (Vec, Option) { + let mut capped = Vec::new(); + let mut total_count = 0usize; + for item in items { + if capped.len() < MAX_POLICY_COLLECTION_ITEMS { + capped.push(item); + } + total_count = total_count.saturating_add(1); + } + + let omitted_count = total_count.saturating_sub(capped.len()); + (capped, (omitted_count > 0).then_some(omitted_count)) +} + +#[allow(dead_code)] +pub(crate) fn redact_sensitive_json_shape(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Null => serde_json::Value::Null, + serde_json::Value::Bool(_) => serde_json::json!({ "type": "bool" }), + serde_json::Value::Number(_) => serde_json::json!({ "type": "number" }), + serde_json::Value::String(_) => serde_json::json!({ "type": "string" }), + serde_json::Value::Array(values) => serde_json::json!({ + "type": "array", + "length": values.len(), + }), + serde_json::Value::Object(map) => { + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + serde_json::json!({ + "type": "object", + "keys": keys, + }) + } + } +} + +pub(crate) fn truncate_for_policy(value: &str) -> String { + if value.len() <= MAX_POLICY_STRING_BYTES { + return value.to_string(); + } + + let mut end = MAX_POLICY_STRING_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + + format!("{}...[truncated]", &value[..end]) +} diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs new file mode 100644 index 000000000..281a69a75 --- /dev/null +++ b/app/src/ai/policy_hooks/tests.rs @@ -0,0 +1,2169 @@ +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; + +use crate::ai::execution_profiles::AIExecutionProfile; +use serde_json::json; + +use super::{ + config::{AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookTransport}, + decision::{ + compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyHookErrorKind, + AgentPolicyHookEvaluation, AgentPolicyHookResponse, AgentPolicyUnavailableDecision, + WarpPermissionSnapshot, + }, + event::{ + AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + AGENT_POLICY_SCHEMA_VERSION, + }, + redaction::{redact_command_for_policy, MAX_POLICY_COLLECTION_ITEMS}, +}; + +#[cfg(not(target_family = "wasm"))] +fn existing_secret_env_var() -> (&'static str, String) { + let name = "PATH"; + let value = std::env::var(name).expect("PATH must be set for policy hook tests"); + assert!(!value.is_empty()); + (name, value) +} + +#[cfg(not(target_family = "wasm"))] +use super::audit::audit_record_json_line; +#[cfg(not(target_family = "wasm"))] +use super::engine::AgentPolicyHookEngine; + +#[test] +fn config_defaults_to_disabled_and_ask_on_unavailable() { + let config = AgentPolicyHookConfig::default(); + + assert!(!config.enabled); + assert!(!config.is_active()); + assert_eq!(config.on_unavailable, AgentPolicyUnavailableDecision::Ask); + assert_eq!(config.timeout_ms, 5_000); + assert!(config.validate().is_ok()); +} + +#[test] +fn config_enabled_without_hooks_is_active_but_invalid() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [] + })) + .unwrap(); + + assert!(config.is_active()); + assert!(config.validate().is_err()); +} + +#[test] +fn config_empty_hook_list_is_not_autoapproval_capable() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "allow_hook_autoapproval": true, + "before_action": [] + })) + .unwrap(); + + assert!(!config.allow_autoapproval_for_all_hooks()); +} + +#[test] +fn config_nonempty_hook_list_can_be_autoapproval_capable() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "allow_hook_autoapproval": true, + "before_action": [{ + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "allow_autoapproval": true + }] + })) + .unwrap(); + + assert!(config.allow_autoapproval_for_all_hooks()); +} + +#[test] +fn config_per_hook_autoapproval_does_not_bypass_global_opt_in() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "allow_autoapproval": true + }] + })) + .unwrap(); + + assert!(!config.allow_autoapproval_for_all_hooks()); +} + +#[test] +fn config_global_autoapproval_does_not_bypass_per_hook_opt_in() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "allow_hook_autoapproval": true, + "before_action": [{ + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "allow_autoapproval": false + }] + })) + .unwrap(); + + assert!(!config.allow_autoapproval_for_all_hooks()); +} + +#[test] +fn config_deserializes_stdio_hook_shape() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "args": ["warp", "before-action"], + "timeout_ms": 2500, + "on_unavailable": "deny" + }] + })) + .unwrap(); + + assert!(config.is_active()); + assert_eq!(config.before_action[0].name, "company-agent-guard"); + assert_eq!(config.hook_timeout_ms(&config.before_action[0]), 2_500); + assert_eq!( + config.hook_unavailable_decision(&config.before_action[0]), + AgentPolicyUnavailableDecision::Deny + ); + assert!(config.validate().is_ok()); +} + +#[test] +fn config_global_unavailable_deny_cannot_be_relaxed_by_hook_allow() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "on_unavailable": "allow" + }] + })) + .unwrap(); + + assert_eq!( + config.hook_unavailable_decision(&config.before_action[0]), + AgentPolicyUnavailableDecision::Deny + ); +} + +#[test] +fn config_rejects_stdio_hook_credential_args() { + for args in [ + json!(["--token=secret"]), + json!(["--token", "secret"]), + json!(["--token", "%s"]), + json!(["--token", "%raw-secret"]), + json!(["--token", "prefix$API_TOKEN"]), + json!(["--api-key", "secret"]), + json!(["--client-secret", "secret"]), + json!(["--refresh-token", "secret"]), + json!(["--accessToken", "secret"]), + json!(["--authorization", "Bearer secret"]), + json!(["--authorization", "Bearer token$with-dollar"]), + json!(["API_KEY=secret"]), + json!(["clientSecret=secret"]), + json!(["API_KEY=secret$with-dollar"]), + json!(["X-API-Key:", "secret"]), + json!(["Authorization: Bearer secret"]), + json!(["Authorization: Bearer token$with-dollar"]), + json!(["Authorization:", "Bearer token$with-dollar"]), + json!(["-u", "user:pass"]), + json!(["--user", "alice:secret"]), + json!(["--proxy-user=proxy:secret"]), + json!(["--client-secret-key", "actual-client-secret"]), + json!(["--token-value", "actual-token"]), + json!(["--authorization-header", "Bearer raw-token"]), + json!(["-H", "X-Api-Key: abc123def456"]), + json!(["--header=X-Api-Key: abc123def456"]), + json!(["-c", "guard --token raw-secret"]), + json!(["-xc", "guard --token raw-secret"]), + json!([ + "-lc", + "curl -H 'X-Api-Key: abc123def456' https://example.com" + ]), + json!(["https://user:pass@example.com/policy"]), + json!(["https://example.com/policy?token=secret"]), + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "args": args + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::StdioArgContainsCredentials) + )); + + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("raw-secret")); + assert!(!value.to_string().contains("abc123def456")); + } +} + +#[test] +fn config_rejects_stdio_hook_credential_command() { + for command in [ + "guard --token secret", + "guard --token %s", + "guard --token %raw-secret", + "guard --authorization 'Bearer raw-token'", + "API_KEY=secret guard", + "guard sk-secretsecretsecret", + "guard ghp_secretsecretsecret", + "curl -u user:pass https://example.com", + "curl --user alice:secret https://example.com", + "curl --proxy-user=proxy:secret https://example.com", + "curl -H 'X-Api-Key: abc123def456' https://example.com", + "curl --header='X-Api-Key: abc123def456' https://example.com", + "sh -c 'guard --token raw-secret'", + "sh -xc 'guard --token raw-secret'", + "bash -euc \"guard --token raw-secret\"", + "guard --client-secret-key actual-client-secret", + "guard --token-value actual-token", + "guard --authorization-header Bearer raw-token", + "bash -lc \"curl -H 'X-Api-Key: abc123def456' https://example.com\"", + "curl https://user:pass@example.com/policy", + "curl 'https://example.com/policy?token=secret'", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": command + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::StdioCommandContainsCredentials) + )); + + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("secret")); + assert!(!value.to_string().contains("raw-token")); + assert!(!value.to_string().contains("raw-secret")); + assert!(!value.to_string().contains("abc123def456")); + } +} + +#[test] +fn config_allows_stdio_hook_secret_env_reference_args() { + for args in [ + json!(["--token", "$API_TOKEN"]), + json!(["--api-key=${POLICY_API_KEY}"]), + json!(["--authorization", "Bearer $POLICY_TOKEN"]), + json!(["--auth", "Basic ${POLICY_AUTH}"]), + json!(["Authorization: BEARER $HEADER_TOKEN"]), + json!(["X-API-Key:", "$HEADER_API_KEY"]), + json!(["Authorization:", "Bearer $HEADER_TOKEN"]), + json!(["-H", "X-Api-Key: $HEADER_API_KEY"]), + json!(["--header=Authorization: Bearer $HEADER_TOKEN"]), + json!(["-c", "guard --token $API_TOKEN"]), + json!(["-xc", "guard --token $API_TOKEN"]), + json!([ + "-lc", + "curl -H 'X-Api-Key: $HEADER_API_KEY' https://example.com" + ]), + json!(["https://example.com/policy?state=public-value"]), + ] { + let args_debug = args.clone(); + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "args": args + }] + })) + .unwrap(); + + let validation = config.validate(); + assert!( + validation.is_ok(), + "expected args {args_debug:?} to validate, got {validation:?}" + ); + } +} + +#[test] +fn config_allows_stdio_hook_secret_env_reference_command() { + for command in [ + "guard --token $API_TOKEN", + "curl -H 'X-Api-Key: $HEADER_API_KEY' https://example.com", + "curl --header='Authorization: Bearer $HEADER_TOKEN' https://example.com", + "sh -c 'guard --token $API_TOKEN'", + "sh -xc 'guard --token $API_TOKEN'", + "bash -euc \"guard --token $API_TOKEN\"", + "bash -lc \"curl -H 'X-Api-Key: $HEADER_API_KEY' https://example.com\"", + "curl 'https://example.com/policy?state=public-value'", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": command + }] + })) + .unwrap(); + + let validation = config.validate(); + assert!( + validation.is_ok(), + "expected command {command:?} to validate, got {validation:?}" + ); + } +} + +#[test] +fn config_rejects_non_https_remote_http_hooks() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": "http://example.com/policy" + }] + })) + .unwrap(); + + assert!(config.validate().is_err()); + + let localhost_config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "local-guard", + "transport": "http", + "url": "http://localhost:3030/policy" + }] + })) + .unwrap(); + assert!(localhost_config.validate().is_ok()); +} + +#[test] +fn config_rejects_http_hook_url_embedded_credentials() { + for url in [ + "https://token@example.com/policy", + "https://user:pass@example.com/policy", + "https://token@example .com/policy", + "https:user:pass@example.com/policy", + "https://example.com/policy?token=secret", + "https://example.com/policy?api_key=secret", + "https://example.com/policy?clientSecret=abc123", + "https://example.com/policy?clientSecretKey=abc123def4567890", + "https://example.com/policy?accessToken=abc123", + "https://example.com/policy?accessTokenValue=abc123def4567890", + "https://example.com/policy?refreshTokenId=abc123def4567890", + "https://example.com/policy?refresh-token=abc123", + "https://example.com/policy?q=sk-secretsecretsecret", + "https://example.com/policy?state=ghp_secretsecretsecret", + "https://example.com/policy?state=gho_secretsecretsecret", + "https://example.com/policy?state=ghu_secretsecretsecret", + "https://example.com/policy?state=ghs_secretsecretsecret", + "https://example.com/policy?state=ghr_secretsecretsecret", + "https://example.com/policy#access_token=secret", + "https://example.com/policy#accessTokenValue=abc123def4567890", + "https://example.com/policy#access_token%3Dsecret", + "https://example.com/policy#state=sk-secretsecretsecret", + "https://example.com/policy#state%3Dsk-secretsecretsecret", + "https://example.com/policy#Authorization%3A%20Bearer%20secret", + "https://example.com/policy?authorization=Bearer%20secret", + "https://example.com/hooks/sk-secretsecretsecret", + "https://example.com/hooks/Authorization%3A%20Bearer%20secret", + "https://example.com/policy?api%255Fkey=abc123def456", + "https://example.com/policy?api%252Dkey=abc123def456", + "https://example.com/hooks/%2525252574oken/abc123def4567890", + "ftp://user:pass@example.com/policy", + "custom://example.com/policy?token=secret", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": url + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::HttpUrlContainsCredentials) + )); + } +} + +#[test] +fn config_allows_http_hook_url_non_credential_query_values() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": "https://example.com/policy?q=skeleton&state=public-value#section" + }] + })) + .unwrap(); + + assert!(config.validate().is_ok()); +} + +#[test] +fn config_rejects_hook_name_and_stdio_working_directory_credentials() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "guard-sk-secretsecretsecret", + "transport": "stdio", + "command": "guard" + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::HookNameContainsCredentials) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("sk-secretsecretsecret")); + + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "working_directory": "/tmp/API_KEY=raw-secret-value" + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::StdioWorkingDirectoryContainsCredentials) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("raw-secret-value")); +} + +#[test] +fn config_rejects_disabled_http_hook_url_embedded_credentials() { + for url in [ + "https://token@example.com/policy", + "https://token@example .com/policy", + "https:user:pass@example.com/policy", + "https://example.com/policy?q=sk-secretsecretsecret", + "https://example .com/policy?q=sk-secretsecretsecret", + "https://example.com/hooks/sk-secretsecretsecret", + "https://example .com/policy?api%255Fkey=abc123def456", + "ftp://user:pass@example.com/policy", + "custom://example.com/policy?token=secret", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": false, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": url + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::HttpUrlContainsCredentials) + )); + } +} + +#[test] +fn profile_serialization_sanitizes_disabled_http_hook_url_embedded_credentials() { + for url in [ + "https:user:pass@example.com/policy", + "https://example .com/policy?q=sk-secretsecretsecret", + "https://example.com/hooks/sk-secretsecretsecret", + "ftp://user:pass@example.com/policy", + ] { + let agent_policy_hooks = AgentPolicyHookConfig { + enabled: false, + before_action: vec![AgentPolicyHook { + name: "remote-guard".to_string(), + transport: AgentPolicyHookTransport::Http { + url: url.to_string(), + headers: Default::default(), + }, + ..Default::default() + }], + ..Default::default() + }; + let profile = AIExecutionProfile { + agent_policy_hooks, + ..Default::default() + }; + + let value = serde_json::to_value(&profile).unwrap(); + assert_eq!(value["agent_policy_hooks"]["enabled"], false); + assert!(value["agent_policy_hooks"]["before_action"] + .as_array() + .unwrap() + .is_empty()); + assert!(!value.to_string().contains('@')); + assert!(!value.to_string().contains("sk-secretsecretsecret")); + } +} + +#[test] +fn profile_serialization_sanitizes_invalid_http_hook_urls() { + for url in [ + "ssh://internal-host/policy", + "http://example.com/policy", + "https://exa mple.com/policy", + ] { + let agent_policy_hooks = AgentPolicyHookConfig { + enabled: true, + before_action: vec![AgentPolicyHook { + name: "remote-guard".to_string(), + transport: AgentPolicyHookTransport::Http { + url: url.to_string(), + headers: Default::default(), + }, + ..Default::default() + }], + ..Default::default() + }; + let profile = AIExecutionProfile { + agent_policy_hooks, + ..Default::default() + }; + + let value = serde_json::to_value(&profile).unwrap(); + assert_eq!(value["agent_policy_hooks"]["enabled"], false); + assert!(value["agent_policy_hooks"]["before_action"] + .as_array() + .unwrap() + .is_empty()); + assert!(!value.to_string().contains(url)); + } +} + +#[test] +fn config_allows_disabled_incomplete_hook_without_persisted_credentials() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": false, + "before_action": [{ + "transport": "stdio", + "command": "" + }] + })) + .unwrap(); + + assert!(config.validate().is_ok()); + assert!(serde_json::to_value(&config).is_ok()); +} + +#[test] +fn config_rejects_inline_hook_secret_values() { + let config = serde_json::from_value::(json!({ + "enabled": true, + "before_action": [ + { + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "env": { "API_TOKEN": "super-secret-token" } + }, + { + "name": "http-guard", + "transport": "http", + "url": "https://example.com/policy", + "headers": { "authorization": "Bearer super-secret-token" } + } + ] + })); + + assert!(config.is_err()); +} + +#[test] +fn config_rejects_object_shaped_hook_secret_literals() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [ + { + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "env": { "API_TOKEN": { "env": "sk-secretsecretsecret" } } + }, + { + "name": "http-guard", + "transport": "http", + "url": "https://example.com/policy", + "headers": { "authorization": { "env": "Bearer raw-secret" } } + } + ] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("sk-secretsecretsecret")); + assert!(!value.to_string().contains("Bearer raw-secret")); +} + +#[test] +fn config_rejects_hook_secret_map_literal_keys() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [ + { + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "env": { "ghp_secretsecretsecret": { "env": "POLICY_TOKEN" } } + }, + { + "name": "http-guard", + "transport": "http", + "url": "https://example.com/policy", + "headers": { "sk-secretsecretsecret": { "env": "POLICY_HEADER" } } + } + ] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("ghp_secretsecretsecret")); + assert!(!value.to_string().contains("sk-secretsecretsecret")); +} + +#[test] +fn config_rejects_http_hook_secret_header_literal_key() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": "https://example.com/policy", + "headers": { "sk-secretsecretsecret": { "env": "POLICY_HEADER" } } + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::InvalidHttpHeaderName(_)) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains("sk-secretsecretsecret")); +} + +#[test] +fn config_rejects_whitespace_padded_hook_secret_refs() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "env": { "API_TOKEN": { "env": " POLICY_TOKEN " } } + }] + })) + .unwrap(); + + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName) + )); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!(value["enabled"], false); + assert!(!value.to_string().contains(" POLICY_TOKEN ")); +} + +#[test] +fn config_serialization_preserves_secret_environment_references() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [ + { + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "env": { "API_TOKEN": { "env": "WARP_POLICY_HOOK_TOKEN" } } + }, + { + "name": "http-guard", + "transport": "http", + "url": "https://example.com/policy", + "headers": { "authorization": { "env": "WARP_POLICY_HOOK_AUTH_HEADER" } } + } + ] + })) + .unwrap(); + + let value = serde_json::to_value(&config).unwrap(); + assert_eq!( + value["before_action"][0]["env"]["API_TOKEN"]["env"], + "WARP_POLICY_HOOK_TOKEN" + ); + assert_eq!( + value["before_action"][1]["headers"]["authorization"]["env"], + "WARP_POLICY_HOOK_AUTH_HEADER" + ); + + let round_trip: AgentPolicyHookConfig = serde_json::from_value(value).unwrap(); + assert_eq!(round_trip, config); +} + +#[test] +fn event_serializes_redacted_command_shape() { + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + Some(PathBuf::from("/repo")), + true, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(Some("RunToCompletion".to_string())), + PolicyExecuteCommandAction::new( + "OPENAI_API_KEY=sk-secretsecretsecret curl -H 'Authorization: Bearer token123' https://example.com", + "OPENAI_API_KEY=sk-secretsecretsecret curl https://example.com", + Some(false), + Some(true), + ), + ); + + let value = serde_json::to_value(event).unwrap(); + assert_eq!(value["schema_version"], AGENT_POLICY_SCHEMA_VERSION); + assert_eq!(value["action_kind"], "execute_command"); + assert_eq!(value["run_until_completion"], true); + assert_eq!(value["hook_autoapproval_enabled"], false); + assert_eq!(value["warp_permission"]["decision"], "allow"); + + let command = value["action"]["command"].as_str().unwrap(); + assert!(command.contains("OPENAI_API_KEY=")); + assert!(command.contains("Authorization: Bearer ")); + assert!(!command.contains("sk-secretsecretsecret")); + assert_eq!(value["action"]["is_risky"], true); +} + +#[test] +fn event_serializes_hook_autoapproval_state() { + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + Some(PathBuf::from( + "/repo/sk-secretsecretsecret/clientSecret=raw-secret-value", + )), + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ) + .with_hook_autoapproval_enabled(true); + + let value = serde_json::to_value(event).unwrap(); + assert_eq!(value["hook_autoapproval_enabled"], true); +} + +#[test] +fn policy_event_redacts_working_directory_before_serialization() { + let raw_path = PathBuf::from("/tmp/sk-secretsecretsecret/clientSecret=raw-secret-value"); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + Some(raw_path.clone()), + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + assert_eq!(event.working_directory.as_deref(), Some(raw_path.as_path())); + let value = serde_json::to_string(&event).unwrap(); + assert!(value.contains("")); + assert!(!value.contains("sk-secretsecretsecret")); + assert!(!value.contains("raw-secret-value")); +} + +#[test] +fn command_redaction_handles_quoted_secret_assignments() { + let command = concat!( + "OPENAI_API_KEY=\"sk-secret value\" ", + "GITHUB_TOKEN='ghp_secret value' ", + "ACCESS_KEY=\"escaped \\\" secret\" curl https://example.com", + ); + let unterminated = "PASSWORD=\"unterminated secret curl https://example.com"; + + let redacted = redact_command_for_policy(command); + let redacted_unterminated = redact_command_for_policy(unterminated); + + assert!(redacted.contains("OPENAI_API_KEY=")); + assert!(redacted.contains("GITHUB_TOKEN=")); + assert!(redacted.contains("ACCESS_KEY=")); + assert!(!redacted.contains("sk-secret")); + assert!(!redacted.contains("ghp_secret")); + assert!(!redacted.contains("escaped")); + assert!(redacted_unterminated.contains("PASSWORD=")); + assert!(!redacted_unterminated.contains("unterminated")); +} + +#[test] +fn command_redaction_handles_url_userinfo_and_basic_auth() { + let command = concat!( + "curl -u user:pass https://user:pass@example.com/api ", + "-H 'Authorization: Basic dXNlcjpwYXNz' && ", + "curl --user='alice:secret value' https://token@example.org" + ); + + let redacted = redact_command_for_policy(command); + + assert!(redacted.contains("curl -u ")); + assert!(redacted.contains("Authorization: Basic ")); + assert!(redacted.contains("https://@example.com/api")); + assert!(redacted.contains("https://@example.org")); + assert!(!redacted.contains("user:pass")); + assert!(!redacted.contains("alice:secret")); + assert!(!redacted.contains("dXNlcjpwYXNz")); + assert!(!redacted.contains("token@example")); +} + +#[test] +fn command_redaction_handles_generic_credential_headers() { + let command = concat!( + "curl -H 'X-API-Key: abc123def456' ", + "-H 'Client-Secret: client-secret-value' ", + "-H 'X-Access-Token: Bearer raw-access-token' ", + "-H 'Authorization: raw-auth-token' ", + "-H 'X-Auth: raw-auth-secret' ", + "https://example.com" + ); + + let redacted = redact_command_for_policy(command); + + assert!(redacted.contains("X-API-Key: ")); + assert!(redacted.contains("Client-Secret: ")); + assert!(redacted.contains("X-Access-Token: ")); + assert!(redacted.contains("Authorization: ")); + assert!(redacted.contains("X-Auth: ")); + assert!(!redacted.contains("abc123def456")); + assert!(!redacted.contains("client-secret-value")); + assert!(!redacted.contains("raw-access-token")); + assert!(!redacted.contains("raw-auth-token")); + assert!(!redacted.contains("raw-auth-secret")); +} + +#[test] +fn command_redaction_handles_split_secret_args() { + let command = concat!( + "guard --token token-secret --password 'quoted secret' ", + "--api-key sk-secretsecretsecret --authorization Bearer split-secret ", + "--authorization=Bearer eq-secret --auth Basic basic-secret ", + "--client-secret client-secret-value --refresh-token refresh-secret ", + "--access-token access-secret --clientSecret=camel-secret ", + "--client-secret-key client-key-secret --token-value token-value-secret ", + "--authorization-header Bearer header-secret ", + "--safe visible" + ); + + let redacted = redact_command_for_policy(command); + + assert!(redacted.contains("--token ")); + assert!(redacted.contains("--password ")); + assert!(redacted.contains("--api-key ")); + assert!(redacted.contains("--authorization ")); + assert!(redacted.contains("--authorization=")); + assert!(redacted.contains("--auth ")); + assert!(redacted.contains("--client-secret ")); + assert!(redacted.contains("--refresh-token ")); + assert!(redacted.contains("--access-token ")); + assert!(redacted.contains("--clientSecret=")); + assert!(redacted.contains("--client-secret-key ")); + assert!(redacted.contains("--token-value ")); + assert!(redacted.contains("--authorization-header ")); + assert!(redacted.contains("--safe visible")); + assert!(!redacted.contains("token-secret")); + assert!(!redacted.contains("quoted secret")); + assert!(!redacted.contains("sk-secretsecretsecret")); + assert!(!redacted.contains("split-secret")); + assert!(!redacted.contains("eq-secret")); + assert!(!redacted.contains("basic-secret")); + assert!(!redacted.contains("client-secret-value")); + assert!(!redacted.contains("refresh-secret")); + assert!(!redacted.contains("access-secret")); + assert!(!redacted.contains("camel-secret")); + assert!(!redacted.contains("client-key-secret")); + assert!(!redacted.contains("token-value-secret")); + assert!(!redacted.contains("header-secret")); +} + +#[test] +fn mcp_tool_action_preserves_only_argument_keys() { + let action = PolicyCallMcpToolAction::new( + None, + "dangerous_tool", + &json!({ + "token": "secret", + "path": "/repo", + "count": 3 + }), + ); + + assert_eq!(action.argument_keys, vec!["count", "path", "token"]); + assert_eq!(action.omitted_argument_key_count, None); +} + +#[test] +fn mcp_tool_action_redacts_secret_shaped_argument_keys() { + let action = PolicyCallMcpToolAction::new( + None, + "tool-sk-secretsecretsecret", + &json!({ + "Authorization: Bearer rawbearer": "omitted", + "sk-secretsecretsecret": "omitted", + "path": "/repo" + }), + ); + + assert_eq!(action.tool_name, "tool-"); + assert_eq!( + action.argument_keys, + vec!["", "Authorization: Bearer ", "path"] + ); +} + +#[test] +fn mcp_resource_action_redacts_secret_shaped_uri() { + let action = PolicyReadMcpResourceAction::new( + None, + "resource-ghp_secretsecretsecret", + Some( + "mcp://user:secret@example/resource?api_key=raw-key&state=sk-secretsecretsecret" + .to_string(), + ), + ); + + assert_eq!(action.name, "resource-"); + let uri = action.uri.as_deref().unwrap(); + assert!(uri.contains("mcp://@example/resource")); + assert!(uri.contains("api_key=")); + assert!(!uri.contains("raw-key")); + assert!(!uri.contains("sk-secretsecretsecret")); +} + +#[test] +fn policy_action_collections_are_capped() { + let paths = (0..MAX_POLICY_COLLECTION_ITEMS + 3) + .map(|index| PathBuf::from(format!("/tmp/policy-path-{index}"))); + let action = PolicyReadFilesAction::new(paths); + + assert_eq!(action.paths.len(), MAX_POLICY_COLLECTION_ITEMS); + assert_eq!(action.omitted_path_count, Some(3)); + + let mut arguments = serde_json::Map::new(); + for index in 0..MAX_POLICY_COLLECTION_ITEMS + 2 { + arguments.insert(format!("key_{index:03}"), json!(index)); + } + let action = PolicyCallMcpToolAction::new(None, "tool", &serde_json::Value::Object(arguments)); + + assert_eq!(action.argument_keys.len(), MAX_POLICY_COLLECTION_ITEMS); + assert_eq!(action.omitted_argument_key_count, Some(2)); +} + +#[test] +fn policy_file_paths_are_redacted_before_serialization() { + let read = PolicyReadFilesAction::new([ + PathBuf::from("/tmp/sk-secretsecretsecret/report.md"), + PathBuf::from("/tmp/clientSecret=raw-secret-value/config.md"), + PathBuf::from("/tmp/X-API-Key: abc123def456/config.md"), + ]); + let write = PolicyWriteFilesAction::new( + [PathBuf::from( + "/tmp/Authorization: Bearer raw-path-token/output.md", + )], + None, + ); + + let value = serde_json::to_string(&json!({ + "read": read, + "write": write, + })) + .unwrap(); + + assert!(value.contains("")); + assert!(!value.contains("sk-secretsecretsecret")); + assert!(!value.contains("raw-secret-value")); + assert!(!value.contains("abc123def456")); + assert!(!value.contains("raw-path-token")); +} + +#[test] +fn mcp_resource_uri_redacts_generic_and_percent_encoded_credentials() { + let resource = PolicyReadMcpResourceAction::new( + None, + "resource", + Some( + "mcp://srv/resource/X-API-Key: abc123def456?api_key%3Draw-key#Authorization%3A%20Bearer%20raw-token" + .to_string(), + ), + ); + + let value = serde_json::to_string(&resource).unwrap(); + assert!(value.contains("")); + assert!(!value.contains("abc123def456")); + assert!(!value.contains("raw-key")); + assert!(!value.contains("raw-token")); +} + +#[test] +fn policy_decision_composition_is_conservative() { + let hook_allow = AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("trusted".to_string()), + external_audit_id: None, + error: None, + }; + + let needs_confirmation = compose_policy_decisions( + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + vec![hook_allow.clone()], + false, + ); + assert_eq!(needs_confirmation.decision, AgentPolicyDecisionKind::Ask); + assert_eq!(needs_confirmation.reason.as_deref(), Some("AlwaysAsk")); + + let autoapproved = compose_policy_decisions( + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + vec![hook_allow], + true, + ); + assert_eq!(autoapproved.decision, AgentPolicyDecisionKind::Allow); + assert_eq!(autoapproved.reason.as_deref(), Some("trusted")); +} + +#[test] +fn policy_decision_composition_does_not_autoapprove_unavailable_allow() { + let unavailable_allow = AgentPolicyHookEvaluation::unavailable( + "guard", + AgentPolicyDecisionKind::Allow, + AgentPolicyHookErrorKind::Timeout, + "hook timed out", + ); + + let decision = compose_policy_decisions( + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + vec![unavailable_allow], + true, + ); + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Ask); + assert_eq!(decision.reason.as_deref(), Some("AlwaysAsk")); +} + +#[test] +fn policy_decision_composition_keeps_denials_terminal() { + let hook_deny = AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some("blocked".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }; + + let denied_by_hook = + compose_policy_decisions(WarpPermissionSnapshot::allow(None), vec![hook_deny], false); + assert_eq!(denied_by_hook.decision, AgentPolicyDecisionKind::Deny); + assert_eq!(denied_by_hook.reason.as_deref(), Some("blocked")); + + let warp_denied = compose_policy_decisions( + WarpPermissionSnapshot::deny(Some("protected path".to_string())), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Allow, + reason: Some("external allow".to_string()), + external_audit_id: None, + error: None, + }], + true, + ); + assert_eq!(warp_denied.decision, AgentPolicyDecisionKind::Deny); + assert_eq!(warp_denied.reason.as_deref(), Some("protected path")); +} + +#[test] +fn hook_response_strings_are_redacted_and_capped() { + let evaluation = AgentPolicyHookEvaluation::from_response( + "guard", + AgentPolicyHookResponse { + schema_version: AGENT_POLICY_SCHEMA_VERSION.to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some(format!( + "OPENAI_API_KEY=sk-secretsecretsecret X-API-Key: abc123def456 {}", + "x".repeat(10_000) + )), + external_audit_id: Some("audit-ghp_secretsecretsecret".to_string()), + }, + ); + + let reason = evaluation.reason.as_deref().unwrap(); + assert!(reason.contains("OPENAI_API_KEY=")); + assert!(reason.contains("X-API-Key: ")); + assert!(!reason.contains("sk-secretsecretsecret")); + assert!(!reason.contains("abc123def456")); + assert!(reason.len() < 8_300); + assert_eq!( + evaluation.external_audit_id.as_deref(), + Some("audit-") + ); +} + +#[cfg(not(target_family = "wasm"))] +#[test] +fn audit_record_uses_redacted_policy_event_payload() { + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + Some(PathBuf::from("/repo")), + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new( + "GITHUB_TOKEN=ghp_secretsecretsecret curl -H 'Authorization: Bearer token123' https://example.com", + "GITHUB_TOKEN=ghp_secretsecretsecret curl https://example.com", + Some(false), + Some(true), + ), + ); + let decision = compose_policy_decisions( + WarpPermissionSnapshot::allow(None), + vec![AgentPolicyHookEvaluation { + hook_name: "guard".to_string(), + decision: AgentPolicyDecisionKind::Deny, + reason: Some("blocked".to_string()), + external_audit_id: Some("audit_1".to_string()), + error: None, + }], + false, + ); + + let line = audit_record_json_line(&event, &decision).unwrap(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + + assert_eq!(value["action_kind"], "execute_command"); + assert_eq!(value["effective_decision"]["decision"], "deny"); + assert_eq!(value["redaction"]["command_secrets_redacted"], true); + assert!(line.contains("GITHUB_TOKEN=")); + assert!(line.contains("Authorization: Bearer ")); + assert!(!line.contains("ghp_secretsecretsecret")); + assert!(!line.contains("sk-secretsecretsecret")); + assert!(!line.contains("raw-secret-value")); + assert!(!line.contains("token123")); +} + +#[cfg(not(target_family = "wasm"))] +#[test] +fn policy_event_new_redacts_execute_command_payload() { + let event = AgentPolicyEvent::new( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + AgentPolicyAction::ExecuteCommand(PolicyExecuteCommandAction::new( + "OPENAI_API_KEY=sk-secretsecretsecret curl https://example.com", + "OPENAI_API_KEY=sk-secretsecretsecret curl https://example.com", + Some(false), + Some(true), + )), + ); + let line = audit_record_json_line( + &event, + &compose_policy_decisions(WarpPermissionSnapshot::allow(None), Vec::new(), false), + ) + .unwrap(); + + assert!(line.contains("OPENAI_API_KEY=")); + assert!(!line.contains("sk-secretsecretsecret")); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_can_deny_before_action() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "local-guard", + "transport": "stdio", + "command": "sh", + "args": [ + "-c", + "cat >/dev/null; printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"deny\",\"reason\":\"blocked by test\",\"external_audit_id\":\"audit_789\"}'" + ], + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!(decision.reason.as_deref(), Some("blocked by test")); + assert_eq!(decision.hook_results[0].hook_name, "local-guard"); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit_789") + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_requires_global_and_hook_autoapproval_for_warp_ask() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "local-guard", + "transport": "stdio", + "command": "sh", + "args": [ + "-c", + "cat >/dev/null; printf '%s\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"allow\",\"reason\":\"approved by test\"}'" + ], + "allow_autoapproval": true, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let global_disabled = AgentPolicyHookEngine::new(config.clone()) + .preflight( + event.clone(), + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + ) + .await; + assert_eq!(global_disabled.decision, AgentPolicyDecisionKind::Ask); + assert_eq!(global_disabled.reason.as_deref(), Some("AlwaysAsk")); + + let mut global_enabled_config = config; + global_enabled_config.allow_hook_autoapproval = true; + let global_enabled = AgentPolicyHookEngine::new(global_enabled_config) + .preflight( + event, + WarpPermissionSnapshot::ask(Some("AlwaysAsk".to_string())), + ) + .await; + assert_eq!(global_enabled.decision, AgentPolicyDecisionKind::Allow); + assert_eq!(global_enabled.reason.as_deref(), Some("approved by test")); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_maps_malformed_response_to_unavailable_policy() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "bad-guard", + "transport": "stdio", + "command": "sh", + "args": ["-c", "cat >/dev/null; printf nope"], + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::MalformedResponse) + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_rejects_oversized_stdout() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "noisy-guard", + "transport": "stdio", + "command": "sh", + "args": ["-c", "cat >/dev/null; dd if=/dev/zero bs=70000 count=1 2>/dev/null"], + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::MalformedResponse) + ); + assert!(decision.hook_results[0] + .reason + .as_deref() + .unwrap() + .contains("stdout exceeded")); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_times_out_blocked_stdin_write() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "blocked-stdin-guard", + "transport": "stdio", + "command": "sleep", + "args": ["5"], + "timeout_ms": 100 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let suffix = "x".repeat(120); + let paths = (0..650) + .map(|index| PathBuf::from(format!("/tmp/policy-hook-large-event-{index}-{suffix}"))) + .collect(); + let event = AgentPolicyEvent::new( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + AgentPolicyAction::ReadFiles(PolicyReadFilesAction { + paths, + omitted_path_count: None, + }), + ); + + let started = Instant::now(); + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::Timeout) + ); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_rejects_oversized_policy_event_before_request() { + let server = mockito::Server::new_async().await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::new( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + AgentPolicyAction::ReadFiles(PolicyReadFilesAction { + paths: vec![PathBuf::from(format!("/tmp/{}", "x".repeat(200_000)))], + omitted_path_count: None, + }), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::PayloadTooLarge) + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_does_not_inherit_parent_environment() { + const PARENT_ONLY_ENV: &str = "WARP_POLICY_HOOK_TEST_PARENT_ENV_SENTINEL"; + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + let _env_guard = EnvGuard { + key: PARENT_ONLY_ENV, + previous: std::env::var_os(PARENT_ONLY_ENV), + }; + std::env::set_var(PARENT_ONLY_ENV, "parent-only"); + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "env-isolated-guard", + "transport": "stdio", + "command": "/bin/sh", + "args": [ + "-c", + "cat >/dev/null; if [ \"${WARP_POLICY_HOOK_TEST_PARENT_ENV_SENTINEL+x}\" = x ]; then printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"deny\",\"reason\":\"inherited parent sentinel\"}'; else printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"allow\"}'; fi" + ], + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Allow); + assert_eq!( + decision.hook_results[0].decision, + AgentPolicyDecisionKind::Allow + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_redacts_configured_secret_stderr() { + let (secret_env, secret_value) = existing_secret_env_var(); + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "failing-guard", + "transport": "stdio", + "command": "sh", + "args": ["-c", "cat >/dev/null; printf '%s\\n' \"$API_TOKEN\" >&2; exit 42"], + "env": { "API_TOKEN": { "env": secret_env } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::NonZeroExit) + ); + assert!(reason.contains("")); + assert!(!reason.contains(&secret_value)); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_redacts_configured_secret_hook_reason() { + let (secret_env, secret_value) = existing_secret_env_var(); + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "secret-reason-guard", + "transport": "stdio", + "command": "sh", + "args": [ + "-c", + "cat >/dev/null; printf '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"deny\",\"reason\":\"token: %s\",\"external_audit_id\":\"audit-%s\"}\\n' \"$API_TOKEN\" \"$API_TOKEN\"" + ], + "env": { "API_TOKEN": { "env": secret_env } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + assert!(reason.contains("")); + assert!(!reason.contains(&secret_value)); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit-") + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_redacts_configured_secret_malformed_response_error() { + const SECRET_ENV: &str = "WARP_POLICY_HOOK_TEST_MALFORMED_STDIO_SECRET"; + const SECRET_VALUE: &str = "sk-malformedstdiosecret"; + + struct EnvGuard; + impl Drop for EnvGuard { + fn drop(&mut self) { + std::env::remove_var(SECRET_ENV); + } + } + + let _guard = EnvGuard; + std::env::set_var(SECRET_ENV, SECRET_VALUE); + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "secret-malformed-guard", + "transport": "stdio", + "command": "sh", + "args": [ + "-c", + "cat >/dev/null; printf '{\"schema_version\":\"%s\",\"decision\":\"allow\"}\\n' \"$API_TOKEN\"" + ], + "env": { "API_TOKEN": { "env": SECRET_ENV } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + let audit_line = audit_record_json_line( + &AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ), + &decision, + ) + .unwrap(); + + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::MalformedResponse) + ); + assert!(!reason.contains(SECRET_VALUE)); + assert!(!audit_line.contains(SECRET_VALUE)); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_can_deny_before_action() { + let mut server = mockito::Server::new_async().await; + let hook_response = json!({ + "schema_version": AGENT_POLICY_SCHEMA_VERSION, + "decision": "deny", + "reason": "blocked by HTTP test", + "external_audit_id": "audit_http_1" + }) + .to_string(); + let mock = server + .mock("POST", "/policy") + .match_header("content-type", "application/json") + .match_header("x-warp-agent-policy-event-id", mockito::Matcher::Any) + .with_status(200) + .with_body(hook_response) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + mock.assert_async().await; + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!(decision.reason.as_deref(), Some("blocked by HTTP test")); + assert_eq!(decision.hook_results[0].hook_name, "http-guard"); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit_http_1") + ); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_rejects_oversized_response_body() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/policy") + .with_status(200) + .with_body(vec![b'x'; 70_000]) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + mock.assert_async().await; + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::MalformedResponse) + ); + assert!(decision.hook_results[0] + .reason + .as_deref() + .unwrap() + .contains("response exceeded")); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_uses_single_timeout_for_request_and_response_body() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/policy", listener.local_addr().unwrap()); + tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut request = [0_u8; 2048]; + let _ = socket.read(&mut request).await; + + tokio::time::sleep(Duration::from_millis(80)).await; + let body = br#"{"schema_version":"warp.agent_policy_hook.v1","decision":"allow"}"#; + let headers = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n", + body.len() + ); + let _ = socket.write_all(headers.as_bytes()).await; + let _ = socket.flush().await; + + tokio::time::sleep(Duration::from_millis(80)).await; + let _ = socket.write_all(body).await; + }); + + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "slow-http-guard", + "transport": "http", + "url": url, + "timeout_ms": 120 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::Timeout) + ); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_does_not_follow_redirects() { + let mut server = mockito::Server::new_async().await; + let (secret_env, _) = existing_secret_env_var(); + let redirect_location = format!("{}/redirected", server.url()); + let mock = server + .mock("POST", "/policy") + .with_status(307) + .with_header("location", redirect_location.as_str()) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "headers": { "authorization": { "env": secret_env } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + mock.assert_async().await; + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::HttpStatus) + ); + assert!(decision.hook_results[0] + .reason + .as_deref() + .unwrap() + .contains("307")); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_redacts_configured_header_secret_hook_reason() { + let mut server = mockito::Server::new_async().await; + let (secret_env, secret_value) = existing_secret_env_var(); + let hook_response = json!({ + "schema_version": AGENT_POLICY_SCHEMA_VERSION, + "decision": "deny", + "reason": format!("raw token {secret_value}"), + "external_audit_id": format!("audit-{secret_value}") + }) + .to_string(); + let mock = server + .mock("POST", "/policy") + .match_header("authorization", secret_value.as_str()) + .with_status(200) + .with_body(hook_response) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "headers": { "authorization": { "env": secret_env } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + mock.assert_async().await; + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + assert!(reason.contains("")); + assert!(!reason.contains(&secret_value)); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit-") + ); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_redacts_configured_header_secret_malformed_response_error() { + let mut server = mockito::Server::new_async().await; + const SECRET_ENV: &str = "WARP_POLICY_HOOK_TEST_MALFORMED_HTTP_SECRET"; + const SECRET_VALUE: &str = "Bearer malformedhttpsecret"; + + struct EnvGuard; + impl Drop for EnvGuard { + fn drop(&mut self) { + std::env::remove_var(SECRET_ENV); + } + } + + let _guard = EnvGuard; + std::env::set_var(SECRET_ENV, SECRET_VALUE); + let hook_response = json!({ + "schema_version": SECRET_VALUE, + "decision": "allow" + }) + .to_string(); + let mock = server + .mock("POST", "/policy") + .match_header("authorization", SECRET_VALUE) + .with_status(200) + .with_body(hook_response) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "headers": { "authorization": { "env": SECRET_ENV } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + let audit_line = audit_record_json_line( + &AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ), + &decision, + ) + .unwrap(); + + mock.assert_async().await; + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::MalformedResponse) + ); + assert!(!reason.contains(SECRET_VALUE)); + assert!(!audit_line.contains(SECRET_VALUE)); +} + +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_redacts_basic_header_credential_fragment_hook_reason() { + let mut server = mockito::Server::new_async().await; + let secret_env = "WARP_POLICY_HOOK_TEST_BASIC_AUTH"; + let credential = "dXNlcjpwYXNz"; + let secret_value = format!("Basic {credential}"); + std::env::set_var(secret_env, &secret_value); + let hook_response = json!({ + "schema_version": AGENT_POLICY_SCHEMA_VERSION, + "decision": "deny", + "reason": format!("credential fragment {credential}"), + "external_audit_id": format!("audit-{credential}") + }) + .to_string(); + let mock = server + .mock("POST", "/policy") + .match_header("authorization", secret_value.as_str()) + .with_status(200) + .with_body(hook_response) + .create_async() + .await; + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "http-guard", + "transport": "http", + "url": format!("{}/policy", server.url()), + "headers": { "authorization": { "env": secret_env } }, + "timeout_ms": 1000 + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("rm -rf .", "rm -rf .", Some(false), Some(true)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + mock.assert_async().await; + let reason = decision.hook_results[0].reason.as_deref().unwrap(); + assert_eq!(reason, "credential fragment "); + assert!(!reason.contains(credential)); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit-") + ); + std::env::remove_var(secret_env); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn engine_maps_enabled_empty_config_to_unavailable_policy() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::InvalidConfiguration) + ); +} + +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn engine_maps_invalid_enabled_config_to_unavailable_policy() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "on_unavailable": "deny", + "before_action": [{ + "name": "missing-command", + "transport": "stdio", + "command": "" + }] + })) + .unwrap(); + let engine = AgentPolicyHookEngine::new(config); + let event = AgentPolicyEvent::execute_command( + "conv_123", + "action_456", + None, + false, + None, + WarpPermissionSnapshot::allow(None), + PolicyExecuteCommandAction::new("ls", "ls", Some(true), Some(false)), + ); + + let decision = engine + .preflight(event, WarpPermissionSnapshot::allow(None)) + .await; + + assert_eq!(decision.decision, AgentPolicyDecisionKind::Deny); + assert_eq!( + decision.hook_results[0].error, + Some(AgentPolicyHookErrorKind::InvalidConfiguration) + ); +} diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index ec89cdb57..33b39d4c4 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -86,6 +86,23 @@ impl TryFrom for api::request::input::tool_call_resu ), ) } + RequestCommandOutputResult::PolicyDenied { command, reason } => { + // The current MAA schema only has a denylisted-command PermissionDenied reason. + // Leave it unset so host policy denials are not mislabeled. + #[allow(deprecated)] + Ok( + api::request::input::tool_call_result::Result::RunShellCommand( + api::RunShellCommandResult { + command, + output: encode_command_policy_denied_message(&reason), + exit_code: Default::default(), + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }, + ), + ) + } } } } @@ -127,6 +144,21 @@ impl TryFrom ), WriteToLongRunningShellCommandResult::Cancelled => Err(ConvertToAPITypeError::Ignore), + WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { + Ok(api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID.to_string(), + output: format!("{WRITE_TO_SHELL_POLICY_DENIED_PREFIX}{reason}"), + exit_code: WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, + }, + ), + ), + }, + )) + } WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound) => { Ok(api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand( api::WriteToLongRunningShellCommandResult { @@ -285,6 +317,17 @@ impl TryFrom for api::request::input::tool_call_result:: }, ), ), + RequestFileEditsResult::PolicyDenied { reason } => Ok( + api::request::input::tool_call_result::Result::ApplyFileDiffs( + api::ApplyFileDiffsResult { + result: Some(api::apply_file_diffs_result::Result::Error( + api::apply_file_diffs_result::Error { + message: encode_file_edits_policy_denied_message(&reason), + }, + )), + }, + ), + ), RequestFileEditsResult::Cancelled => Err(ConvertToAPITypeError::Ignore), } } diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 6adcbdda8..ef2bb56d5 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -28,3 +28,84 @@ fn ask_user_question_skipped_by_auto_approve_converts_to_skipped_answers() { Some(AskUserQuestionAnswer::Skipped(())) )); } + +#[test] +fn policy_denied_shell_result_preserves_policy_reason_without_denylist_label() { + let result = api::request::input::tool_call_result::Result::try_from( + RequestCommandOutputResult::PolicyDenied { + command: "rm -rf target".to_string(), + reason: "blocked by org policy".to_string(), + }, + ) + .unwrap(); + + let api::request::input::tool_call_result::Result::RunShellCommand(result) = result else { + panic!("expected run_shell_command result"); + }; + let Some(api::run_shell_command_result::Result::PermissionDenied(permission_denied)) = + result.result + else { + panic!("expected permission_denied result"); + }; + + assert_eq!(result.command, "rm -rf target"); + #[allow(deprecated)] + let output = &result.output; + assert_eq!( + decode_command_policy_denied_reason(output).as_deref(), + Some("blocked by org policy") + ); + assert!(!output.starts_with(COMMAND_POLICY_DENIED_PREFIX)); + assert!(permission_denied.reason.is_none()); +} + +#[test] +fn policy_denied_file_edit_result_converts_to_policy_marker_message() { + let result = api::request::input::tool_call_result::Result::try_from( + RequestFileEditsResult::PolicyDenied { + reason: "protected path".to_string(), + }, + ) + .unwrap(); + + let api::request::input::tool_call_result::Result::ApplyFileDiffs(result) = result else { + panic!("expected apply_file_diffs result"); + }; + let Some(api::apply_file_diffs_result::Result::Error(error)) = result.result else { + panic!("expected error result"); + }; + + assert_eq!( + decode_file_edits_policy_denied_reason(&error.message).as_deref(), + Some("protected path") + ); + assert!(!error.message.starts_with(FILE_EDITS_POLICY_DENIED_PREFIX)); +} + +#[test] +fn policy_denied_write_to_shell_result_converts_to_policy_marker() { + let result = api::request::input::tool_call_result::Result::try_from( + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: "interactive write blocked".to_string(), + }, + ) + .unwrap(); + + let api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand(result) = + result + else { + panic!("expected write_to_long_running_shell_command result"); + }; + let Some(api::write_to_long_running_shell_command_result::Result::CommandFinished(finished)) = + result.result + else { + panic!("expected command_finished result"); + }; + + assert_eq!(finished.command_id, WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID); + assert_eq!(finished.exit_code, WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE); + assert_eq!( + finished.output, + "Write to long-running shell command blocked by host policy: interactive write blocked" + ); +} diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index a5610455e..8768bfdf4 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -13,6 +13,59 @@ use crate::{ document::{AIDocumentId, AIDocumentVersion}, }; +pub const COMMAND_POLICY_DENIED_PREFIX: &str = "Command blocked by host policy: "; +pub const COMMAND_POLICY_DENIED_MARKER: &str = "warp.command_policy_denied.v1"; +pub const FILE_EDITS_POLICY_DENIED_PREFIX: &str = "File edits blocked by host policy: "; +pub const FILE_EDITS_POLICY_DENIED_MARKER: &str = "warp.file_edits_policy_denied.v1"; +pub const WRITE_TO_SHELL_POLICY_DENIED_PREFIX: &str = + "Write to long-running shell command blocked by host policy: "; +pub const WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID: &str = "__warp_policy_denied_shell_write__"; +pub const WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE: i32 = 126; + +/// `ApplyFileDiffsResult::Error` persists only a message string, so file-edit +/// policy denials use a structured marker instead of human-readable prefix matching. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileEditsPolicyDeniedApiMessage { + marker: String, + reason: String, +} + +/// `RunShellCommandResult::PermissionDenied` currently has no structured +/// host-policy reason, so persist a JSON marker in the deprecated output field. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CommandPolicyDeniedApiMessage { + marker: String, + reason: String, +} + +pub fn encode_command_policy_denied_message(reason: &str) -> String { + serde_json::to_string(&CommandPolicyDeniedApiMessage { + marker: COMMAND_POLICY_DENIED_MARKER.to_string(), + reason: reason.to_string(), + }) + .expect("command policy denial marker should serialize") +} + +pub fn decode_command_policy_denied_reason(message: &str) -> Option { + let decoded: CommandPolicyDeniedApiMessage = serde_json::from_str(message).ok()?; + (decoded.marker == COMMAND_POLICY_DENIED_MARKER).then_some(decoded.reason) +} + +pub fn encode_file_edits_policy_denied_message(reason: &str) -> String { + serde_json::to_string(&FileEditsPolicyDeniedApiMessage { + marker: FILE_EDITS_POLICY_DENIED_MARKER.to_string(), + reason: reason.to_string(), + }) + .expect("file-edit policy denial marker should serialize") +} + +pub fn decode_file_edits_policy_denied_reason(message: &str) -> Option { + let decoded: FileEditsPolicyDeniedApiMessage = serde_json::from_str(message).ok()?; + (decoded.marker == FILE_EDITS_POLICY_DENIED_MARKER).then_some(decoded.reason) +} + #[derive(Debug, Clone, PartialEq)] pub enum AIAgentActionResultType { /// The output of a requested command. @@ -188,6 +241,8 @@ pub enum RequestCommandOutputResult { CancelledBeforeExecution, /// The command was denied because it was present on the denylist. Denylisted { command: String }, + /// The command was denied by a host policy hook before execution. + PolicyDenied { command: String, reason: String }, } impl RequestCommandOutputResult { @@ -195,14 +250,16 @@ impl RequestCommandOutputResult { match self { Self::Completed { exit_code, .. } => exit_code.was_successful(), Self::LongRunningCommandSnapshot { .. } => true, - Self::CancelledBeforeExecution | Self::Denylisted { .. } => false, + Self::CancelledBeforeExecution + | Self::Denylisted { .. } + | Self::PolicyDenied { .. } => false, } } pub fn failed(&self) -> bool { match self { Self::Completed { exit_code, .. } => !exit_code.was_successful(), - Self::Denylisted { .. } => true, + Self::Denylisted { .. } | Self::PolicyDenied { .. } => true, Self::CancelledBeforeExecution | Self::LongRunningCommandSnapshot { .. } => false, } } @@ -234,6 +291,9 @@ impl Display for RequestCommandOutputResult { RequestCommandOutputResult::Denylisted { .. } => { write!(f, "Command output was on denylist") } + RequestCommandOutputResult::PolicyDenied { reason, .. } => { + write!(f, "Command output was blocked by host policy: {reason}") + } } } } @@ -259,6 +319,9 @@ pub enum WriteToLongRunningShellCommandResult { }, Cancelled, Error(ShellCommandError), + PolicyDenied { + reason: String, + }, } impl Display for WriteToLongRunningShellCommandResult { @@ -276,6 +339,10 @@ impl Display for WriteToLongRunningShellCommandResult { ), Self::Cancelled => write!(f, "Writing to long-running shell command cancelled"), Self::Error(e) => write!(f, "Write to long-running shell command failed: {e:?}"), + Self::PolicyDenied { reason } => write!( + f, + "Write to long-running shell command blocked by host policy: {reason}" + ), } } } @@ -634,6 +701,10 @@ pub enum RequestFileEditsResult { DiffApplicationFailed { error: String, }, + /// The file edits were denied by a host policy hook before diff application. + PolicyDenied { + reason: String, + }, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -686,6 +757,9 @@ impl Display for RequestFileEditsResult { RequestFileEditsResult::DiffApplicationFailed { error } => { write!(f, "File edits failed: {error}") } + RequestFileEditsResult::PolicyDenied { reason } => { + write!(f, "File edits blocked by host policy: {reason}") + } } } } @@ -797,7 +871,14 @@ impl AIAgentActionResultType { pub fn is_failed(&self) -> bool { match self { Self::RequestCommandOutput(r) => r.failed(), - Self::RequestFileEdits(RequestFileEditsResult::DiffApplicationFailed { .. }) + Self::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Error(_) + | WriteToLongRunningShellCommandResult::PolicyDenied { .. }, + ) + | Self::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { .. } + | RequestFileEditsResult::PolicyDenied { .. }, + ) | Self::ReadFiles(ReadFilesResult::Error(_)) | Self::UploadArtifact(UploadArtifactResult::Error(_)) | Self::SearchCodebase(SearchCodebaseResult::Failed { .. }) diff --git a/crates/ai/src/agent/action_result/mod_tests.rs b/crates/ai/src/agent/action_result/mod_tests.rs index 59622c89f..6db615f43 100644 --- a/crates/ai/src/agent/action_result/mod_tests.rs +++ b/crates/ai/src/agent/action_result/mod_tests.rs @@ -1,4 +1,37 @@ -use super::{StartAgentResult, StartAgentVersion}; +use super::{ + decode_file_edits_policy_denied_reason, encode_file_edits_policy_denied_message, + StartAgentResult, StartAgentVersion, FILE_EDITS_POLICY_DENIED_MARKER, + FILE_EDITS_POLICY_DENIED_PREFIX, +}; + +#[test] +fn decodes_file_edit_policy_denial_marker() { + let message = encode_file_edits_policy_denied_message("protected path"); + + assert_eq!( + decode_file_edits_policy_denied_reason(&message).as_deref(), + Some("protected path") + ); +} + +#[test] +fn file_edit_policy_denial_decoder_rejects_human_prefix() { + let message = format!("{FILE_EDITS_POLICY_DENIED_PREFIX}protected path"); + + assert_eq!(decode_file_edits_policy_denied_reason(&message), None); +} + +#[test] +fn file_edit_policy_denial_decoder_rejects_unexpected_fields() { + let message = serde_json::json!({ + "marker": FILE_EDITS_POLICY_DENIED_MARKER, + "reason": "protected path", + "error": "diff failed", + }) + .to_string(); + + assert_eq!(decode_file_edits_policy_denied_reason(&message), None); +} #[test] fn deserializes_legacy_start_agent_success_without_version_as_v1() { diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md new file mode 100644 index 000000000..969964ba8 --- /dev/null +++ b/specs/GH9914/product.md @@ -0,0 +1,130 @@ +# Product Spec: Agent Policy Hooks for governed autonomous actions + +**Issue:** [warpdotdev/warp#9914](https://github.com/warpdotdev/warp/issues/9914) +**Figma:** none provided + +## Summary + +Warp should expose a vendor-neutral Agent Policy Hooks capability that lets a user or team connect an external policy engine before sensitive agent actions run. The policy engine can allow, deny, or require confirmation for proposed shell commands, file reads, file writes, MCP tool calls, and MCP resource reads. Warp remains the enforcement point: if the policy hook denies an action, the action does not execute. + +## Problem + +Warp already gives users strong local controls through Agent Profiles, command allowlists and denylists, MCP allowlists and denylists, and "Run until completion". These controls are useful for an individual user, but they do not provide a first-class integration point for teams that need deterministic policy enforcement, external approvals, audit exports, and compliance evidence independent of the agent model's reasoning. + +Today, third-party guardrails can run beside Warp as MCP servers, project rules, or wrapper CLIs. Those integration points can influence an agent, but they cannot reliably enforce a host-side decision on every Warp-owned agent action before the terminal command, file mutation, or MCP tool call occurs. + +## Goals + +1. Give users and teams a first-class, host-enforced policy hook before high-impact Warp Agent actions execute. +2. Keep the contract vendor-neutral so tools such as HoldTheGoblin, SupraWall, internal policy engines, SIEM gateways, or approval services can integrate without Warp depending on any one provider. +3. Preserve existing Warp permission semantics when no policy hook is configured. +4. Make policy decisions visible to the user and understandable to the agent. +5. Emit auditable, redacted policy decision records for governed actions. +6. Ensure "Run until completion" does not bypass configured policy hooks. + +## Non-goals + +1. Building a full policy language inside Warp. +2. Replacing Agent Profiles, command allowlists, command denylists, or MCP allowlists. +3. Governing arbitrary third-party CLI processes that execute inside the terminal without going through Warp's agent action model. +4. Shipping a vendor-specific integration in the first implementation. +5. Designing a complete enterprise admin console in this spec. + +## User Experience + +### Configure a policy hook + +In a personal or managed Agent Profile, a user or team admin can enable an Agent Policy Hook and provide a local command or HTTP endpoint that receives policy events. Example shape: + +```json +{ + "agent_policy_hooks": { + "enabled": true, + "before_action": [ + { + "name": "company-agent-guard", + "transport": "stdio", + "command": "company-agent-guard", + "args": ["warp", "before-action"], + "timeout_ms": 5000, + "on_unavailable": "ask" + } + ] + } +} +``` + +The exact storage location can be decided during implementation. The product behavior should be the same whether configuration comes from a local profile, project config, or managed team policy. + +### Agent proposes a governed action + +When the Agent proposes a governed action, Warp builds a redacted policy event and sends it to the configured hook before execution. The hook returns one of: + +1. `allow`: continue with execution if Warp's own permissions also allow it. +2. `deny`: block execution and return a denial result to the agent. +3. `ask`: show the normal user confirmation UI with the hook's reason attached. + +### User-visible denial + +If a hook denies an action, Warp shows the action as blocked with the hook name and reason. The agent receives a structured result explaining that host policy denied the action, so it can revise its plan instead of retrying blindly. + +Example: + +```text +Blocked by company-agent-guard: production database commands require approval. +``` + +### Audit visibility + +When hooks are enabled, Warp writes a redacted local audit record for every governed action decision and includes the external hook's returned audit id when provided. Teams can use the hook itself to export to SIEM, webhooks, or approval systems. + +## Testable Behavior Invariants + +1. If no policy hook is configured, Warp behavior is unchanged. +2. A configured hook runs before these Warp-owned action surfaces execute: + - shell command execution + - file reads requested by the agent + - file write or code diff application + - MCP tool calls + - MCP resource reads +3. A hook decision of `deny` prevents the underlying command, file operation, or MCP call from starting, and file-edit denials use a stable policy-blocked result rather than a generic diff-application failure. +4. A hook decision of `ask` routes the action through Warp's existing confirmation UI and includes the hook's reason in the UI, even if the user clicked while the hook was still pending. +5. A hook decision of `allow` cannot override a hard Warp denial such as protected write paths or a managed policy denial. +6. By default, a hook decision of `allow` only preserves an already-allowed Warp permission decision. Any option that lets a trusted hook auto-approve actions that Warp would otherwise ask for must be explicit and scoped to that hook. +7. "Run until completion" still invokes policy hooks and cannot bypass a hook denial. +8. Hook timeout, crash, malformed output, or unavailable endpoint maps to `ask` by default. Managed policy can configure `deny`, or an explicit fail-open `allow` that only preserves an already-allowed Warp decision and cannot auto-approve a Warp confirmation prompt. +9. Hook payloads, persisted hook config, and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, basic-auth credentials, URL-embedded credentials, unbounded path/key collections, or unbounded command output by default. +10. Disabled or inactive hook config is still rejected or sanitized before profile storage if it contains persisted credentials or URL-embedded credentials. +11. Hook payloads include enough metadata for deterministic policy decisions: schema version, action id, conversation id, action type, normalized command or paths, MCP server/tool/resource identity, working directory, active profile id, Warp permission result, and whether auto-approve/run-to-completion is active. +12. Warp records a redacted audit event for every governed decision, including hook name, decision, reason, action id, conversation id, timestamp, and policy event id. +13. The agent receives a structured denial or ask result and can continue planning around it. +14. A user can disable a personal hook from settings unless it is provided by a managed team policy. +15. Hook failures are visible enough to debug without exposing secrets. +16. Third-party CLI agents launched as arbitrary terminal commands are out of scope unless they call back through Warp-owned MCP or Agent surfaces. + +## Edge Cases + +- **Multiple hooks:** Hooks are evaluated in configured order. The first `deny` wins. If any hook returns `ask` and none deny, the effective decision is `ask`. +- **Parallel actions:** Each action has its own policy event id. Decisions must not leak across conversations, action ids, or edited action payloads. +- **Cancellation:** If the user cancels an agent run while a hook is pending, Warp cancels or ignores the pending hook result and does not execute the action. +- **Redacted data:** If a value is redacted, the payload should preserve shape where useful, for example path count or argument key names. +- **Offline operation:** If a remote hook cannot be reached, Warp applies the configured unavailable policy; fail-open `allow` remains bounded by the current Warp permission decision. +- **Remote sessions:** The policy event should identify that the action targets a remote session where Warp has that context, but it should still avoid sending remote file contents. + +## Success Criteria + +1. A local hook can deny `rm -rf .` before Warp starts the shell command. +2. A local hook can deny an MCP tool call before Warp calls the MCP peer. +3. A local hook can require user confirmation for a code diff touching a protected path. +4. Enabling "Run until completion" does not bypass the hook. +5. A malformed hook response fails into the configured fallback decision. +6. Audit records are emitted for allow, deny, ask, timeout, and malformed-response outcomes. +7. Existing Agent Profile behavior remains unchanged for users without hooks. + +## Open Questions + +1. Should the first implementation expose configuration only in local Agent Profiles, or also support project and team-managed configuration? +2. Should HTTP hooks be included in the first implementation, or should MVP start with local stdio commands only? +3. Should file reads be governed in MVP, or should MVP focus on shell commands, file writes, and MCP calls first? +4. Should Warp-owned cloud agent runs use the same event schema immediately, or should this spec start with desktop/local agents and extend to cloud agents later? +5. What user-visible wording should distinguish a Warp permission prompt from an external policy `ask` decision? diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md new file mode 100644 index 000000000..0fa2476ed --- /dev/null +++ b/specs/GH9914/tech.md @@ -0,0 +1,317 @@ +# Tech Spec: Agent Policy Hooks for governed autonomous actions + +**Issue:** [warpdotdev/warp#9914](https://github.com/warpdotdev/warp/issues/9914) + +## Context + +Warp already has the primitives needed to enforce agent autonomy locally: + +- `app/src/ai/execution_profiles/mod.rs:35` defines `ActionPermission` as `AgentDecides`, `AlwaysAllow`, and `AlwaysAsk`. +- `app/src/ai/execution_profiles/mod.rs:220` stores per-profile permissions for code diffs, file reads, command execution, PTY writes, MCP permissions, and command/file/MCP allowlists. +- `app/src/settings/ai.rs:596` defines default command allowlist patterns and `app/src/settings/ai.rs:605` defines default command denylist patterns such as shells, `curl`, `wget`, `ssh`, and `rm`. +- `app/src/ai/blocklist/permissions.rs:640` gates file reads, `:711` gates file writes, `:735` gates MCP tool calls, `:767` gates MCP resource reads, and `:850` gates command execution. +- `app/src/ai/blocklist/action_model/execute/shell_command.rs:106` asks `BlocklistAIPermissions` whether a requested shell command can autoexecute. +- `app/src/ai/blocklist/action_model/execute/read_files.rs:36` gates agent file reads before execution. +- `app/src/ai/blocklist/action_model/execute/request_file_edits.rs:76` gates auto-applied file edits before execution. +- `app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs:37` gates MCP tool calls before dispatching to the MCP peer. +- `app/src/ai/blocklist/action_model/execute.rs:526` centralizes action execution. It computes `can_auto_execute` at `:545`, maps non-autoexecuted actions to confirmation at `:551`, and then dispatches the selected executor at `:586`. + +The key implementation constraint is that `should_autoexecute` is currently synchronous and returns `bool` (`app/src/ai/blocklist/action_model/execute.rs:834`). A real policy hook is asynchronous: it may launch a process or call an HTTP endpoint, has a timeout, may be cancelled, and must emit audit evidence. The implementation should therefore extend the action execution state machine rather than calling a policy process from the synchronous permission helpers. + +## Proposed Changes + +### 1. Add an agent policy hook module + +Create `app/src/ai/policy_hooks/` with: + +- `config.rs`: serializable hook configuration and validation. +- `event.rs`: redacted policy event schema. +- `decision.rs`: policy decision types and effective-decision composition. +- `engine.rs`: hook execution, timeout handling, cancellation handling, and audit emission. +- `redaction.rs`: helpers for command/path/MCP argument redaction and size limits. +- `tests.rs`: schema, redaction, decision-composition, and timeout tests. + +Suggested core types: + +```rust +pub enum AgentPolicyHookTransport { + Stdio { + command: String, + args: Vec, + env: BTreeMap, + working_directory: Option, + }, + Http { + url: String, + headers: BTreeMap, + }, +} + +pub struct AgentPolicyHookConfig { + pub enabled: bool, + pub before_action: Vec, + pub timeout_ms: u64, + pub on_unavailable: AgentPolicyUnavailableDecision, + pub allow_hook_autoapproval: bool, +} + +pub enum AgentPolicyAction { + ExecuteCommand { + command: String, + normalized_command: String, + is_read_only: Option, + is_risky: Option, + }, + ReadFiles { + paths: Vec, + }, + WriteFiles { + paths: Vec, + diff_stats: Option, + }, + CallMcpTool { + server_id: Option, + tool_name: String, + argument_keys: Vec, + }, + ReadMcpResource { + server_id: Option, + name: String, + uri: Option, + }, +} + +pub enum AgentPolicyDecisionKind { + Allow, + Deny, + Ask, +} +``` + +The first version should prefer a stable JSON schema over Rust-internal shape. Include `schema_version: "warp.agent_policy_hook.v1"` so external guard tools can validate compatibility. + +### 2. Add policy-aware action preflight + +Replace the current `bool`-only autoexecute preflight with a richer result: + +```rust +pub enum AutoexecuteDecision { + Allowed { + warp_reason: Option, + }, + NeedsConfirmation { + reason: NotExecutedReason, + policy_reason: Option, + }, + Denied { + policy_result: AgentPolicyHookResult, + }, + PendingPolicyHook { + event_id: uuid::Uuid, + }, +} +``` + +Implementation approach: + +1. Keep existing executor-specific `should_autoexecute` logic as the source of the base Warp permission decision. +2. Add an action-to-policy-event builder in `BlocklistAIActionExecutor`. +3. In `try_to_execute_action`, before the final execution dispatch, call a new `PolicyHookEngine::preflight(action, base_decision, ctx)`. +4. If hooks are disabled, return the base decision immediately. +5. If hooks are enabled and no cached decision exists for `(conversation_id, action_id, redacted action payload)`, start an async hook request, store pending state, and return `TryExecuteResult::NotExecuted { reason: NotReady, action }`. +6. When the hook completes, store the decision and notify the action model to retry the pending action. +7. On retry, recompose stored hook results with the current base Warp permission decision and continue, ask, or deny, so permission/profile changes while a hook is pending cannot leave a stale allow cached. A user click while the hook is pending does not pre-confirm a later hook `ask`; the confirmation UI must include the completed hook reason. + +This avoids blocking the UI thread or changing every executor to directly await a hook. + +### 3. Compose Warp permissions with hook decisions conservatively + +Effective decision rules: + +1. Existing hard Warp denials are never upgraded by hooks. +2. `deny` from any hook wins. +3. `ask` from any hook wins over `allow`. +4. `allow` from hooks preserves an existing Warp allow. +5. `allow` from hooks may auto-approve a Warp `NeedsConfirmation` only when `allow_hook_autoapproval` is enabled for that hook and the hook is trusted by configuration. +6. Hook timeout, process failure, HTTP failure, or malformed JSON maps to the configured unavailable decision, defaulting to `ask`. A configured unavailable `allow` is fail-open only for actions Warp already allows and must not auto-approve an existing Warp prompt. + +This keeps the first implementation safe by default and still allows teams to opt into stronger policy automation later. + +### 4. Make run-to-completion policy-aware + +Current permission helpers return early for run-to-completion in several places: + +- file reads: `app/src/ai/blocklist/permissions.rs:647` +- file writes: `app/src/ai/blocklist/permissions.rs:724` +- MCP server use: `app/src/ai/blocklist/permissions.rs:808` +- command execution: `app/src/ai/blocklist/permissions.rs:882` + +Do not put hook invocation behind these branches. The hook preflight should run after the base Warp permission has been computed and before action execution. The policy event should include `run_until_completion: true` so external policy engines can decide whether to deny or ask. + +### 5. Add denial and ask result plumbing + +When a hook denies an action: + +- Shell commands should return a `RequestCommandOutputResult` variant that tells the model the command was blocked by host policy. +- MCP tool calls should return `CallMCPToolResult::Error` with a policy-blocked message before `reconnecting_peer.call_tool(...)` starts. +- File reads should return `ReadFilesResult::Error` before local or remote file content is read. +- File edits should return a stable `RequestFileEditsResult::PolicyDenied` variant with a policy-blocked reason before diffs are saved, not a generic diff-application failure. + +If new result variants are preferred over reusing existing error strings, add variants with stable, machine-readable policy metadata so the agent can recover reliably. + +### 6. Configuration and settings integration + +MVP configuration options: + +- local profile-scoped hook settings under the Agent Profile model +- managed/team policy can be layered later using the same serialized config +- hook name, transport, command/url, args/headers/env, timeout, unavailable behavior, and autoapproval behavior + +Suggested storage strategy: + +1. Add optional `agent_policy_hooks` to `AIExecutionProfile`. +2. Keep default disabled so old profiles deserialize unchanged. +3. Persist hook credentials only as environment-variable references such as `{ "env": "WARP_POLICY_TOKEN" }`; do not store raw header, environment, or URL credentials in synced profile JSON. +4. Validate persisted credential-bearing fields even when hooks are disabled, and sanitize unsafe config during `AgentPolicyHookConfig` serialization so inactive profile config cannot be locally or cloud-synced with raw or URL-embedded credentials. +5. Detect URL-embedded credentials without relying only on successful URL parsing, because disabled configs may otherwise be incomplete while still containing raw userinfo in the URL authority. +6. Surface minimal settings UI after the engine exists: enabled toggle, hook list, timeout, unavailable behavior, and latest error. + +### 7. Audit events + +Add a local JSONL audit writer owned by `policy_hooks::engine`: + +- event id +- action id +- conversation id +- timestamp +- action kind +- hook name +- hook decision +- effective decision +- reason +- timeout/error class when applicable +- redaction metadata + +Do not include file contents, full env, access tokens, or unbounded MCP argument values. If a hook returns an `external_audit_id`, include it in the local record. On Unix, create the audit directory with private `0700` permissions at creation time and write audit files with private `0600` permissions. +Bound policy event JSON serialization with a maximum byte budget before stdio/HTTP dispatch, and cap model-controlled collections such as path lists and MCP argument-key lists while recording omitted counts. + +### 8. Stdio hook protocol + +MVP stdio protocol: + +1. Warp launches the configured command with args. +2. Warp clears the child process environment and passes only explicitly configured environment-variable references resolved from the local host. +3. Warp writes one JSON policy event to stdin and closes stdin. +4. Hook writes one JSON decision to stdout. +5. Warp kills the process on timeout/cancellation. +6. Stderr is captured only for debug logs and truncated/redacted before UI display. + +Example request: + +```json +{ + "schema_version": "warp.agent_policy_hook.v1", + "event_id": "018f5b3c-2c6b-7cf0-9e2a-6d3b2f0dd111", + "conversation_id": "conv_123", + "action_id": "action_456", + "action_kind": "execute_command", + "working_directory": "/repo", + "run_until_completion": true, + "warp_permission": { + "decision": "allow", + "reason": "RunToCompletion" + }, + "action": { + "command": "rm -rf .", + "normalized_command": "rm -rf .", + "is_read_only": false, + "is_risky": true + } +} +``` + +Example response: + +```json +{ + "schema_version": "warp.agent_policy_hook.v1", + "decision": "deny", + "reason": "recursive delete in repository root is blocked", + "external_audit_id": "audit_789" +} +``` + +### 9. HTTP hook protocol + +If HTTP is included in MVP, use the same JSON body and expect the same JSON response: + +- POST to the configured URL. +- Include an idempotency key header derived from `event_id`. +- Require HTTPS except for localhost. +- Reject embedded URL credentials such as `https://user:pass@example.com`; credentials must be supplied through configured header environment-variable references. +- Apply the same timeout and unavailable behavior. +- Redact resolved header credentials in settings, logs, hook errors, and hook-returned reasons. +- Redact both complete configured header values and credential fragments for bearer/basic auth headers when hook responses echo only the token portion. +- Reject oversized serialized policy events before sending the HTTP request, using the configured unavailable behavior. + +If this is too much for MVP, defer HTTP and keep the JSON schema transport-independent. + +## Testing and Validation + +Unit tests: + +1. Event builders generate stable schema for shell command, file read, file write, MCP tool, and MCP resource actions. +2. Redaction removes env-like secrets, access-token-like values, URL userinfo/basic-auth command credentials, and MCP argument values while preserving useful keys and counts. +3. Decision composition implements deny-wins, ask-over-allow, and no hard-denial upgrade. +4. Run-to-completion base decisions still pass through policy hook composition. +5. Timeout, malformed JSON, process nonzero exit, and missing executable map to configured unavailable behavior. + +Action executor tests: + +1. A hook denial for `RequestCommandOutput` returns a policy-blocked command result and does not write to the PTY. +2. A hook denial for `CallMCPTool` returns before `call_tool` is invoked. +3. A hook denial for `ReadFiles` returns before local or remote file content is read. +4. A hook denial for `RequestFileEdits` prevents diff save/application. +5. A hook `ask` decision returns `NeedsConfirmation` with policy reason. +6. A hook `allow` decision preserves existing autoexecution when the base Warp decision is allow. +7. A hook `allow` decision does not autoapprove an existing Warp prompt unless `allow_hook_autoapproval` is enabled. + +Integration tests: + +1. Configure a test stdio hook that denies `rm -rf .`; ask the Agent to run it; verify no command block starts and the agent receives policy denial. +2. Configure a hook that denies a test MCP tool; verify the MCP peer receives no call. +3. Toggle run-to-completion and verify the same denial still applies. +4. Configure a hook that sleeps past timeout; verify the configured fallback decision applies and an audit event is written. + +Manual validation: + +1. Enable a local policy hook from an Agent Profile. +2. Run a safe command and verify allow path. +3. Run a denied command and verify UI message, agent result, and audit record. +4. Call a known MCP tool and verify allow/deny behavior. +5. Disable the hook and verify existing Agent Profile behavior is unchanged. + +## Risks and Mitigations + +**Risk:** Blocking the UI thread while a policy hook runs. +**Mitigation:** Treat hook execution as async preflight state and retry the pending action after completion. + +**Risk:** Hook providers exfiltrate sensitive context. +**Mitigation:** Redact by default, send no file contents, cap payload size, require explicit config for any expanded context, and make hook configuration visible. + +**Risk:** Users expect "Run until completion" to bypass all prompts. +**Mitigation:** Make the distinction clear: run-to-completion bypasses normal interactive prompts, not configured host policy. + +**Risk:** Broad first scope delays shipping. +**Mitigation:** Implement in phases. Phase 1: stdio hooks for shell commands, file writes, and MCP tool calls. Phase 2: file reads, MCP resources, HTTP hooks, team-managed policy, and cloud agents. + +**Risk:** Third-party CLI agents appear governed when they are not. +**Mitigation:** Document that only Warp-owned Agent/MCP action surfaces are governed. CLI agents need their own native hooks or must route actions through Warp-owned MCP/Agent surfaces. + +## Follow-ups + +1. Reuse the schema for Oz cloud agent governance. +2. Add managed team policy distribution through Warp Drive. +3. Add a policy event viewer in Agent history. +4. Add SIEM/webhook export directly from Warp if external hook export is insufficient. +5. Add compatibility docs for external tools that implement the hook contract.