From f9c29e0a552369577152dd14cd54ab704af3f63e Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 05:51:43 +0300 Subject: [PATCH 01/40] Add agent policy hooks spec --- specs/GH9914/product.md | 129 +++++++++++++++++ specs/GH9914/tech.md | 310 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 specs/GH9914/product.md create mode 100644 specs/GH9914/tech.md diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md new file mode 100644 index 000000000..b2fd62dbb --- /dev/null +++ b/specs/GH9914/product.md @@ -0,0 +1,129 @@ +# 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. +4. A hook decision of `ask` routes the action through Warp's existing confirmation UI and includes the hook's reason in the UI. +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 and can be configured to `deny` by managed policy. +9. Hook payloads do not include file contents, secret values, full environment variables, access tokens, or unbounded command output by default. +10. 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. +11. Warp records a redacted audit event for every governed decision, including hook name, decision, reason, action id, conversation id, timestamp, and policy event id. +12. The agent receives a structured denial or ask result and can continue planning around it. +13. A user can disable a personal hook from settings unless it is provided by a managed team policy. +14. Hook failures are visible enough to debug without exposing secrets. +15. 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 actions. +- **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. +- **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..f1c95576e --- /dev/null +++ b/specs/GH9914/tech.md @@ -0,0 +1,310 @@ +# 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)`, 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, compose the stored policy decision with the base Warp permission decision and continue, ask, or deny. + +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`. + +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 `RequestFileEditsResult` failure/cancelled variant with a policy-blocked reason before diffs are saved. + +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. Redact secret-like config values the same way MCP server config redacts environment variables when shared. +4. 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. + +### 8. Stdio hook protocol + +MVP stdio protocol: + +1. Warp launches the configured command with args. +2. Warp writes one JSON policy event to stdin and closes stdin. +3. Hook writes one JSON decision to stdout. +4. Warp kills the process on timeout/cancellation. +5. 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. +- Apply the same timeout and unavailable behavior. +- Redact headers in settings and logs. + +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, 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. From 8a0e2a585cc861c71400c71ca2fc759d55721873 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 06:19:38 +0300 Subject: [PATCH 02/40] Add agent policy hook preflight scaffold --- app/src/ai/blocklist/permissions.rs | 1 + app/src/ai/execution_profiles/mod.rs | 6 + app/src/ai/mod.rs | 1 + app/src/ai/policy_hooks/config.rs | 237 +++++++++++++++++++++ app/src/ai/policy_hooks/decision.rs | 207 +++++++++++++++++++ app/src/ai/policy_hooks/engine.rs | 233 +++++++++++++++++++++ app/src/ai/policy_hooks/event.rs | 203 ++++++++++++++++++ app/src/ai/policy_hooks/mod.rs | 11 + app/src/ai/policy_hooks/redaction.rs | 73 +++++++ app/src/ai/policy_hooks/tests.rs | 295 +++++++++++++++++++++++++++ 10 files changed, 1267 insertions(+) create mode 100644 app/src/ai/policy_hooks/config.rs create mode 100644 app/src/ai/policy_hooks/decision.rs create mode 100644 app/src/ai/policy_hooks/engine.rs create mode 100644 app/src/ai/policy_hooks/event.rs create mode 100644 app/src/ai/policy_hooks/mod.rs create mode 100644 app/src/ai/policy_hooks/redaction.rs create mode 100644 app/src/ai/policy_hooks/tests.rs 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..c40b3c687 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(), } } } @@ -388,6 +393,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/config.rs b/app/src/ai/policy_hooks/config.rs new file mode 100644 index 000000000..3655cf163 --- /dev/null +++ b/app/src/ai/policy_hooks/config.rs @@ -0,0 +1,237 @@ +use std::{ + collections::BTreeMap, + fmt, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::decision::AgentPolicyUnavailableDecision; + +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, Serialize, 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 && !self.before_action.is_empty() + } + + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + 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 { + 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 + .iter() + .all(|hook| hook.allow_autoapproval) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, 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 { + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + if self.name.trim().is_empty() { + return Err(AgentPolicyHookConfigError::MissingHookName); + } + + 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, Serialize, Deserialize)] +#[serde(tag = "transport", rename_all = "snake_case")] +pub(crate) enum AgentPolicyHookTransport { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + #[serde(default)] + working_directory: Option, + }, + Http { + url: String, + #[serde(default)] + headers: BTreeMap, + }, +} + +impl AgentPolicyHookTransport { + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + match self { + Self::Stdio { + command, + working_directory, + .. + } => { + if command.trim().is_empty() { + return Err(AgentPolicyHookConfigError::MissingStdioCommand); + } + + if working_directory + .as_deref() + .is_some_and(|path| path.as_os_str().is_empty()) + { + return Err(AgentPolicyHookConfigError::InvalidWorkingDirectory( + Path::new("").to_path_buf(), + )); + } + } + Self::Http { url, .. } => { + let parsed = url::Url::parse(url) + .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; + + 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.clone())); + } + } + } + + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct AgentPolicyHookSecretValue(String); + +impl AgentPolicyHookSecretValue { + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AgentPolicyHookSecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("\"\"") + } +} + +impl From for AgentPolicyHookSecretValue { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for AgentPolicyHookSecretValue { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +#[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 stdio command must not be empty")] + MissingStdioCommand, + #[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), +} + +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..f1ac914aa --- /dev/null +++ b/app/src/ai/policy_hooks/decision.rs @@ -0,0 +1,207 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentPolicyDecisionKind { + Allow, + Deny, + Ask, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, 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, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WarpPermissionDecisionKind { + Allow, + Ask, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq, 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, + MalformedResponse, + UnsupportedTransport, +} + +#[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: hook_name.into(), + decision: response.decision, + reason: response.reason, + external_audit_id: response.external_audit_id, + error: None, + } + } + + pub(crate) fn unavailable( + hook_name: impl Into, + decision: AgentPolicyDecisionKind, + error: AgentPolicyHookErrorKind, + reason: impl Into, + ) -> Self { + Self { + hook_name: hook_name.into(), + decision, + reason: Some(reason.into()), + external_audit_id: None, + error: Some(error), + } + } +} + +#[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 && !hook_results.is_empty() => { + 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"), + } +} diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs new file mode 100644 index 000000000..a1a52e436 --- /dev/null +++ b/app/src/ai/policy_hooks/engine.rs @@ -0,0 +1,233 @@ +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use command::{r#async::Command, Stdio}; +use futures_lite::io::AsyncWriteExt; +use warpui::r#async::FutureExt as _; + +use super::{ + config::{AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookTransport}, + decision::{ + compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + AgentPolicyHookErrorKind, AgentPolicyHookEvaluation, AgentPolicyHookResponse, + WarpPermissionSnapshot, + }, + event::{AgentPolicyEvent, AGENT_POLICY_SCHEMA_VERSION}, + redaction::truncate_for_policy, +}; + +const MAX_HOOK_STDOUT_BYTES: usize = 64 * 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() { + return 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, + ); + } + + 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; + } + } + + compose_policy_decisions( + warp_permission, + hook_results, + self.config.allow_autoapproval_for_all_hooks(), + ) + } + + 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 { .. } => Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::UnsupportedTransport, + detail: "HTTP policy hooks are not implemented in the local engine yet".to_string(), + }), + }; + + match response { + Ok(response) => AgentPolicyHookEvaluation::from_response(hook.name.clone(), response), + Err(failure) => 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) + .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, value.as_str()); + } + + let mut child = command.spawn().map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: format!("failed to spawn policy hook: {source}"), + })?; + + let event_bytes = serialize_event(event).map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("failed to serialize policy event: {source}"), + })?; + + 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 timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); + let output = child + .output() + .with_timeout(timeout) + .await + .map_err(|_| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::Timeout, + detail: format!("policy hook timed out after {timeout:?}"), + })? + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::SpawnFailed, + detail: format!("failed to wait for policy hook: {source}"), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::NonZeroExit, + detail: format!( + "policy hook exited with {}; stderr={}", + output.status, + truncate_for_policy(stderr.trim()) + ), + }); + } + + if output.stdout.len() > MAX_HOOK_STDOUT_BYTES { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook stdout exceeded {MAX_HOOK_STDOUT_BYTES} bytes"), + }); + } + + let response = + parse_hook_response(&output.stdout).map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook returned malformed response: {source:#}"), + })?; + + Ok(response) + } +} + +#[derive(Debug, Clone)] +struct AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind, + detail: String, +} + +fn serialize_event(event: &AgentPolicyEvent) -> Result> { + serde_json::to_vec(event).context("serialize policy event") +} + +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 {:?}", + response.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..c6c7b25e4 --- /dev/null +++ b/app/src/ai/policy_hooks/event.rs @@ -0,0 +1,203 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize, Serializer}; + +use super::{ + decision::WarpPermissionSnapshot, + redaction::{mcp_argument_keys, redact_command_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")] + pub working_directory: Option, + pub run_until_completion: 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 { + 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, + active_profile_id, + warp_permission, + action, + } + } + + 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, + ReadFiles, + WriteFiles, + CallMcpTool, + ReadMcpResource, +} + +#[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] +pub(crate) enum AgentPolicyAction { + ExecuteCommand(PolicyExecuteCommandAction), + ReadFiles(PolicyReadFilesAction), + WriteFiles(PolicyWriteFilesAction), + CallMcpTool(PolicyCallMcpToolAction), + ReadMcpResource(PolicyReadMcpResourceAction), +} + +impl AgentPolicyAction { + pub(crate) fn kind(&self) -> AgentPolicyActionKind { + match self { + Self::ExecuteCommand(_) => AgentPolicyActionKind::ExecuteCommand, + Self::ReadFiles(_) => AgentPolicyActionKind::ReadFiles, + Self::WriteFiles(_) => AgentPolicyActionKind::WriteFiles, + Self::CallMcpTool(_) => AgentPolicyActionKind::CallMcpTool, + Self::ReadMcpResource(_) => AgentPolicyActionKind::ReadMcpResource, + } + } +} + +impl Serialize for AgentPolicyAction { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::ExecuteCommand(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, 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, Serialize, Deserialize)] +pub(crate) struct PolicyReadFilesAction { + pub paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PolicyWriteFilesAction { + pub paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_stats: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PolicyDiffStats { + pub files_changed: usize, + pub additions: usize, + pub deletions: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, 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, +} + +impl PolicyCallMcpToolAction { + pub(crate) fn new( + server_id: Option, + tool_name: impl Into, + arguments: &serde_json::Value, + ) -> Self { + Self { + server_id, + tool_name: tool_name.into(), + argument_keys: mcp_argument_keys(arguments), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, 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, +} diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs new file mode 100644 index 000000000..e44222cb7 --- /dev/null +++ b/app/src/ai/policy_hooks/mod.rs @@ -0,0 +1,11 @@ +mod config; +mod decision; +#[cfg(not(target_family = "wasm"))] +mod engine; +mod event; +mod redaction; + +pub(crate) use config::AgentPolicyHookConfig; + +#[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..8dd047cc7 --- /dev/null +++ b/app/src/ai/policy_hooks/redaction.rs @@ -0,0 +1,73 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +pub(crate) const MAX_POLICY_STRING_BYTES: usize = 8 * 1024; + +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 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 { + let command = SECRET_ASSIGNMENT_RE.replace_all(command, "$1="); + let command = AUTHORIZATION_BEARER_RE.replace_all(&command, "$1"); + let command = COMMON_TOKEN_RE.replace_all(&command, ""); + truncate_for_policy(&command) +} + +pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> Vec { + let serde_json::Value::Object(map) = arguments else { + return Vec::new(); + }; + + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + keys +} + +#[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..b19e50c74 --- /dev/null +++ b/app/src/ai/policy_hooks/tests.rs @@ -0,0 +1,295 @@ +use std::path::PathBuf; + +use serde_json::json; + +use super::{ + config::AgentPolicyHookConfig, + decision::{ + compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyHookErrorKind, + AgentPolicyHookEvaluation, AgentPolicyUnavailableDecision, WarpPermissionSnapshot, + }, + event::{ + AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + AGENT_POLICY_SCHEMA_VERSION, + }, +}; + +#[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_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_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 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["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 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"]); +} + +#[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_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")); +} + +#[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_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 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) + ); +} From edb22612d4fbb201ed814210f0e50c5fecafdec5 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 07:01:25 +0300 Subject: [PATCH 03/40] Wire agent policy hooks into action execution --- app/src/ai/agent/mod.rs | 6 + app/src/ai/agent_sdk/driver/output.rs | 8 + app/src/ai/blocklist/action_model.rs | 19 +- app/src/ai/blocklist/action_model/execute.rs | 445 +++++++++++++++++- .../blocklist/action_model/execute_tests.rs | 90 ++++ app/src/ai/policy_hooks/audit.rs | 152 ++++++ app/src/ai/policy_hooks/decision.rs | 2 + app/src/ai/policy_hooks/engine.rs | 98 +++- app/src/ai/policy_hooks/mod.rs | 20 +- app/src/ai/policy_hooks/tests.rs | 97 ++++ crates/ai/src/agent/action_result/convert.rs | 3 +- crates/ai/src/agent/action_result/mod.rs | 11 +- 12 files changed, 927 insertions(+), 24 deletions(-) create mode 100644 app/src/ai/policy_hooks/audit.rs diff --git a/app/src/ai/agent/mod.rs b/app/src/ai/agent/mod.rs index e84dda499..9a04376dc 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, .. } => { diff --git a/app/src/ai/agent_sdk/driver/output.rs b/app/src/ai/agent_sdk/driver/output.rs index f7e879a7f..d919efb87 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 { .. } => { @@ -825,6 +828,11 @@ pub mod json { "Command was not allowed to run due to presence on denylist", ), }), + RequestCommandOutputResult::PolicyDenied { reason, .. } => { + Some(JsonMessage::ToolError { + error: Cow::Borrowed(reason.as_str()), + }) + } }, AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result { WriteToLongRunningShellCommandResult::Snapshot { .. } => { diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 486152841..542def6e9 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(&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(&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..1d1209c8b 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -20,6 +20,10 @@ 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, +}; use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult}; pub use ask_user_question::AskUserQuestionExecutor; pub(crate) use call_mcp_tool::coerce_integer_args; @@ -58,13 +62,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,7 +87,9 @@ 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(any(feature = "local_fs", not(target_family = "wasm")))] +use crate::ai::paths::host_native_absolute_path; use crate::{ ai::{ agent::{ @@ -99,6 +109,16 @@ use crate::{ BlocklistAIHistoryModel, }; +#[cfg(not(target_family = "wasm"))] +use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +#[cfg(not(target_family = "wasm"))] +use crate::ai::policy_hooks::{ + AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, + AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyDiffStats, PolicyExecuteCommandAction, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + WarpPermissionSnapshot, +}; + /// Types of actions that can be executed in parallel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ParallelExecutionPolicy { @@ -170,6 +190,14 @@ enum AnyActionExecution { InvalidAction, } +#[cfg(not(target_family = "wasm"))] +enum PolicyPreflightState { + Pending, + Allowed, + NeedsConfirmation(Option), + Denied(AIAgentActionResultType), +} + impl From> for AnyActionExecution where T: Send + 'static, @@ -195,16 +223,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 +276,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 +302,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"))] + user_initiated_policy_preflights: HashSet, + #[cfg(not(target_family = "wasm"))] + completed_policy_preflights: HashMap, /// Reference to the terminal model for checking session sharing state. terminal_model: Arc>, @@ -327,6 +372,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 +391,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"))] + user_initiated_policy_preflights: Default::default(), + #[cfg(not(target_family = "wasm"))] + completed_policy_preflights: Default::default(), terminal_model, read_skill_executor, fetch_conversation_executor, @@ -544,6 +597,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,10 +608,55 @@ impl BlocklistAIActionExecutor { let needs_confirmation = !(is_user_initiated || can_auto_execute || (is_agent_autonomous && action.action.is_request_command_output())); + #[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 => {} + } + } if needs_confirmation { return TryExecuteResult::NotExecuted { action: Box::new(action), - reason: NotExecutedReason::NeedsConfirmation, + reason: NotExecutedReason::NeedsConfirmation { + policy_reason: None, + }, }; } else if !is_user_initiated && !can_auto_execute && is_agent_autonomous { // It must be the case that the autonomous agent is requesting a denylisted command. @@ -781,6 +883,149 @@ impl BlocklistAIActionExecutor { ) } + #[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.pending_policy_preflights.remove(&action.id); + self.user_initiated_policy_preflights.remove(&action.id); + self.completed_policy_preflights.remove(&action.id); + return None; + } + + if let Some(decision) = self.completed_policy_preflights.remove(&action.id) { + let user_confirmed = + is_user_initiated || self.user_initiated_policy_preflights.remove(&action.id); + return Some(self.policy_preflight_state_from_decision( + action, + decision, + user_confirmed, + )); + } + + if self.pending_policy_preflights.contains(&action.id) { + if is_user_initiated { + self.user_initiated_policy_preflights + .insert(action.id.clone()); + } + return Some(PolicyPreflightState::Pending); + } + + let warp_permission = warp_permission_snapshot_for_policy( + is_user_initiated, + can_auto_execute, + needs_confirmation, + autonomous_shell_command_denied, + ); + let event = self.agent_policy_event( + action, + conversation_id, + Some(active_profile.id().to_string()), + warp_permission.clone(), + ctx, + )?; + + let action_id = action.id.clone(); + self.pending_policy_preflights.insert(action_id.clone()); + if is_user_initiated { + self.user_initiated_policy_preflights + .insert(action_id.clone()); + } + let engine = AgentPolicyHookEngine::new(config); + ctx.spawn( + async move { engine.preflight(event, warp_permission).await }, + move |me, decision, ctx| { + if !me.pending_policy_preflights.remove(&action_id) { + me.user_initiated_policy_preflights.remove(&action_id); + return; + } + me.completed_policy_preflights.insert(action_id, decision); + ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { + conversation_id, + }); + }, + ); + + Some(PolicyPreflightState::Pending) + } + + pub fn cancel_policy_preflight_for_action(&mut self, action_id: &AIAgentActionId) { + #[cfg(not(target_family = "wasm"))] + { + self.pending_policy_preflights.remove(action_id); + self.user_initiated_policy_preflights.remove(action_id); + self.completed_policy_preflights.remove(action_id); + } + } + + #[cfg(not(target_family = "wasm"))] + fn policy_preflight_state_from_decision( + &self, + action: &AIAgentAction, + decision: AgentPolicyEffectiveDecision, + is_user_initiated: bool, + ) -> PolicyPreflightState { + match decision.decision { + AgentPolicyDecisionKind::Allow => PolicyPreflightState::Allowed, + AgentPolicyDecisionKind::Ask if is_user_initiated => PolicyPreflightState::Allowed, + 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 agent_policy_event( + &self, + action: &AIAgentAction, + conversation_id: AIConversationId, + active_profile_id: Option, + warp_permission: WarpPermissionSnapshot, + 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, + )) + } + pub fn cancel_running_async_action( &mut self, action_id: &AIAgentActionId, @@ -909,6 +1154,187 @@ impl BlocklistAIActionExecutor { self.terminal_model.lock().is_shared_session_viewer() } } + +#[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, +) -> WarpPermissionSnapshot { + 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::ReadFiles(read_files) => { + Some(AgentPolicyAction::ReadFiles(PolicyReadFilesAction { + paths: read_files + .locations + .iter() + .map(|file| policy_path(&file.name, shell, current_working_directory)) + .collect(), + })) + } + AIAgentActionType::RequestFileEdits { file_edits, .. } => { + let paths = file_edits + .iter() + .filter_map(|edit| edit.file()) + .map(|file| policy_path(file, shell, current_working_directory)) + .collect::>(); + let diff_stats = PolicyDiffStats { + files_changed: paths.len(), + additions: 0, + deletions: 0, + }; + Some(AgentPolicyAction::WriteFiles(PolicyWriteFilesAction { + paths, + diff_stats: Some(diff_stats), + })) + } + 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 { + server_id: *server_id, + name: name.clone(), + uri: uri.clone(), + }, + )), + _ => None, + } +} + +#[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 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 { + let reason = policy_denied_message(decision); + match &action.action { + AIAgentActionType::RequestCommandOutput { command, .. } => { + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::PolicyDenied { + command: command.clone(), + reason, + }, + ) + } + AIAgentActionType::ReadFiles(_) => AIAgentActionResultType::ReadFiles( + ReadFilesResult::Error(format!("Blocked by host policy: {reason}")), + ), + AIAgentActionType::RequestFileEdits { .. } => AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { + error: format!("Blocked by host policy: {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 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 +1353,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_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index ddc6e5c04..3c122928b 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -97,3 +97,93 @@ mod binary_detection { assert!(block_on(is_file_content_binary_async(&missing))); } } + +#[cfg(not(target_family = "wasm"))] +mod policy_hooks { + use crate::{ + ai::{ + agent::task::TaskId, + agent::{ + AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, + RequestCommandOutputResult, + }, + policy_hooks::{ + decision::{ + AgentPolicyHookEvaluation, WarpPermissionDecisionKind, WarpPermissionSnapshot, + }, + AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + }, + }, + terminal::shell::ShellType, + }; + + use super::super::{ + normalize_command_for_policy, policy_denied_action_result, + warp_permission_snapshot_for_policy, + }; + + 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, + } + } + + #[test] + fn policy_denied_result_preserves_command_and_policy_reason() { + let action = command_action("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: "rm -rf target".to_string(), + reason: "guard denied the action: dangerous command".to_string(), + } + ) + ); + } + + #[test] + fn warp_permission_snapshot_marks_autonomous_denials_terminal() { + let snapshot = warp_permission_snapshot_for_policy(false, false, false, true); + + assert_eq!(snapshot.decision, WarpPermissionDecisionKind::Deny); + } + + #[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/policy_hooks/audit.rs b/app/src/ai/policy_hooks/audit.rs new file mode 100644 index 000000000..37507154a --- /dev/null +++ b/app/src/ai/policy_hooks/audit.rs @@ -0,0 +1,152 @@ +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::PathBuf, +}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use super::{ + decision::AgentPolicyEffectiveDecision, + event::{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")] + 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")?; + + fs::create_dir_all(parent) + .with_context(|| format!("create agent policy audit directory {}", parent.display()))?; + set_private_directory_permissions(parent); + + 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(()) +} + +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 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: &std::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: &std::path::Path) {} + +#[cfg(unix)] +fn set_private_file_permissions(path: &std::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: &std::path::Path) {} diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index f1ac914aa..40cda7bb3 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -89,6 +89,8 @@ pub(crate) enum AgentPolicyHookErrorKind { NonZeroExit, MalformedResponse, UnsupportedTransport, + HttpRequestFailed, + HttpStatus, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index a1a52e436..e90ae2b36 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -3,9 +3,11 @@ use std::time::Duration; use anyhow::{anyhow, Context, Result}; use command::{r#async::Command, Stdio}; use futures_lite::io::AsyncWriteExt; +use reqwest::header::CONTENT_TYPE; use warpui::r#async::FutureExt as _; use super::{ + audit::write_audit_record, config::{AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookTransport}, decision::{ compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, @@ -40,7 +42,7 @@ impl AgentPolicyHookEngine { } if let Err(err) = self.config.validate() { - return compose_policy_decisions( + let decision = compose_policy_decisions( warp_permission, vec![AgentPolicyHookEvaluation::unavailable( "agent_policy_hooks", @@ -50,6 +52,8 @@ impl AgentPolicyHookEngine { )], false, ); + audit_decision(&event, &decision); + return decision; } let mut hook_results = Vec::new(); @@ -63,11 +67,13 @@ impl AgentPolicyHookEngine { } } - compose_policy_decisions( + 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( @@ -77,10 +83,7 @@ impl AgentPolicyHookEngine { ) -> AgentPolicyHookEvaluation { let response = match &hook.transport { AgentPolicyHookTransport::Stdio { .. } => self.run_stdio_hook(hook, event).await, - AgentPolicyHookTransport::Http { .. } => Err(AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::UnsupportedTransport, - detail: "HTTP policy hooks are not implemented in the local engine yet".to_string(), - }), + AgentPolicyHookTransport::Http { .. } => self.run_http_hook(hook, event).await, }; match response { @@ -202,6 +205,87 @@ impl AgentPolicyHookEngine { 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).map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("failed to serialize policy event: {source}"), + })?; + + let client = reqwest::Client::new(); + 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(), value.as_str()); + } + + let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); + let response = request + .send() + .with_timeout(timeout) + .await + .map_err(|_| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::Timeout, + detail: format!("policy hook timed out after {timeout:?}"), + })? + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpRequestFailed, + detail: format!("failed to call HTTP policy hook: {source}"), + })?; + + let status = response.status(); + if !status.is_success() { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpStatus, + detail: format!("HTTP policy hook returned status {status}"), + }); + } + + let response_bytes = response + .bytes() + .with_timeout(timeout) + .await + .map_err(|_| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::Timeout, + detail: format!("policy hook response timed out after {timeout:?}"), + })? + .map_err(|source| AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpRequestFailed, + detail: format!("failed to read HTTP policy hook response: {source}"), + })?; + + if response_bytes.len() > MAX_HOOK_STDOUT_BYTES { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::MalformedResponse, + detail: format!("policy hook response exceeded {MAX_HOOK_STDOUT_BYTES} bytes"), + }); + } + + 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)] diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs index e44222cb7..5f5919f54 100644 --- a/app/src/ai/policy_hooks/mod.rs +++ b/app/src/ai/policy_hooks/mod.rs @@ -1,11 +1,23 @@ -mod config; -mod decision; #[cfg(not(target_family = "wasm"))] -mod engine; -mod event; +mod audit; +pub(crate) mod config; +pub(crate) mod decision; +#[cfg(not(target_family = "wasm"))] +pub(crate) mod engine; +pub(crate) mod event; 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, PolicyDiffStats, + PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, + PolicyWriteFilesAction, +}; #[cfg(test)] mod tests; diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index b19e50c74..a2df80db5 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -14,6 +14,8 @@ use super::{ }, }; +#[cfg(not(target_family = "wasm"))] +use super::audit::audit_record_json_line; #[cfg(not(target_family = "wasm"))] use super::engine::AgentPolicyHookEngine; @@ -181,6 +183,47 @@ fn policy_decision_composition_keeps_denials_terminal() { assert_eq!(warp_denied.reason.as_deref(), Some("protected path")); } +#[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("token123")); +} + #[cfg(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn stdio_engine_can_deny_before_action() { @@ -259,6 +302,60 @@ async fn stdio_engine_maps_malformed_response_to_unavailable_policy() { ); } +#[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(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn engine_maps_invalid_enabled_config_to_unavailable_policy() { diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index ec89cdb57..dbcc719cb 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -66,7 +66,8 @@ impl TryFrom for api::request::input::tool_call_resu RequestCommandOutputResult::CancelledBeforeExecution => { Err(ConvertToAPITypeError::Ignore) } - RequestCommandOutputResult::Denylisted { command } => + RequestCommandOutputResult::Denylisted { command } + | RequestCommandOutputResult::PolicyDenied { command, .. } => { #[allow(deprecated)] Ok( diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index a5610455e..45a48186e 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -188,6 +188,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 +197,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 +238,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}") + } } } } From d5d6be5002a63ef7f183bfaff82327511387e0b5 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 07:29:18 +0300 Subject: [PATCH 04/40] Address agent policy hook review findings --- app/src/ai/agent/api/convert_conversation.rs | 27 ++- .../agent/api/convert_conversation_tests.rs | 43 ++++ app/src/ai/agent/conversation_yaml.rs | 6 + app/src/ai/blocklist/action_model/execute.rs | 9 +- .../blocklist/action_model/execute_tests.rs | 31 ++- app/src/ai/policy_hooks/engine.rs | 186 ++++++++++++++---- app/src/ai/policy_hooks/mod.rs | 5 +- app/src/ai/policy_hooks/redaction.rs | 12 +- app/src/ai/policy_hooks/tests.rs | 131 ++++++++++++ crates/ai/src/agent/action_result/convert.rs | 20 +- .../src/agent/action_result/convert_tests.rs | 29 +++ 11 files changed, 438 insertions(+), 61 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 943e9e456..fd8ab5d3c 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -591,9 +591,30 @@ 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(); + let reason = output + .strip_prefix("Command blocked by host policy: ") + .unwrap_or(output); + if reason.is_empty() { + RequestCommandOutputResult::CancelledBeforeExecution + } else { + RequestCommandOutputResult::PolicyDenied { + command: result.command.clone(), + reason: reason.to_string(), + } + } + } + }, + None => { // If no result is present, treat as cancelled RequestCommandOutputResult::CancelledBeforeExecution } diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index c49e742a3..6fe2ab5a1 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -79,6 +79,49 @@ 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: "Command blocked by host policy: blocked by org policy".to_string(), + 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_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..40359ef32 100644 --- a/app/src/ai/agent/conversation_yaml.rs +++ b/app/src/ai/agent/conversation_yaml.rs @@ -551,6 +551,12 @@ 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() { + out.push_str("reason: |\n"); + write_block_scalar(out, output); + } } } } diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 1d1209c8b..2e8eec434 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -114,7 +114,7 @@ use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; #[cfg(not(target_family = "wasm"))] use crate::ai::policy_hooks::{ AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, - AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyDiffStats, PolicyExecuteCommandAction, + AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, WarpPermissionSnapshot, }; @@ -1226,14 +1226,9 @@ fn agent_policy_action( .filter_map(|edit| edit.file()) .map(|file| policy_path(file, shell, current_working_directory)) .collect::>(); - let diff_stats = PolicyDiffStats { - files_changed: paths.len(), - additions: 0, - deletions: 0, - }; Some(AgentPolicyAction::WriteFiles(PolicyWriteFilesAction { paths, - diff_stats: Some(diff_stats), + diff_stats: None, })) } AIAgentActionType::CallMCPTool { diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 3c122928b..2ef796854 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -105,20 +105,20 @@ mod policy_hooks { agent::task::TaskId, agent::{ AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, - RequestCommandOutputResult, + FileEdit, RequestCommandOutputResult, }, policy_hooks::{ decision::{ AgentPolicyHookEvaluation, WarpPermissionDecisionKind, WarpPermissionSnapshot, }, - AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, }, }, terminal::shell::ShellType, }; use super::super::{ - normalize_command_for_policy, policy_denied_action_result, + agent_policy_action, normalize_command_for_policy, policy_denied_action_result, warp_permission_snapshot_for_policy, }; @@ -175,6 +175,31 @@ mod policy_hooks { assert_eq!(snapshot.decision, WarpPermissionDecisionKind::Deny); } + #[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 command_normalization_matches_shell_escape_style() { assert_eq!( diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index e90ae2b36..bdbbcb8f7 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -1,24 +1,31 @@ -use std::time::Duration; +use std::{collections::BTreeMap, process::ExitStatus, time::Duration}; use anyhow::{anyhow, Context, Result}; use command::{r#async::Command, Stdio}; -use futures_lite::io::AsyncWriteExt; +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, AgentPolicyHookTransport}, + config::{ + AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookSecretValue, + AgentPolicyHookTransport, + }, decision::{ compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyHookErrorKind, AgentPolicyHookEvaluation, AgentPolicyHookResponse, WarpPermissionSnapshot, }, event::{AgentPolicyEvent, AGENT_POLICY_SCHEMA_VERSION}, - redaction::truncate_for_policy, + redaction::redact_sensitive_text_for_policy, }; -const MAX_HOOK_STDOUT_BYTES: usize = 64 * 1024; +const MAX_HOOK_OUTPUT_BYTES: usize = 64 * 1024; #[derive(Debug, Clone)] pub(crate) struct AgentPolicyHookEngine { @@ -165,38 +172,63 @@ impl AgentPolicyHookEngine { drop(stdin); let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); - let output = child - .output() - .with_timeout(timeout) - .await - .map_err(|_| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::Timeout, - detail: format!("policy hook timed out after {timeout:?}"), - })? - .map_err(|source| AgentPolicyHookFailure { + let output = match async { + let stdout = child.stdout.take().ok_or_else(|| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::SpawnFailed, - detail: format!("failed to wait for policy hook: {source}"), + 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 = String::from_utf8_lossy(&output.stderr); + let stderr = redact_hook_stderr(&output.stderr, env); return Err(AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::NonZeroExit, detail: format!( "policy hook exited with {}; stderr={}", - output.status, - truncate_for_policy(stderr.trim()) + output.status, stderr, ), }); } - if output.stdout.len() > MAX_HOOK_STDOUT_BYTES { - return Err(AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::MalformedResponse, - detail: format!("policy hook stdout exceeded {MAX_HOOK_STDOUT_BYTES} bytes"), - }); - } - let response = parse_hook_response(&output.stdout).map_err(|source| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::MalformedResponse, @@ -255,25 +287,13 @@ impl AgentPolicyHookEngine { }); } - let response_bytes = response - .bytes() + let response_bytes = read_capped_http_response(response) .with_timeout(timeout) .await .map_err(|_| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::Timeout, detail: format!("policy hook response timed out after {timeout:?}"), - })? - .map_err(|source| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::HttpRequestFailed, - detail: format!("failed to read HTTP policy hook response: {source}"), - })?; - - if response_bytes.len() > MAX_HOOK_STDOUT_BYTES { - return Err(AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::MalformedResponse, - detail: format!("policy hook response exceeded {MAX_HOOK_STDOUT_BYTES} bytes"), - }); - } + })??; parse_hook_response(&response_bytes).map_err(|source| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::MalformedResponse, @@ -294,6 +314,94 @@ struct AgentPolicyHookFailure { 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}"), + })?; + + 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 mut redacted = stderr.trim().to_string(); + for value in env.values() { + let secret = value.as_str(); + if !secret.is_empty() { + redacted = redacted.replace(secret, ""); + } + } + redact_sensitive_text_for_policy(&redacted) +} + fn serialize_event(event: &AgentPolicyEvent) -> Result> { serde_json::to_vec(event).context("serialize policy event") } diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs index 5f5919f54..20c7f63a5 100644 --- a/app/src/ai/policy_hooks/mod.rs +++ b/app/src/ai/policy_hooks/mod.rs @@ -14,9 +14,8 @@ pub(crate) use decision::{ #[cfg(not(target_family = "wasm"))] pub(crate) use engine::AgentPolicyHookEngine; pub(crate) use event::{ - AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyDiffStats, - PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, - PolicyWriteFilesAction, + AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, }; #[cfg(test)] diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 8dd047cc7..b1d4febd8 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -21,10 +21,14 @@ static COMMON_TOKEN_RE: Lazy = Lazy::new(|| { }); pub(crate) fn redact_command_for_policy(command: &str) -> String { - let command = SECRET_ASSIGNMENT_RE.replace_all(command, "$1="); - let command = AUTHORIZATION_BEARER_RE.replace_all(&command, "$1"); - let command = COMMON_TOKEN_RE.replace_all(&command, ""); - truncate_for_policy(&command) + redact_sensitive_text_for_policy(command) +} + +pub(crate) fn redact_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 = COMMON_TOKEN_RE.replace_all(&value, ""); + truncate_for_policy(&value) } pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> Vec { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index a2df80db5..51d8592d4 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -302,6 +302,88 @@ async fn stdio_engine_maps_malformed_response_to_unavailable_policy() { ); } +#[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_redacts_configured_secret_stderr() { + 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": "super-secret-token" }, + "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("super-secret-token")); +} + #[cfg(not(target_family = "wasm"))] #[tokio::test] async fn http_engine_can_deny_before_action() { @@ -356,6 +438,55 @@ async fn http_engine_can_deny_before_action() { ); } +#[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(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn engine_maps_invalid_enabled_config_to_unavailable_policy() { diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index dbcc719cb..72f1a5839 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -66,8 +66,7 @@ impl TryFrom for api::request::input::tool_call_resu RequestCommandOutputResult::CancelledBeforeExecution => { Err(ConvertToAPITypeError::Ignore) } - RequestCommandOutputResult::Denylisted { command } - | RequestCommandOutputResult::PolicyDenied { command, .. } => + RequestCommandOutputResult::Denylisted { command } => { #[allow(deprecated)] Ok( @@ -87,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: format!("Command blocked by host policy: {reason}"), + exit_code: Default::default(), + result: Some(api::run_shell_command_result::Result::PermissionDenied( + api::PermissionDenied { reason: None }, + )), + }, + ), + ) + } } } } diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 6adcbdda8..92e690215 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -28,3 +28,32 @@ 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!( + output.as_str(), + "Command blocked by host policy: blocked by org policy" + ); + assert!(permission_denied.reason.is_none()); +} From a7aa47f378c2a3c67bfc0461028c76fcd0e9e689 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 15:41:09 +0300 Subject: [PATCH 05/40] Harden agent policy hook lifecycle --- app/src/ai/blocklist/action_model/execute.rs | 58 +++--- .../blocklist/action_model/execute_tests.rs | 31 ++- app/src/ai/policy_hooks/decision.rs | 16 +- app/src/ai/policy_hooks/engine.rs | 115 ++++++++---- app/src/ai/policy_hooks/tests.rs | 176 +++++++++++++++++- 5 files changed, 327 insertions(+), 69 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 2e8eec434..1c7b594ec 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -191,6 +191,7 @@ enum AnyActionExecution { } #[cfg(not(target_family = "wasm"))] +#[derive(Debug, PartialEq)] enum PolicyPreflightState { Pending, Allowed, @@ -908,14 +909,15 @@ impl BlocklistAIActionExecutor { return None; } - if let Some(decision) = self.completed_policy_preflights.remove(&action.id) { + if let Some(decision) = self.completed_policy_preflights.get(&action.id) { let user_confirmed = - is_user_initiated || self.user_initiated_policy_preflights.remove(&action.id); - return Some(self.policy_preflight_state_from_decision( - action, - decision, - user_confirmed, - )); + is_user_initiated || self.user_initiated_policy_preflights.contains(&action.id); + let state = policy_preflight_state_from_decision(action, decision, user_confirmed); + if should_consume_completed_policy_preflight(&state) { + self.completed_policy_preflights.remove(&action.id); + self.user_initiated_policy_preflights.remove(&action.id); + } + return Some(state); } if self.pending_policy_preflights.contains(&action.id) { @@ -973,25 +975,6 @@ impl BlocklistAIActionExecutor { } } - #[cfg(not(target_family = "wasm"))] - fn policy_preflight_state_from_decision( - &self, - action: &AIAgentAction, - decision: AgentPolicyEffectiveDecision, - is_user_initiated: bool, - ) -> PolicyPreflightState { - match decision.decision { - AgentPolicyDecisionKind::Allow => PolicyPreflightState::Allowed, - AgentPolicyDecisionKind::Ask if is_user_initiated => PolicyPreflightState::Allowed, - 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 agent_policy_event( &self, @@ -1311,6 +1294,29 @@ fn policy_denied_action_result( } } +#[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, + AgentPolicyDecisionKind::Ask if is_user_initiated => PolicyPreflightState::Allowed, + 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_consume_completed_policy_preflight(state: &PolicyPreflightState) -> bool { + !matches!(state, PolicyPreflightState::NeedsConfirmation(_)) +} + #[cfg(not(target_family = "wasm"))] fn policy_denied_message(decision: &AgentPolicyEffectiveDecision) -> String { if let Some(denial) = decision diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 2ef796854..268946dc2 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -119,7 +119,8 @@ mod policy_hooks { use super::super::{ agent_policy_action, normalize_command_for_policy, policy_denied_action_result, - warp_permission_snapshot_for_policy, + policy_preflight_state_from_decision, should_consume_completed_policy_preflight, + warp_permission_snapshot_for_policy, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -175,6 +176,34 @@ mod policy_hooks { assert_eq!(snapshot.decision, WarpPermissionDecisionKind::Deny); } + #[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); + assert!(should_consume_completed_policy_preflight(&confirmed)); + } + #[test] fn write_file_policy_action_omits_unavailable_diff_stats() { let action = AIAgentAction { diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index 40cda7bb3..75c2d3fc9 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use super::redaction::redact_sensitive_text_for_policy; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AgentPolicyDecisionKind { @@ -111,10 +113,10 @@ impl AgentPolicyHookEvaluation { response: AgentPolicyHookResponse, ) -> Self { Self { - hook_name: hook_name.into(), + hook_name: sanitize_policy_string(hook_name.into()), decision: response.decision, - reason: response.reason, - external_audit_id: response.external_audit_id, + reason: response.reason.map(sanitize_policy_string), + external_audit_id: response.external_audit_id.map(sanitize_policy_string), error: None, } } @@ -126,15 +128,19 @@ impl AgentPolicyHookEvaluation { reason: impl Into, ) -> Self { Self { - hook_name: hook_name.into(), + hook_name: sanitize_policy_string(hook_name.into()), decision, - reason: Some(reason.into()), + 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, diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index bdbbcb8f7..361f02e4f 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -94,7 +94,10 @@ impl AgentPolicyHookEngine { }; match response { - Ok(response) => AgentPolicyHookEvaluation::from_response(hook.name.clone(), response), + Ok(response) => AgentPolicyHookEvaluation::from_response( + hook.name.clone(), + redact_hook_response_configured_secrets(response, hook), + ), Err(failure) => AgentPolicyHookEvaluation::unavailable( hook.name.clone(), self.config.hook_unavailable_decision(hook).decision_kind(), @@ -138,41 +141,41 @@ impl AgentPolicyHookEngine { command.env(key, value.as_str()); } - let mut child = command.spawn().map_err(|source| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::SpawnFailed, - detail: format!("failed to spawn policy hook: {source}"), - })?; - let event_bytes = serialize_event(event).map_err(|source| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::MalformedResponse, detail: format!("failed to serialize policy event: {source}"), })?; - 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 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(), @@ -276,7 +279,7 @@ impl AgentPolicyHookEngine { })? .map_err(|source| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::HttpRequestFailed, - detail: format!("failed to call HTTP policy hook: {source}"), + detail: format!("failed to call HTTP policy hook: {}", source.without_url()), })?; let status = response.status(); @@ -374,7 +377,10 @@ async fn read_capped_http_response( 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}"), + detail: format!( + "failed to read HTTP policy hook response: {}", + source.without_url() + ), })?; if output.len().saturating_add(chunk.len()) > MAX_HOOK_OUTPUT_BYTES { @@ -392,14 +398,57 @@ async fn read_capped_http_response( fn redact_hook_stderr(stderr: &[u8], env: &BTreeMap) -> String { let stderr = String::from_utf8_lossy(stderr); - let mut redacted = stderr.trim().to_string(); - for value in env.values() { + 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_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 secret = value.as_str(); if !secret.is_empty() { redacted = redacted.replace(secret, ""); } + if let Some((scheme, credential)) = secret.split_once(' ') { + if scheme.eq_ignore_ascii_case("bearer") && credential.len() >= 4 { + redacted = redacted.replace(credential, ""); + } + } } - redact_sensitive_text_for_policy(&redacted) + redacted } fn serialize_event(event: &AgentPolicyEvent) -> Result> { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 51d8592d4..1ed7a3d41 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; use serde_json::json; @@ -6,11 +9,12 @@ use super::{ config::AgentPolicyHookConfig, decision::{ compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyHookErrorKind, - AgentPolicyHookEvaluation, AgentPolicyUnavailableDecision, WarpPermissionSnapshot, + AgentPolicyHookEvaluation, AgentPolicyHookResponse, AgentPolicyUnavailableDecision, + WarpPermissionSnapshot, }, event::{ - AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, - AGENT_POLICY_SCHEMA_VERSION, + AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + PolicyReadFilesAction, AGENT_POLICY_SCHEMA_VERSION, }, }; @@ -183,6 +187,31 @@ fn policy_decision_composition_keeps_denials_terminal() { 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".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("sk-secretsecretsecret")); + 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() { @@ -344,6 +373,49 @@ async fn stdio_engine_rejects_oversized_stdout() { .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(160); + let paths = (0..15_000) + .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 }), + ); + + 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(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn stdio_engine_redacts_configured_secret_stderr() { @@ -384,6 +456,48 @@ async fn stdio_engine_redacts_configured_secret_stderr() { assert!(!reason.contains("super-secret-token")); } +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_redacts_configured_secret_hook_reason() { + 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": "super-secret-token" }, + "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("super-secret-token")); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit-") + ); +} + #[cfg(not(target_family = "wasm"))] #[tokio::test] async fn http_engine_can_deny_before_action() { @@ -487,6 +601,60 @@ async fn http_engine_rejects_oversized_response_body() { .contains("response exceeded")); } +#[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 hook_response = json!({ + "schema_version": AGENT_POLICY_SCHEMA_VERSION, + "decision": "deny", + "reason": "raw token super-secret-token", + "external_audit_id": "audit-super-secret-token" + }) + .to_string(); + let mock = server + .mock("POST", "/policy") + .match_header("authorization", "Bearer super-secret-token") + .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": "Bearer super-secret-token" }, + "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("super-secret-token")); + assert_eq!( + decision.hook_results[0].external_audit_id.as_deref(), + Some("audit-") + ); +} + #[cfg(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn engine_maps_invalid_enabled_config_to_unavailable_policy() { From e31aec2c18eb66487aa3453c6ff9dc379f10e32f Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 16:05:45 +0300 Subject: [PATCH 06/40] Address policy hook audit findings --- app/src/ai/execution_profiles/mod.rs | 1 + app/src/ai/policy_hooks/engine.rs | 11 ++++- app/src/ai/policy_hooks/event.rs | 1 + app/src/ai/policy_hooks/redaction.rs | 2 +- app/src/ai/policy_hooks/tests.rs | 74 ++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/src/ai/execution_profiles/mod.rs b/app/src/ai/execution_profiles/mod.rs index c40b3c687..d63c67fa9 100644 --- a/app/src/ai/execution_profiles/mod.rs +++ b/app/src/ai/execution_profiles/mod.rs @@ -338,6 +338,7 @@ impl AIExecutionProfile { context_window_limit: None, autosync_plans_to_warp_drive: false, web_search_enabled: true, + agent_policy_hooks: AgentPolicyHookConfig::default(), } } diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 361f02e4f..cbe51ea36 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -258,7 +258,16 @@ impl AgentPolicyHookEngine { detail: format!("failed to serialize policy event: {source}"), })?; - let client = reqwest::Client::new(); + 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") diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index c6c7b25e4..2f51f1493 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -49,6 +49,7 @@ impl AgentPolicyEvent { } } + #[cfg(test)] pub(crate) fn execute_command( conversation_id: impl Into, action_id: impl Into, diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index b1d4febd8..764d67e0e 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -5,7 +5,7 @@ pub(crate) const MAX_POLICY_STRING_BYTES: usize = 8 * 1024; 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;&|]+)", + r#"(?i)\b([A-Z0-9_.-]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY)[A-Z0-9_.-]*)=("(?:[^"\\]|\\.)*"|"(?:[^;&|]*)|'(?:[^'\\]|\\.)*'|'(?:[^;&|]*)|[^\s;&|]+)"#, ) .expect("secret assignment regex should compile") }); diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 1ed7a3d41..5e59d9972 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -16,6 +16,7 @@ use super::{ AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, AGENT_POLICY_SCHEMA_VERSION, }, + redaction::redact_command_for_policy, }; #[cfg(not(target_family = "wasm"))] @@ -115,6 +116,28 @@ fn event_serializes_redacted_command_shape() { assert_eq!(value["action"]["is_risky"], true); } +#[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 mcp_tool_action_preserves_only_argument_keys() { let action = PolicyCallMcpToolAction::new( @@ -601,6 +624,57 @@ async fn http_engine_rejects_oversized_response_body() { .contains("response exceeded")); } +#[cfg(not(target_family = "wasm"))] +#[tokio::test] +async fn http_engine_does_not_follow_redirects() { + let mut server = mockito::Server::new_async().await; + 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": "Bearer super-secret-token" }, + "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() { From a83865e094113356f25899470f802ea5fc68b272 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 16:24:38 +0300 Subject: [PATCH 07/40] Harden policy hook preflight boundaries --- app/src/ai/blocklist/action_model/execute.rs | 117 ++++++++++++++++-- .../blocklist/action_model/execute_tests.rs | 33 ++++- app/src/ai/policy_hooks/config.rs | 4 +- app/src/ai/policy_hooks/tests.rs | 32 +++++ 4 files changed, 173 insertions(+), 13 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 1c7b594ec..cfe612938 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -32,7 +32,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; @@ -113,10 +113,10 @@ use crate::{ use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; #[cfg(not(target_family = "wasm"))] use crate::ai::policy_hooks::{ - AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, - AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, - PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, - WarpPermissionSnapshot, + decision::WarpPermissionDecisionKind, AgentPolicyAction, AgentPolicyDecisionKind, + AgentPolicyEffectiveDecision, AgentPolicyEvent, AgentPolicyHookEngine, PolicyCallMcpToolAction, + PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, + PolicyWriteFilesAction, WarpPermissionSnapshot, }; /// Types of actions that can be executed in parallel. @@ -194,7 +194,7 @@ enum AnyActionExecution { #[derive(Debug, PartialEq)] enum PolicyPreflightState { Pending, - Allowed, + Allowed { skip_confirmation: bool }, NeedsConfirmation(Option), Denied(AIAgentActionResultType), } @@ -497,6 +497,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 { .. } @@ -609,6 +614,7 @@ impl BlocklistAIActionExecutor { let needs_confirmation = !(is_user_initiated || can_auto_execute || (is_agent_autonomous && action.action.is_request_command_output())); + let mut skip_confirmation = false; #[cfg(not(target_family = "wasm"))] if let Some(preflight_state) = self.start_policy_preflight_if_needed( &action, @@ -649,10 +655,14 @@ impl BlocklistAIActionExecutor { return TryExecuteResult::ExecutedSync; } - PolicyPreflightState::Allowed => {} + PolicyPreflightState::Allowed { + skip_confirmation: policy_skip_confirmation, + } => { + skip_confirmation = policy_skip_confirmation; + } } } - if needs_confirmation { + if needs_confirmation && !skip_confirmation { return TryExecuteResult::NotExecuted { action: Box::new(action), reason: NotExecutedReason::NeedsConfirmation { @@ -884,6 +894,89 @@ impl BlocklistAIActionExecutor { ) } + #[cfg(not(target_family = "wasm"))] + fn preprocess_request_file_edits_after_policy( + &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() { + return None; + } + + let can_auto_execute = self.should_autoexecute( + ExecuteActionInput { + action: input.action, + conversation_id: input.conversation_id, + }, + ctx, + ); + let warp_permission = + warp_permission_snapshot_for_policy(false, can_auto_execute, !can_auto_execute, false); + let event = self.agent_policy_event( + input.action, + input.conversation_id, + Some(active_profile.id().to_string()), + warp_permission.clone(), + ctx, + )?; + + let action = input.action.clone(); + let action_id = action.id.clone(); + let conversation_id = input.conversation_id; + let (done_tx, done_rx) = oneshot::channel(); + let engine = AgentPolicyHookEngine::new(config); + + ctx.spawn( + async move { engine.preflight(event, warp_permission).await }, + move |me, decision, ctx| { + let denied = matches!( + decision.decision, + AgentPolicyDecisionKind::Deny | AgentPolicyDecisionKind::Unknown + ); + me.completed_policy_preflights + .insert(action_id.clone(), decision); + + if denied { + 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"))] #[allow(clippy::too_many_arguments)] fn start_policy_preflight_if_needed( @@ -1301,8 +1394,12 @@ fn policy_preflight_state_from_decision( is_user_initiated: bool, ) -> PolicyPreflightState { match decision.decision { - AgentPolicyDecisionKind::Allow => PolicyPreflightState::Allowed, - AgentPolicyDecisionKind::Ask if is_user_initiated => PolicyPreflightState::Allowed, + 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()) } diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 268946dc2..74b4d44b3 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -200,10 +200,41 @@ mod policy_hooks { assert!(!should_consume_completed_policy_preflight(&unconfirmed)); let confirmed = policy_preflight_state_from_decision(&action, &decision, true); - assert_eq!(confirmed, PolicyPreflightState::Allowed); + 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 write_file_policy_action_omits_unavailable_diff_stats() { let action = AIAgentAction { diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 3655cf163..34a48efa0 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -126,14 +126,14 @@ pub(crate) enum AgentPolicyHookTransport { command: String, #[serde(default)] args: Vec, - #[serde(default)] + #[serde(default, skip_serializing)] env: BTreeMap, #[serde(default)] working_directory: Option, }, Http { url: String, - #[serde(default)] + #[serde(default, skip_serializing)] headers: BTreeMap, }, } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 5e59d9972..79fa4e176 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -86,6 +86,38 @@ fn config_rejects_non_https_remote_http_hooks() { assert!(localhost_config.validate().is_ok()); } +#[test] +fn config_serialization_omits_hook_secret_values() { + let config: AgentPolicyHookConfig = 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" } + } + ] + })) + .unwrap(); + + let value = serde_json::to_value(&config).unwrap(); + let stdio_hook = value["before_action"][0].as_object().unwrap(); + let http_hook = value["before_action"][1].as_object().unwrap(); + let serialized = value.to_string(); + + assert!(!stdio_hook.contains_key("env")); + assert!(!http_hook.contains_key("headers")); + assert!(!serialized.contains("super-secret-token")); + assert!(!serialized.contains("Bearer")); +} + #[test] fn event_serializes_redacted_command_shape() { let event = AgentPolicyEvent::execute_command( From 0db91acb543ac4029385592dfb9e7f246b5de529 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 17:03:43 +0300 Subject: [PATCH 08/40] Close policy hook review gaps --- app/src/ai/blocklist/action_model.rs | 4 +- app/src/ai/blocklist/action_model/execute.rs | 75 +++++++---- .../blocklist/action_model/execute_tests.rs | 23 +++- app/src/ai/policy_hooks/config.rs | 56 +++++--- app/src/ai/policy_hooks/engine.rs | 21 ++- app/src/ai/policy_hooks/tests.rs | 121 +++++++++++++++--- 6 files changed, 226 insertions(+), 74 deletions(-) diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 542def6e9..3107d92bb 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -992,7 +992,7 @@ impl BlocklistAIActionModel { { if let Some(action) = pending_actions_for_conversation.remove(idx) { self.executor.update(ctx, |executor, _ctx| { - executor.cancel_policy_preflight_for_action(&action.id); + executor.cancel_policy_preflight_for_action(conversation_id, &action.id); }); self.cancel_pending_action(conversation_id, action, Some(reason), ctx); } @@ -1015,7 +1015,7 @@ impl BlocklistAIActionModel { }; for action in actions_to_cancel.drain(..).collect_vec() { self.executor.update(ctx, |executor, _ctx| { - executor.cancel_policy_preflight_for_action(&action.id); + 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 cfe612938..35004ff89 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -148,6 +148,23 @@ 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, +} + +#[cfg(not(target_family = "wasm"))] +impl PolicyPreflightKey { + fn new(conversation_id: AIConversationId, action_id: AIAgentActionId) -> Self { + Self { + conversation_id, + action_id, + } + } +} + type AsyncExecuteActionFn = Pin>>; type OnCompleteFn = Box AIAgentActionResultType>; @@ -305,11 +322,11 @@ pub struct BlocklistAIActionExecutor { /// parallel phase can complete independently. async_executing_actions: HashMap, #[cfg(not(target_family = "wasm"))] - pending_policy_preflights: HashSet, + pending_policy_preflights: HashSet, #[cfg(not(target_family = "wasm"))] - user_initiated_policy_preflights: HashSet, + user_initiated_policy_preflights: HashSet, #[cfg(not(target_family = "wasm"))] - completed_policy_preflights: HashMap, + completed_policy_preflights: HashMap, /// Reference to the terminal model for checking session sharing state. terminal_model: Arc>, @@ -934,8 +951,8 @@ impl BlocklistAIActionExecutor { )?; let action = input.action.clone(); - let action_id = action.id.clone(); let conversation_id = input.conversation_id; + let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); let (done_tx, done_rx) = oneshot::channel(); let engine = AgentPolicyHookEngine::new(config); @@ -947,7 +964,7 @@ impl BlocklistAIActionExecutor { AgentPolicyDecisionKind::Deny | AgentPolicyDecisionKind::Unknown ); me.completed_policy_preflights - .insert(action_id.clone(), decision); + .insert(preflight_key.clone(), decision); if denied { let _ = done_tx.send(()); @@ -994,29 +1011,32 @@ impl BlocklistAIActionExecutor { 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; + let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); if !config.is_active() { - self.pending_policy_preflights.remove(&action.id); - self.user_initiated_policy_preflights.remove(&action.id); - self.completed_policy_preflights.remove(&action.id); + self.pending_policy_preflights.remove(&preflight_key); + self.user_initiated_policy_preflights.remove(&preflight_key); + self.completed_policy_preflights.remove(&preflight_key); return None; } - if let Some(decision) = self.completed_policy_preflights.get(&action.id) { - let user_confirmed = - is_user_initiated || self.user_initiated_policy_preflights.contains(&action.id); + if let Some(decision) = self.completed_policy_preflights.get(&preflight_key) { + let user_confirmed = is_user_initiated + || self + .user_initiated_policy_preflights + .contains(&preflight_key); let state = policy_preflight_state_from_decision(action, decision, user_confirmed); if should_consume_completed_policy_preflight(&state) { - self.completed_policy_preflights.remove(&action.id); - self.user_initiated_policy_preflights.remove(&action.id); + self.completed_policy_preflights.remove(&preflight_key); + self.user_initiated_policy_preflights.remove(&preflight_key); } return Some(state); } - if self.pending_policy_preflights.contains(&action.id) { + if self.pending_policy_preflights.contains(&preflight_key) { if is_user_initiated { self.user_initiated_policy_preflights - .insert(action.id.clone()); + .insert(preflight_key.clone()); } return Some(PolicyPreflightState::Pending); } @@ -1035,21 +1055,21 @@ impl BlocklistAIActionExecutor { ctx, )?; - let action_id = action.id.clone(); - self.pending_policy_preflights.insert(action_id.clone()); + self.pending_policy_preflights.insert(preflight_key.clone()); if is_user_initiated { self.user_initiated_policy_preflights - .insert(action_id.clone()); + .insert(preflight_key.clone()); } let engine = AgentPolicyHookEngine::new(config); ctx.spawn( async move { engine.preflight(event, warp_permission).await }, move |me, decision, ctx| { - if !me.pending_policy_preflights.remove(&action_id) { - me.user_initiated_policy_preflights.remove(&action_id); + if !me.pending_policy_preflights.remove(&preflight_key) { + me.user_initiated_policy_preflights.remove(&preflight_key); return; } - me.completed_policy_preflights.insert(action_id, decision); + me.completed_policy_preflights + .insert(preflight_key, decision); ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { conversation_id, }); @@ -1059,12 +1079,17 @@ impl BlocklistAIActionExecutor { Some(PolicyPreflightState::Pending) } - pub fn cancel_policy_preflight_for_action(&mut self, action_id: &AIAgentActionId) { + pub fn cancel_policy_preflight_for_action( + &mut self, + conversation_id: AIConversationId, + action_id: &AIAgentActionId, + ) { #[cfg(not(target_family = "wasm"))] { - self.pending_policy_preflights.remove(action_id); - self.user_initiated_policy_preflights.remove(action_id); - self.completed_policy_preflights.remove(action_id); + let preflight_key = PolicyPreflightKey::new(conversation_id, action_id.clone()); + self.pending_policy_preflights.remove(&preflight_key); + self.user_initiated_policy_preflights.remove(&preflight_key); + self.completed_policy_preflights.remove(&preflight_key); } } diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 74b4d44b3..a05e19797 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -100,12 +100,14 @@ mod binary_detection { #[cfg(not(target_family = "wasm"))] mod policy_hooks { + use std::collections::HashSet; + use crate::{ ai::{ agent::task::TaskId, agent::{ - AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, - FileEdit, RequestCommandOutputResult, + conversation::AIConversationId, AIAgentAction, AIAgentActionId, + AIAgentActionResultType, AIAgentActionType, FileEdit, RequestCommandOutputResult, }, policy_hooks::{ decision::{ @@ -120,7 +122,7 @@ mod policy_hooks { use super::super::{ agent_policy_action, normalize_command_for_policy, policy_denied_action_result, policy_preflight_state_from_decision, should_consume_completed_policy_preflight, - warp_permission_snapshot_for_policy, PolicyPreflightState, + warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -235,6 +237,21 @@ mod policy_hooks { ); } + #[test] + fn policy_preflight_key_scopes_same_action_id_by_conversation() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let conversation_one = AIConversationId::new(); + let conversation_two = AIConversationId::new(); + let key_one = PolicyPreflightKey::new(conversation_one, action_id.clone()); + let key_two = PolicyPreflightKey::new(conversation_two, action_id); + + assert_ne!(key_one, key_two); + + let mut pending = HashSet::new(); + pending.insert(key_one); + assert!(!pending.contains(&key_two)); + } + #[test] fn write_file_policy_action_omits_unavailable_diff_stats() { let action = AIAgentAction { diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 34a48efa0..7d914ebab 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -36,7 +36,7 @@ impl Default for AgentPolicyHookConfig { impl AgentPolicyHookConfig { pub(crate) fn is_active(&self) -> bool { - self.enabled && !self.before_action.is_empty() + self.enabled } pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { @@ -126,14 +126,14 @@ pub(crate) enum AgentPolicyHookTransport { command: String, #[serde(default)] args: Vec, - #[serde(default, skip_serializing)] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] env: BTreeMap, #[serde(default)] working_directory: Option, }, Http { url: String, - #[serde(default, skip_serializing)] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] headers: BTreeMap, }, } @@ -143,12 +143,14 @@ impl AgentPolicyHookTransport { match self { Self::Stdio { command, + env, working_directory, .. } => { if command.trim().is_empty() { return Err(AgentPolicyHookConfigError::MissingStdioCommand); } + validate_secret_value_map(env)?; if working_directory .as_deref() @@ -159,7 +161,7 @@ impl AgentPolicyHookTransport { )); } } - Self::Http { url, .. } => { + Self::Http { url, headers } => { let parsed = url::Url::parse(url) .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; @@ -169,6 +171,8 @@ impl AgentPolicyHookTransport { if parsed.scheme() != "https" && !is_allowed_local_http { return Err(AgentPolicyHookConfigError::InsecureHttpUrl(url.clone())); } + + validate_secret_value_map(headers)?; } } @@ -176,36 +180,46 @@ impl AgentPolicyHookTransport { } } +/// 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, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct AgentPolicyHookSecretValue(String); +#[serde(deny_unknown_fields)] +pub(crate) struct AgentPolicyHookSecretValue { + env: String, +} impl AgentPolicyHookSecretValue { - pub(crate) fn new(value: impl Into) -> Self { - Self(value.into()) + #[cfg(not(target_family = "wasm"))] + pub(crate) fn resolved_value(&self) -> Result { + std::env::var(&self.env).map_err(|_| self.env.clone()) } - pub(crate) fn as_str(&self) -> &str { - &self.0 + #[cfg(target_family = "wasm")] + pub(crate) fn resolved_value(&self) -> Result { + Err(self.env.clone()) } -} -impl fmt::Debug for AgentPolicyHookSecretValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("\"\"") + fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + if self.env.trim().is_empty() { + return Err(AgentPolicyHookConfigError::MissingSecretEnvironmentVariableName); + } + Ok(()) } } -impl From for AgentPolicyHookSecretValue { - fn from(value: String) -> Self { - Self::new(value) +impl fmt::Debug for AgentPolicyHookSecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Env").field("env", &self.env).finish() } } -impl From<&str> for AgentPolicyHookSecretValue { - fn from(value: &str) -> Self { - Self::new(value) +fn validate_secret_value_map( + values: &BTreeMap, +) -> Result<(), AgentPolicyHookConfigError> { + for value in values.values() { + value.validate()?; } + Ok(()) } #[derive(Debug, Error, PartialEq, Eq)] @@ -226,6 +240,8 @@ pub(crate) enum AgentPolicyHookConfigError { InvalidHttpUrl(String), #[error("agent policy hook HTTP URL must use HTTPS unless it targets localhost: {0}")] InsecureHttpUrl(String), + #[error("agent policy hook secret environment variable name must not be empty")] + MissingSecretEnvironmentVariableName, } fn validate_timeout_ms(timeout_ms: u64) -> Result<(), AgentPolicyHookConfigError> { diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index cbe51ea36..2b3539630 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -138,7 +138,7 @@ impl AgentPolicyHookEngine { } for (key, value) in env { - command.env(key, value.as_str()); + command.env(key, resolve_hook_secret_value(value)?); } let event_bytes = serialize_event(event).map_err(|source| AgentPolicyHookFailure { @@ -274,7 +274,7 @@ impl AgentPolicyHookEngine { .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(), value.as_str()); + request = request.header(key.as_str(), resolve_hook_secret_value(value)?); } let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); @@ -447,9 +447,11 @@ fn redact_configured_secret_values<'a>( ) -> String { let mut redacted = value.to_string(); for value in secrets { - let secret = value.as_str(); + let Ok(secret) = value.resolved_value() else { + continue; + }; if !secret.is_empty() { - redacted = redacted.replace(secret, ""); + redacted = redacted.replace(&secret, ""); } if let Some((scheme, credential)) = secret.split_once(' ') { if scheme.eq_ignore_ascii_case("bearer") && credential.len() >= 4 { @@ -460,6 +462,17 @@ fn redact_configured_secret_values<'a>( 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"), + }) +} + fn serialize_event(event: &AgentPolicyEvent) -> Result> { serde_json::to_vec(event).context("serialize policy event") } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 79fa4e176..a0faa2c6b 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -19,6 +19,14 @@ use super::{ redaction::redact_command_for_policy, }; +#[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"))] @@ -35,6 +43,18 @@ fn config_defaults_to_disabled_and_ask_on_unavailable() { 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_deserializes_stdio_hook_shape() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -87,8 +107,8 @@ fn config_rejects_non_https_remote_http_hooks() { } #[test] -fn config_serialization_omits_hook_secret_values() { - let config: AgentPolicyHookConfig = serde_json::from_value(json!({ +fn config_rejects_inline_hook_secret_values() { + let config = serde_json::from_value::(json!({ "enabled": true, "before_action": [ { @@ -104,18 +124,44 @@ fn config_serialization_omits_hook_secret_values() { "headers": { "authorization": "Bearer super-secret-token" } } ] + })); + + assert!(config.is_err()); +} + +#[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(); - let stdio_hook = value["before_action"][0].as_object().unwrap(); - let http_hook = value["before_action"][1].as_object().unwrap(); - let serialized = value.to_string(); - - assert!(!stdio_hook.contains_key("env")); - assert!(!http_hook.contains_key("headers")); - assert!(!serialized.contains("super-secret-token")); - assert!(!serialized.contains("Bearer")); + 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] @@ -474,6 +520,7 @@ async fn stdio_engine_times_out_blocked_stdin_write() { #[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", @@ -482,7 +529,7 @@ async fn stdio_engine_redacts_configured_secret_stderr() { "transport": "stdio", "command": "sh", "args": ["-c", "cat >/dev/null; printf '%s\\n' \"$API_TOKEN\" >&2; exit 42"], - "env": { "API_TOKEN": "super-secret-token" }, + "env": { "API_TOKEN": { "env": secret_env } }, "timeout_ms": 1000 }] })) @@ -508,12 +555,13 @@ async fn stdio_engine_redacts_configured_secret_stderr() { Some(AgentPolicyHookErrorKind::NonZeroExit) ); assert!(reason.contains("")); - assert!(!reason.contains("super-secret-token")); + 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": [{ @@ -524,7 +572,7 @@ async fn stdio_engine_redacts_configured_secret_hook_reason() { "-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": "super-secret-token" }, + "env": { "API_TOKEN": { "env": secret_env } }, "timeout_ms": 1000 }] })) @@ -546,7 +594,7 @@ async fn stdio_engine_redacts_configured_secret_hook_reason() { let reason = decision.hook_results[0].reason.as_deref().unwrap(); assert!(reason.contains("")); - assert!(!reason.contains("super-secret-token")); + assert!(!reason.contains(&secret_value)); assert_eq!( decision.hook_results[0].external_audit_id.as_deref(), Some("audit-") @@ -660,6 +708,7 @@ async fn http_engine_rejects_oversized_response_body() { #[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") @@ -674,7 +723,7 @@ async fn http_engine_does_not_follow_redirects() { "name": "http-guard", "transport": "http", "url": format!("{}/policy", server.url()), - "headers": { "authorization": "Bearer super-secret-token" }, + "headers": { "authorization": { "env": secret_env } }, "timeout_ms": 1000 }] })) @@ -711,16 +760,17 @@ async fn http_engine_does_not_follow_redirects() { #[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": "raw token super-secret-token", - "external_audit_id": "audit-super-secret-token" + "reason": format!("raw token {secret_value}"), + "external_audit_id": format!("audit-{secret_value}") }) .to_string(); let mock = server .mock("POST", "/policy") - .match_header("authorization", "Bearer super-secret-token") + .match_header("authorization", secret_value.as_str()) .with_status(200) .with_body(hook_response) .create_async() @@ -731,7 +781,7 @@ async fn http_engine_redacts_configured_header_secret_hook_reason() { "name": "http-guard", "transport": "http", "url": format!("{}/policy", server.url()), - "headers": { "authorization": "Bearer super-secret-token" }, + "headers": { "authorization": { "env": secret_env } }, "timeout_ms": 1000 }] })) @@ -754,13 +804,44 @@ async fn http_engine_redacts_configured_header_secret_hook_reason() { mock.assert_async().await; let reason = decision.hook_results[0].reason.as_deref().unwrap(); assert!(reason.contains("")); - assert!(!reason.contains("super-secret-token")); + 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 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() { From 2a693ec743cb9be61cd15672472d6ad8ad6cf870 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 17:20:36 +0300 Subject: [PATCH 09/40] Tighten policy hook cancellation and timeout --- app/src/ai/blocklist/action_model/execute.rs | 39 +++++++++--- .../blocklist/action_model/execute_tests.rs | 41 +++++++++++-- app/src/ai/policy_hooks/engine.rs | 54 +++++++++-------- app/src/ai/policy_hooks/tests.rs | 60 +++++++++++++++++++ 4 files changed, 157 insertions(+), 37 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 35004ff89..7a075ef95 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -499,7 +499,7 @@ impl BlocklistAIActionExecutor { } pub fn preprocess_action( - &self, + &mut self, action: &AIAgentAction, conversation_id: AIConversationId, ctx: &mut ModelContext, @@ -913,7 +913,7 @@ impl BlocklistAIActionExecutor { #[cfg(not(target_family = "wasm"))] fn preprocess_request_file_edits_after_policy( - &self, + &mut self, input: PreprocessActionInput, ctx: &mut ModelContext, ) -> Option> { @@ -955,6 +955,7 @@ impl BlocklistAIActionExecutor { let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); let (done_tx, done_rx) = oneshot::channel(); let engine = AgentPolicyHookEngine::new(config); + self.pending_policy_preflights.insert(preflight_key.clone()); ctx.spawn( async move { engine.preflight(event, warp_permission).await }, @@ -963,8 +964,15 @@ impl BlocklistAIActionExecutor { decision.decision, AgentPolicyDecisionKind::Deny | AgentPolicyDecisionKind::Unknown ); - me.completed_policy_preflights - .insert(preflight_key.clone(), 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 denied { let _ = done_tx.send(()); @@ -1064,12 +1072,15 @@ impl BlocklistAIActionExecutor { ctx.spawn( async move { engine.preflight(event, warp_permission).await }, move |me, decision, ctx| { - if !me.pending_policy_preflights.remove(&preflight_key) { + if !complete_policy_preflight_if_pending( + &mut me.pending_policy_preflights, + &mut me.completed_policy_preflights, + preflight_key.clone(), + decision, + ) { me.user_initiated_policy_preflights.remove(&preflight_key); return; } - me.completed_policy_preflights - .insert(preflight_key, decision); ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { conversation_id, }); @@ -1439,6 +1450,20 @@ fn should_consume_completed_policy_preflight(state: &PolicyPreflightState) -> bo !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 diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index a05e19797..28348360d 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -100,7 +100,7 @@ mod binary_detection { #[cfg(not(target_family = "wasm"))] mod policy_hooks { - use std::collections::HashSet; + use std::collections::{HashMap, HashSet}; use crate::{ ai::{ @@ -120,9 +120,10 @@ mod policy_hooks { }; use super::super::{ - agent_policy_action, normalize_command_for_policy, policy_denied_action_result, - policy_preflight_state_from_decision, should_consume_completed_policy_preflight, - warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, + agent_policy_action, complete_policy_preflight_if_pending, normalize_command_for_policy, + policy_denied_action_result, policy_preflight_state_from_decision, + should_consume_completed_policy_preflight, warp_permission_snapshot_for_policy, + PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -252,6 +253,38 @@ mod policy_hooks { assert!(!pending.contains(&key_two)); } + #[test] + fn cancelled_policy_preflight_completion_is_not_cached() { + let action_id = AIAgentActionId::from("action_1".to_string()); + let preflight_key = PolicyPreflightKey::new(AIConversationId::new(), action_id); + 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 { diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 2b3539630..07fb8b608 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -278,34 +278,36 @@ impl AgentPolicyHookEngine { } let timeout = Duration::from_millis(self.config.hook_timeout_ms(hook)); - let response = request - .send() - .with_timeout(timeout) - .await - .map_err(|_| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::Timeout, - detail: format!("policy hook timed out after {timeout:?}"), - })? - .map_err(|source| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::HttpRequestFailed, - detail: format!("failed to call HTTP policy hook: {}", source.without_url()), - })?; + 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}"), - }); - } + let status = response.status(); + if !status.is_success() { + return Err(AgentPolicyHookFailure { + kind: AgentPolicyHookErrorKind::HttpStatus, + detail: format!("HTTP policy hook returned status {status}"), + }); + } - let response_bytes = read_capped_http_response(response) - .with_timeout(timeout) - .await - .map_err(|_| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::Timeout, - detail: format!("policy hook response timed out after {timeout:?}"), - })??; + 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, diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index a0faa2c6b..158c7140a 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -704,6 +704,66 @@ async fn http_engine_rejects_oversized_response_body() { .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() { From 6c09a02d05c24da8d2ef3c0629ce00e075c44e8f Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 17:39:06 +0300 Subject: [PATCH 10/40] Preserve Warp denials in policy hook preflight --- app/src/ai/blocklist/action_model/execute.rs | 186 +++++++++++++++++- .../blocklist/action_model/execute_tests.rs | 36 +++- app/src/ai/policy_hooks/config.rs | 6 + app/src/ai/policy_hooks/tests.rs | 20 ++ specs/GH9914/product.md | 2 +- specs/GH9914/tech.md | 9 +- 6 files changed, 249 insertions(+), 10 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 7a075ef95..32762961f 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -88,6 +88,16 @@ use mime_guess::from_path; use self::search_codebase::SearchCodebaseExecutor; #[cfg(feature = "local_fs")] 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::{ @@ -911,6 +921,161 @@ 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 escape_char = self + .active_session + .as_ref(ctx) + .shell_type(ctx) + .map(|shell_type| ShellFamily::from(shell_type).escape_char())?; + let permission = 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, + ); + + match permission { + CommandExecutionPermission::Denied( + CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted, + ) => Some("command is explicitly denylisted by Warp permissions".to_string()), + _ => None, + } + } + AIAgentActionType::RequestFileEdits { file_edits, .. } => { + let paths = file_edits + .iter() + .filter_map(|edit| edit.file().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, @@ -940,8 +1105,15 @@ impl BlocklistAIActionExecutor { }, ctx, ); - let warp_permission = - warp_permission_snapshot_for_policy(false, can_auto_execute, !can_auto_execute, false); + 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, @@ -1049,11 +1221,14 @@ impl BlocklistAIActionExecutor { return Some(PolicyPreflightState::Pending); } - let warp_permission = warp_permission_snapshot_for_policy( + 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, @@ -1273,7 +1448,12 @@ fn warp_permission_snapshot_for_policy( 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(), diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 28348360d..5a09bfa3d 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -111,7 +111,8 @@ mod policy_hooks { }, policy_hooks::{ decision::{ - AgentPolicyHookEvaluation, WarpPermissionDecisionKind, WarpPermissionSnapshot, + compose_policy_decisions, AgentPolicyHookEvaluation, + WarpPermissionDecisionKind, WarpPermissionSnapshot, }, AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, }, @@ -174,11 +175,42 @@ mod policy_hooks { #[test] fn warp_permission_snapshot_marks_autonomous_denials_terminal() { - let snapshot = warp_permission_snapshot_for_policy(false, false, false, true); + 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 cached_ask_policy_decision_is_retained_until_user_confirmation() { let action = command_action("rm -rf target"); diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 7d914ebab..9a3f3a2ef 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -165,6 +165,10 @@ impl AgentPolicyHookTransport { let parsed = url::Url::parse(url) .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); + } + 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; @@ -240,6 +244,8 @@ pub(crate) enum AgentPolicyHookConfigError { 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 secret environment variable name must not be empty")] MissingSecretEnvironmentVariableName, } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 158c7140a..d50ed056b 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -106,6 +106,26 @@ fn config_rejects_non_https_remote_http_hooks() { 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", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": url + }] + })) + .unwrap(); + + assert!(config.validate().is_err()); + } +} + #[test] fn config_rejects_inline_hook_secret_values() { let config = serde_json::from_value::(json!({ diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index b2fd62dbb..678b1bc5c 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -93,7 +93,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. -9. Hook payloads do not include file contents, secret values, full environment variables, access tokens, or unbounded command output by default. +9. Hook payloads do not include file contents, secret values, full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. 10. 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. 11. Warp records a redacted audit event for every governed decision, including hook name, decision, reason, action id, conversation id, timestamp, and policy event id. 12. The agent receives a structured denial or ask result and can continue planning around it. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index f1c95576e..2989bfe7d 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -38,12 +38,12 @@ pub enum AgentPolicyHookTransport { Stdio { command: String, args: Vec, - env: BTreeMap, + env: BTreeMap, working_directory: Option, }, Http { url: String, - headers: BTreeMap, + headers: BTreeMap, }, } @@ -171,7 +171,7 @@ Suggested storage strategy: 1. Add optional `agent_policy_hooks` to `AIExecutionProfile`. 2. Keep default disabled so old profiles deserialize unchanged. -3. Redact secret-like config values the same way MCP server config redacts environment variables when shared. +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. Surface minimal settings UI after the engine exists: enabled toggle, hook list, timeout, unavailable behavior, and latest error. ### 7. Audit events @@ -244,8 +244,9 @@ If HTTP is included in MVP, use the same JSON body and expect the same JSON resp - 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 headers in settings and logs. +- Redact resolved header credentials in settings, logs, hook errors, and hook-returned reasons. If this is too much for MVP, defer HTTP and keep the JSON schema transport-independent. From 9cf4c30519d2cd4f5a95e4634dbbb6e7ea000ae6 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 17:51:07 +0300 Subject: [PATCH 11/40] Clarify policy preflight preprocessing input borrow --- app/src/ai/blocklist/action_model/execute.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 32762961f..1ab060455 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -525,7 +525,7 @@ impl BlocklistAIActionExecutor { }; #[cfg(not(target_family = "wasm"))] - if let Some(preprocess) = self.preprocess_request_file_edits_after_policy(input, ctx) { + if let Some(preprocess) = self.preprocess_request_file_edits_after_policy(&input, ctx) { return preprocess; } @@ -1079,7 +1079,7 @@ impl BlocklistAIActionExecutor { #[cfg(not(target_family = "wasm"))] fn preprocess_request_file_edits_after_policy( &mut self, - input: PreprocessActionInput, + input: &PreprocessActionInput<'_>, ctx: &mut ModelContext, ) -> Option> { if !matches!( @@ -1122,7 +1122,7 @@ impl BlocklistAIActionExecutor { ctx, )?; - let action = input.action.clone(); + let action = (*input.action).clone(); let conversation_id = input.conversation_id; let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); let (done_tx, done_rx) = oneshot::channel(); From dc08cc8b54b01385953216db2d542bd237ba2f6c Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 18:04:22 +0300 Subject: [PATCH 12/40] Isolate policy hook secrets in disabled configs and stdio --- app/src/ai/policy_hooks/config.rs | 24 +++++++++++ app/src/ai/policy_hooks/engine.rs | 1 + app/src/ai/policy_hooks/tests.rs | 69 +++++++++++++++++++++++++++++++ specs/GH9914/product.md | 2 +- specs/GH9914/tech.md | 12 +++--- 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 9a3f3a2ef..5afb4712c 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -40,6 +40,10 @@ impl AgentPolicyHookConfig { } pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + for hook in &self.before_action { + hook.validate_safe_to_persist()?; + } + if !self.enabled { return Ok(()); } @@ -89,6 +93,10 @@ pub(crate) struct AgentPolicyHook { } impl AgentPolicyHook { + fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { + self.transport.validate_safe_to_persist() + } + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { if self.name.trim().is_empty() { return Err(AgentPolicyHookConfigError::MissingHookName); @@ -139,6 +147,22 @@ pub(crate) enum AgentPolicyHookTransport { } impl AgentPolicyHookTransport { + fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { + match self { + Self::Stdio { env, .. } => validate_secret_value_map(env)?, + Self::Http { url, headers } => { + if let Ok(parsed) = url::Url::parse(url) { + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); + } + } + validate_secret_value_map(headers)?; + } + } + + Ok(()) + } + pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { match self { Self::Stdio { diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 07fb8b608..32205a83c 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -128,6 +128,7 @@ impl AgentPolicyHookEngine { let mut command = Command::new(command); command .args(args) + .env_clear() .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index d50ed056b..bf772a9a2 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -126,6 +126,35 @@ fn config_rejects_http_hook_url_embedded_credentials() { } } +#[test] +fn config_rejects_disabled_http_hook_url_embedded_credentials() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": false, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": "https://token@example.com/policy" + }] + })) + .unwrap(); + + assert!(config.validate().is_err()); +} + +#[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()); +} + #[test] fn config_rejects_inline_hook_secret_values() { let config = serde_json::from_value::(json!({ @@ -537,6 +566,46 @@ async fn stdio_engine_times_out_blocked_stdin_write() { ); } +#[cfg(all(unix, not(target_family = "wasm")))] +#[tokio::test] +async fn stdio_engine_does_not_inherit_parent_environment() { + std::env::var("HOME").expect("HOME must be set for policy hook environment inheritance test"); + 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 [ -n \"${HOME+x}\" ]; then printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"deny\",\"reason\":\"inherited HOME\"}'; 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() { diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index 678b1bc5c..26f39a06b 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -93,7 +93,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. -9. Hook payloads do not include file contents, secret values, full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. +9. Hook payloads and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. 10. 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. 11. Warp records a redacted audit event for every governed decision, including hook name, decision, reason, action id, conversation id, timestamp, and policy event id. 12. The agent receives a structured denial or ask result and can continue planning around it. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index 2989bfe7d..abe931c47 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -172,7 +172,8 @@ 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. Surface minimal settings UI after the engine exists: enabled toggle, hook list, timeout, unavailable behavior, and latest error. +4. Validate persisted credential-bearing fields even when hooks are disabled, so inactive profile config cannot store raw or URL-embedded credentials. +5. Surface minimal settings UI after the engine exists: enabled toggle, hook list, timeout, unavailable behavior, and latest error. ### 7. Audit events @@ -197,10 +198,11 @@ Do not include file contents, full env, access tokens, or unbounded MCP argument MVP stdio protocol: 1. Warp launches the configured command with args. -2. Warp writes one JSON policy event to stdin and closes stdin. -3. Hook writes one JSON decision to stdout. -4. Warp kills the process on timeout/cancellation. -5. Stderr is captured only for debug logs and truncated/redacted before UI display. +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: From 385cd72920cd105b9398805be5b78b0fa5ebba57 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 18:35:05 +0300 Subject: [PATCH 13/40] Reject unsafe policy hook profile persistence --- app/src/ai/policy_hooks/config.rs | 73 ++++++++++++++++++++++++++----- app/src/ai/policy_hooks/tests.rs | 36 +++++++++++++-- specs/GH9914/product.md | 15 ++++--- specs/GH9914/tech.md | 5 ++- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 5afb4712c..8d6e0e880 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -4,7 +4,7 @@ use std::{ path::{Path, PathBuf}, }; -use serde::{Deserialize, Serialize}; +use serde::{ser::SerializeStruct, Deserialize, Serialize}; use thiserror::Error; use super::decision::AgentPolicyUnavailableDecision; @@ -12,7 +12,7 @@ use super::decision::AgentPolicyUnavailableDecision; 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, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default)] pub(crate) struct AgentPolicyHookConfig { pub enabled: bool, @@ -39,11 +39,17 @@ impl AgentPolicyHookConfig { self.enabled } - pub(crate) fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { + 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(()); } @@ -81,6 +87,24 @@ impl AgentPolicyHookConfig { } } +impl Serialize for AgentPolicyHookConfig { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.validate_safe_to_persist() + .map_err(serde::ser::Error::custom)?; + + let mut state = serializer.serialize_struct("AgentPolicyHookConfig", 5)?; + state.serialize_field("enabled", &self.enabled)?; + state.serialize_field("before_action", &self.before_action)?; + state.serialize_field("timeout_ms", &self.timeout_ms)?; + state.serialize_field("on_unavailable", &self.on_unavailable)?; + state.serialize_field("allow_hook_autoapproval", &self.allow_hook_autoapproval)?; + state.end() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub(crate) struct AgentPolicyHook { @@ -151,10 +175,8 @@ impl AgentPolicyHookTransport { match self { Self::Stdio { env, .. } => validate_secret_value_map(env)?, Self::Http { url, headers } => { - if let Ok(parsed) = url::Url::parse(url) { - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); - } + if http_url_contains_credentials(url) { + return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); } validate_secret_value_map(headers)?; } @@ -186,13 +208,13 @@ impl AgentPolicyHookTransport { } } Self::Http { url, headers } => { - let parsed = url::Url::parse(url) - .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; - - if !parsed.username().is_empty() || parsed.password().is_some() { + if http_url_contains_credentials(url) { return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); } + let parsed = url::Url::parse(url) + .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; + 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; @@ -250,6 +272,35 @@ fn validate_secret_value_map( Ok(()) } +fn http_url_contains_credentials(url: &str) -> bool { + if let Ok(parsed) = url::Url::parse(url) { + return !parsed.username().is_empty() || parsed.password().is_some(); + } + + 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()); + + url[authority_start..authority_end].contains('@') +} + #[derive(Debug, Error, PartialEq, Eq)] pub(crate) enum AgentPolicyHookConfigError { #[error("agent policy hooks are enabled but no before-action hooks are configured")] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index bf772a9a2..798ab6274 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -3,6 +3,7 @@ use std::{ time::{Duration, Instant}, }; +use crate::ai::execution_profiles::AIExecutionProfile; use serde_json::json; use super::{ @@ -111,6 +112,8 @@ 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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -128,17 +131,43 @@ fn config_rejects_http_hook_url_embedded_credentials() { #[test] fn config_rejects_disabled_http_hook_url_embedded_credentials() { - let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + for url in [ + "https://token@example.com/policy", + "https://token@example .com/policy", + "https:user:pass@example.com/policy", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": false, + "before_action": [{ + "name": "remote-guard", + "transport": "http", + "url": url + }] + })) + .unwrap(); + + assert!(config.validate().is_err()); + } +} + +#[test] +fn profile_serialization_rejects_disabled_http_hook_url_embedded_credentials() { + let agent_policy_hooks: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": false, "before_action": [{ "name": "remote-guard", "transport": "http", - "url": "https://token@example.com/policy" + "url": "https:user:pass@example.com/policy" }] })) .unwrap(); + let profile = AIExecutionProfile { + agent_policy_hooks, + ..Default::default() + }; - assert!(config.validate().is_err()); + let error = serde_json::to_value(&profile).unwrap_err().to_string(); + assert!(error.contains("embedded credentials")); } #[test] @@ -153,6 +182,7 @@ fn config_allows_disabled_incomplete_hook_without_persisted_credentials() { .unwrap(); assert!(config.validate().is_ok()); + assert!(serde_json::to_value(&config).is_ok()); } #[test] diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index 26f39a06b..f79d1a1e8 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -93,13 +93,14 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. -9. Hook payloads and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. -10. 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. -11. Warp records a redacted audit event for every governed decision, including hook name, decision, reason, action id, conversation id, timestamp, and policy event id. -12. The agent receives a structured denial or ask result and can continue planning around it. -13. A user can disable a personal hook from settings unless it is provided by a managed team policy. -14. Hook failures are visible enough to debug without exposing secrets. -15. Third-party CLI agents launched as arbitrary terminal commands are out of scope unless they call back through Warp-owned MCP or Agent surfaces. +9. Hook payloads, persisted hook config, and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. +10. Disabled or inactive hook config is still rejected 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 diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index abe931c47..6daa82ef3 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -172,8 +172,9 @@ 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, so inactive profile config cannot store raw or URL-embedded credentials. -5. Surface minimal settings UI after the engine exists: enabled toggle, hook list, timeout, unavailable behavior, and latest error. +4. Validate persisted credential-bearing fields even when hooks are disabled, and run the same safe-to-persist check from `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 From 820b0b9dbf8fa865d241b74b1f3ee144af9a9019 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 18:44:49 +0300 Subject: [PATCH 14/40] Sanitize unsafe policy hook profile serialization --- app/src/ai/policy_hooks/config.rs | 19 ++++++++++------- app/src/ai/policy_hooks/tests.rs | 34 +++++++++++++++++++------------ specs/GH9914/product.md | 2 +- specs/GH9914/tech.md | 2 +- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 8d6e0e880..4d9fe85f8 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -92,15 +92,20 @@ impl Serialize for AgentPolicyHookConfig { where S: serde::Serializer, { - self.validate_safe_to_persist() - .map_err(serde::ser::Error::custom)?; + 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", &self.enabled)?; - state.serialize_field("before_action", &self.before_action)?; - state.serialize_field("timeout_ms", &self.timeout_ms)?; - state.serialize_field("on_unavailable", &self.on_unavailable)?; - state.serialize_field("allow_hook_autoapproval", &self.allow_hook_autoapproval)?; + 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() } } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 798ab6274..4c0220dd4 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -7,7 +7,7 @@ use crate::ai::execution_profiles::AIExecutionProfile; use serde_json::json; use super::{ - config::AgentPolicyHookConfig, + config::{AgentPolicyHook, AgentPolicyHookConfig, AgentPolicyHookTransport}, decision::{ compose_policy_decisions, AgentPolicyDecisionKind, AgentPolicyHookErrorKind, AgentPolicyHookEvaluation, AgentPolicyHookResponse, AgentPolicyUnavailableDecision, @@ -151,23 +151,31 @@ fn config_rejects_disabled_http_hook_url_embedded_credentials() { } #[test] -fn profile_serialization_rejects_disabled_http_hook_url_embedded_credentials() { - let agent_policy_hooks: AgentPolicyHookConfig = serde_json::from_value(json!({ - "enabled": false, - "before_action": [{ - "name": "remote-guard", - "transport": "http", - "url": "https:user:pass@example.com/policy" - }] - })) - .unwrap(); +fn profile_serialization_sanitizes_disabled_http_hook_url_embedded_credentials() { + let agent_policy_hooks = AgentPolicyHookConfig { + enabled: false, + before_action: vec![AgentPolicyHook { + name: "remote-guard".to_string(), + transport: AgentPolicyHookTransport::Http { + url: "https:user:pass@example.com/policy".to_string(), + headers: Default::default(), + }, + ..Default::default() + }], + ..Default::default() + }; let profile = AIExecutionProfile { agent_policy_hooks, ..Default::default() }; - let error = serde_json::to_value(&profile).unwrap_err().to_string(); - assert!(error.contains("embedded credentials")); + 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('@')); } #[test] diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index f79d1a1e8..02d78da18 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -94,7 +94,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. 9. Hook payloads, persisted hook config, and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. -10. Disabled or inactive hook config is still rejected before profile storage if it contains persisted credentials or URL-embedded credentials. +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. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index 6daa82ef3..dbc0bd096 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -172,7 +172,7 @@ 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 run the same safe-to-persist check from `AgentPolicyHookConfig` serialization so inactive profile config cannot be locally or cloud-synced with raw or URL-embedded credentials. +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. From 0d20e25da0c10ca1ae1794d8dfd18578678b28db Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 18:55:11 +0300 Subject: [PATCH 15/40] Recompose cached policy decisions and redact command credentials --- app/src/ai/blocklist/action_model/execute.rs | 38 ++++++++++++++++--- .../blocklist/action_model/execute_tests.rs | 33 +++++++++++++++- app/src/ai/policy_hooks/redaction.rs | 20 ++++++++++ app/src/ai/policy_hooks/tests.rs | 20 ++++++++++ specs/GH9914/product.md | 2 +- specs/GH9914/tech.md | 4 +- 6 files changed, 107 insertions(+), 10 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 1ab060455..fa047d69d 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -123,10 +123,11 @@ use crate::{ use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; #[cfg(not(target_family = "wasm"))] use crate::ai::policy_hooks::{ - decision::WarpPermissionDecisionKind, AgentPolicyAction, AgentPolicyDecisionKind, - AgentPolicyEffectiveDecision, AgentPolicyEvent, AgentPolicyHookEngine, PolicyCallMcpToolAction, - PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, - PolicyWriteFilesAction, WarpPermissionSnapshot, + decision::{compose_policy_decisions, WarpPermissionDecisionKind}, + AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, + AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + WarpPermissionSnapshot, }; /// Types of actions that can be executed in parallel. @@ -1205,7 +1206,21 @@ impl BlocklistAIActionExecutor { || self .user_initiated_policy_preflights .contains(&preflight_key); - let state = policy_preflight_state_from_decision(action, decision, user_confirmed); + 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 decision = recompose_completed_policy_decision( + decision, + warp_permission, + config.allow_autoapproval_for_all_hooks(), + ); + let state = policy_preflight_state_from_decision(action, &decision, user_confirmed); if should_consume_completed_policy_preflight(&state) { self.completed_policy_preflights.remove(&preflight_key); self.user_initiated_policy_preflights.remove(&preflight_key); @@ -1603,6 +1618,19 @@ fn policy_denied_action_result( } } +#[cfg(not(target_family = "wasm"))] +fn recompose_completed_policy_decision( + decision: &AgentPolicyEffectiveDecision, + warp_permission: WarpPermissionSnapshot, + allow_hook_autoapproval: bool, +) -> AgentPolicyEffectiveDecision { + 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, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 5a09bfa3d..ad4601f47 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -123,8 +123,8 @@ mod policy_hooks { use super::super::{ agent_policy_action, complete_policy_preflight_if_pending, normalize_command_for_policy, policy_denied_action_result, policy_preflight_state_from_decision, - should_consume_completed_policy_preflight, warp_permission_snapshot_for_policy, - PolicyPreflightKey, PolicyPreflightState, + recompose_completed_policy_decision, should_consume_completed_policy_preflight, + warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -270,6 +270,35 @@ mod policy_hooks { ); } + #[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 policy_preflight_key_scopes_same_action_id_by_conversation() { let action_id = AIAgentActionId::from("action_1".to_string()); diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 764d67e0e..6366a4c46 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -15,6 +15,23 @@ static AUTHORIZATION_BEARER_RE: Lazy = Lazy::new(|| { .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 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 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") @@ -27,6 +44,9 @@ pub(crate) fn redact_command_for_policy(command: &str) -> String { pub(crate) fn redact_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 = CURL_BASIC_AUTH_RE.replace_all(&value, "$1"); + let value = URL_USERINFO_RE.replace_all(&value, "$1@"); let value = COMMON_TOKEN_RE.replace_all(&value, ""); truncate_for_policy(&value) } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 4c0220dd4..85793d409 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -303,6 +303,26 @@ fn command_redaction_handles_quoted_secret_assignments() { 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 mcp_tool_action_preserves_only_argument_keys() { let action = PolicyCallMcpToolAction::new( diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index 02d78da18..6fca57b9e 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -93,7 +93,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. -9. Hook payloads, persisted hook config, and hook child processes do not include file contents, secret values, inherited full environment variables, access tokens, URL-embedded credentials, or unbounded command output by default. +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, 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. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index dbc0bd096..8f8414587 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -120,7 +120,7 @@ Implementation approach: 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)`, 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, compose the stored policy decision with the base Warp permission decision and continue, ask, or deny. +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. This avoids blocking the UI thread or changing every executor to directly await a hook. @@ -258,7 +258,7 @@ If this is too much for MVP, defer HTTP and keep the JSON schema transport-indep 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, and MCP argument values while preserving useful keys and counts. +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. From 89e9806f733dc8f597129dbd4f9ab8f576e8d697 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 19:02:57 +0300 Subject: [PATCH 16/40] Avoid cached hook autoapproval after permission changes --- app/src/ai/blocklist/action_model/execute.rs | 2 ++ .../blocklist/action_model/execute_tests.rs | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index fa047d69d..67b7fdb7a 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1624,6 +1624,8 @@ fn recompose_completed_policy_decision( 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(), diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index ad4601f47..9d6bcf571 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -299,6 +299,34 @@ mod policy_hooks { 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 policy_preflight_key_scopes_same_action_id_by_conversation() { let action_id = AIAgentActionId::from("action_1".to_string()); From 718feeb5cbd4e978c41592aac4283c50fe5d452b Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 19:10:56 +0300 Subject: [PATCH 17/40] Create policy audit directory with private permissions --- app/src/ai/policy_hooks/audit.rs | 48 +++++++++++++++++++++++++++----- specs/GH9914/tech.md | 2 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/app/src/ai/policy_hooks/audit.rs b/app/src/ai/policy_hooks/audit.rs index 37507154a..2ef3b6dc8 100644 --- a/app/src/ai/policy_hooks/audit.rs +++ b/app/src/ai/policy_hooks/audit.rs @@ -1,7 +1,7 @@ use std::{ fs::{self, OpenOptions}, io::Write, - path::PathBuf, + path::{Path, PathBuf}, }; use anyhow::{Context, Result}; @@ -53,9 +53,8 @@ pub(crate) fn write_audit_record( .parent() .context("agent policy audit path has no parent directory")?; - fs::create_dir_all(parent) + create_private_directory_all(parent) .with_context(|| format!("create agent policy audit directory {}", parent.display()))?; - set_private_directory_permissions(parent); let line = audit_record_json_line(event, decision)?; @@ -79,6 +78,23 @@ pub(crate) fn write_audit_record( 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, @@ -122,7 +138,7 @@ fn audit_log_path() -> Option { } #[cfg(unix)] -fn set_private_directory_permissions(path: &std::path::Path) { +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)) { @@ -134,10 +150,10 @@ fn set_private_directory_permissions(path: &std::path::Path) { } #[cfg(not(unix))] -fn set_private_directory_permissions(_path: &std::path::Path) {} +fn set_private_directory_permissions(_path: &Path) {} #[cfg(unix)] -fn set_private_file_permissions(path: &std::path::Path) { +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)) { @@ -149,4 +165,22 @@ fn set_private_file_permissions(path: &std::path::Path) { } #[cfg(not(unix))] -fn set_private_file_permissions(_path: &std::path::Path) {} +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/specs/GH9914/tech.md b/specs/GH9914/tech.md index 8f8414587..edddae087 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -192,7 +192,7 @@ Add a local JSONL audit writer owned by `policy_hooks::engine`: - 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. +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. ### 8. Stdio hook protocol From b5b624abd6f4571d99b33cd84c1d620e9d4945fa Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 19:26:43 +0300 Subject: [PATCH 18/40] Scope policy preflight cache by action payload --- app/src/ai/blocklist/action_model/execute.rs | 107 +++++++++--------- .../blocklist/action_model/execute_tests.rs | 30 ++++- app/src/ai/policy_hooks/engine.rs | 4 +- app/src/ai/policy_hooks/event.rs | 14 +-- app/src/ai/policy_hooks/tests.rs | 59 ++++++++++ specs/GH9914/product.md | 4 +- specs/GH9914/tech.md | 5 +- 7 files changed, 154 insertions(+), 69 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 67b7fdb7a..06638ba3b 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -164,16 +164,30 @@ struct PreprocessActionInput<'a> { struct PolicyPreflightKey { conversation_id: AIConversationId, action_id: AIAgentActionId, + action: AgentPolicyAction, } #[cfg(not(target_family = "wasm"))] impl PolicyPreflightKey { - fn new(conversation_id: AIConversationId, action_id: AIAgentActionId) -> Self { + fn new( + conversation_id: AIConversationId, + action_id: AIAgentActionId, + action: &AgentPolicyAction, + ) -> Self { Self { conversation_id, action_id, + action: action.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>>; @@ -335,8 +349,6 @@ pub struct BlocklistAIActionExecutor { #[cfg(not(target_family = "wasm"))] pending_policy_preflights: HashSet, #[cfg(not(target_family = "wasm"))] - user_initiated_policy_preflights: HashSet, - #[cfg(not(target_family = "wasm"))] completed_policy_preflights: HashMap, /// Reference to the terminal model for checking session sharing state. @@ -423,8 +435,6 @@ impl BlocklistAIActionExecutor { #[cfg(not(target_family = "wasm"))] pending_policy_preflights: Default::default(), #[cfg(not(target_family = "wasm"))] - user_initiated_policy_preflights: Default::default(), - #[cfg(not(target_family = "wasm"))] completed_policy_preflights: Default::default(), terminal_model, read_skill_executor, @@ -1096,6 +1106,7 @@ impl BlocklistAIActionExecutor { .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; } @@ -1125,9 +1136,11 @@ impl BlocklistAIActionExecutor { let action = (*input.action).clone(); let conversation_id = input.conversation_id; - let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); + let preflight_key = + PolicyPreflightKey::new(conversation_id, action.id.clone(), &event.action); 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( @@ -1192,50 +1205,12 @@ impl BlocklistAIActionExecutor { 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; - let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone()); if !config.is_active() { - self.pending_policy_preflights.remove(&preflight_key); - self.user_initiated_policy_preflights.remove(&preflight_key); - self.completed_policy_preflights.remove(&preflight_key); + self.remove_policy_preflights_for_action(conversation_id, &action.id); return None; } - if let Some(decision) = self.completed_policy_preflights.get(&preflight_key) { - let user_confirmed = is_user_initiated - || self - .user_initiated_policy_preflights - .contains(&preflight_key); - 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 decision = recompose_completed_policy_decision( - decision, - warp_permission, - config.allow_autoapproval_for_all_hooks(), - ); - let state = policy_preflight_state_from_decision(action, &decision, user_confirmed); - if should_consume_completed_policy_preflight(&state) { - self.completed_policy_preflights.remove(&preflight_key); - self.user_initiated_policy_preflights.remove(&preflight_key); - } - return Some(state); - } - - if self.pending_policy_preflights.contains(&preflight_key) { - if is_user_initiated { - self.user_initiated_policy_preflights - .insert(preflight_key.clone()); - } - return Some(PolicyPreflightState::Pending); - } - let warp_permission = self.warp_permission_snapshot_for_action( action, conversation_id, @@ -1252,12 +1227,28 @@ impl BlocklistAIActionExecutor { warp_permission.clone(), ctx, )?; + let preflight_key = + PolicyPreflightKey::new(conversation_id, action.id.clone(), &event.action); - self.pending_policy_preflights.insert(preflight_key.clone()); - if is_user_initiated { - self.user_initiated_policy_preflights - .insert(preflight_key.clone()); + if let Some(decision) = self.completed_policy_preflights.get(&preflight_key) { + 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); + if should_consume_completed_policy_preflight(&state) { + 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 }, @@ -1268,7 +1259,6 @@ impl BlocklistAIActionExecutor { preflight_key.clone(), decision, ) { - me.user_initiated_policy_preflights.remove(&preflight_key); return; } ctx.emit(BlocklistAIActionExecutorEvent::PolicyPreflightFinished { @@ -1287,13 +1277,22 @@ impl BlocklistAIActionExecutor { ) { #[cfg(not(target_family = "wasm"))] { - let preflight_key = PolicyPreflightKey::new(conversation_id, action_id.clone()); - self.pending_policy_preflights.remove(&preflight_key); - self.user_initiated_policy_preflights.remove(&preflight_key); - self.completed_policy_preflights.remove(&preflight_key); + 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)); + } + #[cfg(not(target_family = "wasm"))] fn agent_policy_event( &self, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 9d6bcf571..0970f323b 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -144,6 +144,11 @@ mod policy_hooks { } } + fn policy_command_action(command: &str) -> AgentPolicyAction { + agent_policy_action(&command_action(command), None, &None, &None) + .expect("command action should build a policy action") + } + #[test] fn policy_denied_result_preserves_command_and_policy_reason() { let action = command_action("rm -rf target"); @@ -330,10 +335,11 @@ mod policy_hooks { #[test] fn policy_preflight_key_scopes_same_action_id_by_conversation() { let action_id = AIAgentActionId::from("action_1".to_string()); + let policy_action = policy_command_action("ls"); let conversation_one = AIConversationId::new(); let conversation_two = AIConversationId::new(); - let key_one = PolicyPreflightKey::new(conversation_one, action_id.clone()); - let key_two = PolicyPreflightKey::new(conversation_two, action_id); + let key_one = PolicyPreflightKey::new(conversation_one, action_id.clone(), &policy_action); + let key_two = PolicyPreflightKey::new(conversation_two, action_id, &policy_action); assert_ne!(key_one, key_two); @@ -342,10 +348,28 @@ mod policy_hooks { 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 = policy_command_action("echo old"); + let new_action = policy_command_action("echo new"); + + let old_key = PolicyPreflightKey::new(conversation_id, action_id.clone(), &old_action); + let new_key = PolicyPreflightKey::new(conversation_id, action_id.clone(), &new_action); + + assert_ne!(old_key, new_key); + assert!(old_key.matches_action(conversation_id, &action_id)); + } + #[test] fn cancelled_policy_preflight_completion_is_not_cached() { let action_id = AIAgentActionId::from("action_1".to_string()); - let preflight_key = PolicyPreflightKey::new(AIConversationId::new(), action_id); + let preflight_key = PolicyPreflightKey::new( + AIConversationId::new(), + action_id, + &policy_command_action("ls"), + ); let decision = AgentPolicyEffectiveDecision { decision: AgentPolicyDecisionKind::Allow, reason: None, diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 32205a83c..72c05ce79 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -457,7 +457,9 @@ fn redact_configured_secret_values<'a>( redacted = redacted.replace(&secret, ""); } if let Some((scheme, credential)) = secret.split_once(' ') { - if scheme.eq_ignore_ascii_case("bearer") && credential.len() >= 4 { + if (scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("basic")) + && credential.len() >= 4 + { redacted = redacted.replace(credential, ""); } } diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index 2f51f1493..26e7e2b41 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -81,7 +81,7 @@ pub(crate) enum AgentPolicyActionKind { ReadMcpResource, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[allow(dead_code)] pub(crate) enum AgentPolicyAction { ExecuteCommand(PolicyExecuteCommandAction), @@ -118,7 +118,7 @@ impl Serialize for AgentPolicyAction { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct PolicyExecuteCommandAction { pub command: String, pub normalized_command: String, @@ -153,26 +153,26 @@ impl PolicyExecuteCommandAction { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct PolicyReadFilesAction { pub paths: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct PolicyWriteFilesAction { pub paths: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub diff_stats: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[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, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct PolicyCallMcpToolAction { #[serde(default, skip_serializing_if = "Option::is_none")] pub server_id: Option, @@ -194,7 +194,7 @@ impl PolicyCallMcpToolAction { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct PolicyReadMcpResourceAction { #[serde(default, skip_serializing_if = "Option::is_none")] pub server_id: Option, diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 85793d409..4fee409dd 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -1018,6 +1018,65 @@ async fn http_engine_redacts_configured_header_secret_hook_reason() { ); } +#[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() { diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index 6fca57b9e..a583c447f 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -88,7 +88,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove - MCP tool calls - MCP resource reads 3. A hook decision of `deny` prevents the underlying command, file operation, or MCP call from starting. -4. A hook decision of `ask` routes the action through Warp's existing confirmation UI and includes the hook's reason in the UI. +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. @@ -105,7 +105,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove ## 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 actions. +- **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. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index edddae087..5c3a00838 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -118,9 +118,9 @@ Implementation approach: 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)`, start an async hook request, store pending state, and return `TryExecuteResult::NotExecuted { reason: NotReady, action }`. +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. +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. @@ -250,6 +250,7 @@ If HTTP is included in MVP, use the same JSON body and expect the same JSON resp - 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. If this is too much for MVP, defer HTTP and keep the JSON schema transport-independent. From f0b856ced7fc3660d10095dacf8b87a170360085 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 19:44:18 +0300 Subject: [PATCH 19/40] Bound policy event payloads and file edit denials --- app/src/ai/agent/mod.rs | 3 + app/src/ai/agent_sdk/driver/output.rs | 10 +++ app/src/ai/blocklist/action_model/execute.rs | 35 ++++----- .../blocklist/action_model/execute_tests.rs | 38 ++++++++++ app/src/ai/policy_hooks/decision.rs | 1 + app/src/ai/policy_hooks/engine.rs | 68 +++++++++++++++--- app/src/ai/policy_hooks/event.rs | 67 ++++++++++++++++- app/src/ai/policy_hooks/redaction.rs | 30 ++++++-- app/src/ai/policy_hooks/tests.rs | 71 +++++++++++++++++-- crates/ai/src/agent/action_result/convert.rs | 11 +++ .../src/agent/action_result/convert_tests.rs | 22 ++++++ crates/ai/src/agent/action_result/mod.rs | 12 +++- specs/GH9914/product.md | 4 +- specs/GH9914/tech.md | 4 +- 14 files changed, 329 insertions(+), 47 deletions(-) diff --git a/app/src/ai/agent/mod.rs b/app/src/ai/agent/mod.rs index 9a04376dc..4fef6a262 100644 --- a/app/src/ai/agent/mod.rs +++ b/app/src/ai/agent/mod.rs @@ -1029,6 +1029,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_sdk/driver/output.rs b/app/src/ai/agent_sdk/driver/output.rs index d919efb87..7c7734c09 100644 --- a/app/src/ai/agent_sdk/driver/output.rs +++ b/app/src/ai/agent_sdk/driver/output.rs @@ -109,6 +109,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(()), @@ -868,6 +871,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 { diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 06638ba3b..8ee8a5bf9 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1518,24 +1518,21 @@ fn agent_policy_action( .redacted(), )), AIAgentActionType::ReadFiles(read_files) => { - Some(AgentPolicyAction::ReadFiles(PolicyReadFilesAction { - paths: read_files + Some(AgentPolicyAction::ReadFiles(PolicyReadFilesAction::new( + read_files .locations .iter() - .map(|file| policy_path(&file.name, shell, current_working_directory)) - .collect(), - })) + .map(|file| policy_path(&file.name, shell, current_working_directory)), + ))) } AIAgentActionType::RequestFileEdits { file_edits, .. } => { let paths = file_edits .iter() .filter_map(|edit| edit.file()) - .map(|file| policy_path(file, shell, current_working_directory)) - .collect::>(); - Some(AgentPolicyAction::WriteFiles(PolicyWriteFilesAction { - paths, - diff_stats: None, - })) + .map(|file| policy_path(file, shell, current_working_directory)); + Some(AgentPolicyAction::WriteFiles(PolicyWriteFilesAction::new( + paths, None, + ))) } AIAgentActionType::CallMCPTool { server_id, @@ -1549,11 +1546,7 @@ fn agent_policy_action( name, uri, } => Some(AgentPolicyAction::ReadMcpResource( - PolicyReadMcpResourceAction { - server_id: *server_id, - name: name.clone(), - uri: uri.clone(), - }, + PolicyReadMcpResourceAction::new(*server_id, name.clone(), uri.clone()), )), _ => None, } @@ -1602,11 +1595,11 @@ fn policy_denied_action_result( AIAgentActionType::ReadFiles(_) => AIAgentActionResultType::ReadFiles( ReadFilesResult::Error(format!("Blocked by host policy: {reason}")), ), - AIAgentActionType::RequestFileEdits { .. } => AIAgentActionResultType::RequestFileEdits( - RequestFileEditsResult::DiffApplicationFailed { - error: format!("Blocked by host policy: {reason}"), - }, - ), + AIAgentActionType::RequestFileEdits { .. } => { + AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::PolicyDenied { + reason, + }) + } AIAgentActionType::CallMCPTool { .. } => AIAgentActionResultType::CallMCPTool( CallMCPToolResult::Error(format!("Blocked by host policy: {reason}")), ), diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 0970f323b..f92538108 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -108,6 +108,7 @@ mod policy_hooks { agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, FileEdit, RequestCommandOutputResult, + RequestFileEditsResult, }, policy_hooks::{ decision::{ @@ -178,6 +179,43 @@ mod policy_hooks { ); } + #[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 warp_permission_snapshot_marks_autonomous_denials_terminal() { let snapshot = warp_permission_snapshot_for_policy(false, false, false, true, None); diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index 75c2d3fc9..a069db157 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -89,6 +89,7 @@ pub(crate) enum AgentPolicyHookErrorKind { SpawnFailed, StdinWriteFailed, NonZeroExit, + PayloadTooLarge, MalformedResponse, UnsupportedTransport, HttpRequestFailed, diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 72c05ce79..19776d8f3 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, process::ExitStatus, time::Duration}; +use std::{collections::BTreeMap, io, process::ExitStatus, time::Duration}; use anyhow::{anyhow, Context, Result}; use command::{r#async::Command, Stdio}; @@ -26,6 +26,7 @@ use super::{ }; const MAX_HOOK_OUTPUT_BYTES: usize = 64 * 1024; +const MAX_HOOK_EVENT_BYTES: usize = 128 * 1024; #[derive(Debug, Clone)] pub(crate) struct AgentPolicyHookEngine { @@ -142,10 +143,7 @@ impl AgentPolicyHookEngine { command.env(key, resolve_hook_secret_value(value)?); } - let event_bytes = serialize_event(event).map_err(|source| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::MalformedResponse, - detail: format!("failed to serialize policy event: {source}"), - })?; + let event_bytes = serialize_event(event)?; let mut child = command.spawn().map_err(|source| AgentPolicyHookFailure { kind: AgentPolicyHookErrorKind::SpawnFailed, @@ -254,10 +252,7 @@ impl AgentPolicyHookEngine { }); }; - let event_bytes = serialize_event(event).map_err(|source| AgentPolicyHookFailure { - kind: AgentPolicyHookErrorKind::MalformedResponse, - detail: format!("failed to serialize policy event: {source}"), - })?; + let event_bytes = serialize_event(event)?; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) @@ -478,8 +473,59 @@ fn resolve_hook_secret_value( }) } -fn serialize_event(event: &AgentPolicyEvent) -> Result> { - serde_json::to_vec(event).context("serialize policy event") +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 { diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index 26e7e2b41..e77d75c4f 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -4,7 +4,9 @@ use serde::{Deserialize, Serialize, Serializer}; use super::{ decision::WarpPermissionSnapshot, - redaction::{mcp_argument_keys, redact_command_for_policy}, + redaction::{ + capped_policy_items, mcp_argument_keys, redact_command_for_policy, truncate_for_policy, + }, }; pub(crate) const AGENT_POLICY_SCHEMA_VERSION: &str = "warp.agent_policy_hook.v1"; @@ -156,15 +158,45 @@ impl PolicyExecuteCommandAction { #[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, @@ -178,6 +210,8 @@ pub(crate) struct PolicyCallMcpToolAction { 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 { @@ -186,10 +220,13 @@ impl PolicyCallMcpToolAction { 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: tool_name.into(), - argument_keys: mcp_argument_keys(arguments), + tool_name: truncate_for_policy(&tool_name), + argument_keys, + omitted_argument_key_count, } } } @@ -202,3 +239,27 @@ pub(crate) struct PolicyReadMcpResourceAction { #[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: truncate_for_policy(&name), + uri: uri.map(|uri| truncate_for_policy(&uri)), + } + } +} + +fn truncate_policy_path(path: PathBuf) -> PathBuf { + let path_text = path.to_string_lossy(); + if path_text.len() <= super::redaction::MAX_POLICY_STRING_BYTES { + return path; + } + + PathBuf::from(truncate_for_policy(&path_text)) +} diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 6366a4c46..b7922fe2b 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -2,6 +2,7 @@ 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( @@ -51,14 +52,35 @@ pub(crate) fn redact_sensitive_text_for_policy(value: &str) -> String { truncate_for_policy(&value) } -pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> Vec { +pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> (Vec, Option) { let serde_json::Value::Object(map) = arguments else { - return Vec::new(); + return (Vec::new(), None); }; - let mut keys = map.keys().cloned().collect::>(); + let mut keys = map + .keys() + .take(MAX_POLICY_COLLECTION_ITEMS) + .map(|key| truncate_for_policy(key)) + .collect::>(); keys.sort(); - keys + 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)] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 4fee409dd..b84ee693e 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -17,7 +17,7 @@ use super::{ AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, AGENT_POLICY_SCHEMA_VERSION, }, - redaction::redact_command_for_policy, + redaction::{redact_command_for_policy, MAX_POLICY_COLLECTION_ITEMS}, }; #[cfg(not(target_family = "wasm"))] @@ -336,6 +336,26 @@ fn mcp_tool_action_preserves_only_argument_keys() { ); assert_eq!(action.argument_keys, vec!["count", "path", "token"]); + assert_eq!(action.omitted_argument_key_count, None); +} + +#[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] @@ -597,8 +617,8 @@ async fn stdio_engine_times_out_blocked_stdin_write() { })) .unwrap(); let engine = AgentPolicyHookEngine::new(config); - let suffix = "x".repeat(160); - let paths = (0..15_000) + 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( @@ -608,7 +628,10 @@ async fn stdio_engine_times_out_blocked_stdin_write() { false, None, WarpPermissionSnapshot::allow(None), - AgentPolicyAction::ReadFiles(PolicyReadFilesAction { paths }), + AgentPolicyAction::ReadFiles(PolicyReadFilesAction { + paths, + omitted_path_count: None, + }), ); let started = Instant::now(); @@ -624,6 +647,46 @@ async fn stdio_engine_times_out_blocked_stdin_write() { ); } +#[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() { diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index 72f1a5839..66f4deea4 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -302,6 +302,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: format!("File edits blocked by host policy: {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 92e690215..8c940a0b1 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -57,3 +57,25 @@ fn policy_denied_shell_result_preserves_policy_reason_without_denylist_label() { ); assert!(permission_denied.reason.is_none()); } + +#[test] +fn policy_denied_file_edit_result_converts_to_policy_error_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!( + error.message, + "File edits blocked by host policy: protected path" + ); +} diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index 45a48186e..2ad160c7f 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -641,6 +641,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)] @@ -693,6 +697,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}") + } } } } @@ -804,7 +811,10 @@ impl AIAgentActionResultType { pub fn is_failed(&self) -> bool { match self { Self::RequestCommandOutput(r) => r.failed(), - Self::RequestFileEdits(RequestFileEditsResult::DiffApplicationFailed { .. }) + Self::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { .. } + | RequestFileEditsResult::PolicyDenied { .. }, + ) | Self::ReadFiles(ReadFilesResult::Error(_)) | Self::UploadArtifact(UploadArtifactResult::Error(_)) | Self::SearchCodebase(SearchCodebaseResult::Failed { .. }) diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index a583c447f..2d9d9c315 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -87,13 +87,13 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove - 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. +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 and can be configured to `deny` by managed policy. -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, or unbounded command output by default. +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. diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index 5c3a00838..2201cdb2e 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -155,7 +155,7 @@ 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 `RequestFileEditsResult` failure/cancelled variant with a policy-blocked reason before diffs are saved. +- 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. @@ -193,6 +193,7 @@ Add a local JSONL audit writer owned by `policy_hooks::engine`: - 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 @@ -251,6 +252,7 @@ If HTTP is included in MVP, use the same JSON body and expect the same JSON resp - 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. From aa0a1b6682b75cd6c87617ea0798115763082b3b Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 20:15:50 +0300 Subject: [PATCH 20/40] Govern shell writes and preserve policy denials --- app/src/ai/agent/api/convert_conversation.rs | 21 ++++- .../agent/api/convert_conversation_tests.rs | 83 +++++++++++++++++++ app/src/ai/agent/mod.rs | 3 + app/src/ai/agent/redaction.rs | 1 + app/src/ai/agent_sdk/driver/output.rs | 8 ++ app/src/ai/blocklist/action_model/execute.rs | 32 ++++++- .../blocklist/action_model/execute_tests.rs | 61 +++++++++++++- app/src/ai/policy_hooks/event.rs | 30 +++++++ app/src/ai/policy_hooks/mod.rs | 1 + crates/ai/src/agent/action_result/convert.rs | 13 ++- .../src/agent/action_result/convert_tests.rs | 22 +++++ crates/ai/src/agent/action_result/mod.rs | 15 +++- 12 files changed, 279 insertions(+), 11 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index fd8ab5d3c..264a0a464 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -24,7 +24,7 @@ use crate::ai::agent::{ RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, - UploadArtifactResult, WriteToLongRunningShellCommandResult, + UploadArtifactResult, WriteToLongRunningShellCommandResult, FILE_EDITS_POLICY_DENIED_PREFIX, }; use crate::ai::block_context::BlockContext; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; @@ -650,7 +650,12 @@ pub(crate) fn convert_tool_call_result_to_input( 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), - Some(api::write_to_long_running_shell_command_result::Result::Error(_)) | None => WriteToLongRunningShellCommandResult::Cancelled, + Some(api::write_to_long_running_shell_command_result::Result::Error(api::ShellCommandError{ + r#type: None, + })) => WriteToLongRunningShellCommandResult::PolicyDenied { + reason: "blocked by host policy".to_string(), + }, + None => WriteToLongRunningShellCommandResult::Cancelled, }; Some(AIAgentInput::ActionResult { @@ -781,8 +786,16 @@ 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) = + error.message.strip_prefix(FILE_EDITS_POLICY_DENIED_PREFIX) + { + RequestFileEditsResult::PolicyDenied { + reason: reason.to_string(), + } + } 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 6fe2ab5a1..753146d51 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -122,6 +122,89 @@ fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { } } +#[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 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: format!( + "{}protected path", + crate::ai::agent::FILE_EDITS_POLICY_DENIED_PREFIX + ), + }, + )), + }, + )), + }; + + 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, "protected path"); + } + 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_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 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::PolicyDenied { reason }, + ) => { + assert_eq!(reason, "blocked by host policy"); + } + 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_upload_artifact_success() { let task_id = crate::ai::agent::task::TaskId::new("task".to_string()); diff --git a/app/src/ai/agent/mod.rs b/app/src/ai/agent/mod.rs index 4fef6a262..423c094ed 100644 --- a/app/src/ai/agent/mod.rs +++ b/app/src/ai/agent/mod.rs @@ -1020,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, .. } => { diff --git a/app/src/ai/agent/redaction.rs b/app/src/ai/agent/redaction.rs index 11063cbf3..236129208 100644 --- a/app/src/ai/agent/redaction.rs +++ b/app/src/ai/agent/redaction.rs @@ -119,6 +119,7 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) { match result { Snapshot { grid_contents, .. } => redact_secrets(grid_contents), CommandFinished { output, .. } => redact_secrets(output), + PolicyDenied { reason } => redact_secrets(reason), Error(_) | Cancelled => {} } } diff --git a/app/src/ai/agent_sdk/driver/output.rs b/app/src/ai/agent_sdk/driver/output.rs index 7c7734c09..61360e401 100644 --- a/app/src/ai/agent_sdk/driver/output.rs +++ b/app/src/ai/agent_sdk/driver/output.rs @@ -88,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 { @@ -858,6 +861,11 @@ pub mod json { error: "Failed to write to command.".into(), }) } + WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { + Some(JsonMessage::ToolError { + error: Cow::Borrowed(reason.as_str()), + }) + } WriteToLongRunningShellCommandResult::Cancelled => { Some(JsonMessage::ToolCanceled) } diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 8ee8a5bf9..8421839f5 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -23,6 +23,7 @@ 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; @@ -104,8 +105,8 @@ use crate::{ ai::{ agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult, - AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext, - FileLocations, ServerOutputId, + AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode, CancellationReason, + FileContext, FileLocations, ServerOutputId, }, ambient_agents::AmbientAgentTaskId, get_relevant_files::controller::GetRelevantFilesController, @@ -127,7 +128,7 @@ use crate::ai::policy_hooks::{ AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, - WarpPermissionSnapshot, + PolicyWriteToLongRunningShellCommandAction, WarpPermissionSnapshot, }; /// Types of actions that can be executed in parallel. @@ -1517,6 +1518,17 @@ fn agent_policy_action( ) .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 @@ -1552,6 +1564,15 @@ fn agent_policy_action( } } +#[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, @@ -1600,6 +1621,11 @@ fn policy_denied_action_result( reason, }) } + AIAgentActionType::WriteToLongRunningShellCommand { .. } => { + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::PolicyDenied { reason }, + ) + } AIAgentActionType::CallMCPTool { .. } => AIAgentActionResultType::CallMCPTool( CallMCPToolResult::Error(format!("Blocked by host policy: {reason}")), ), diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index f92538108..5a44cc02f 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -107,8 +107,9 @@ mod policy_hooks { agent::task::TaskId, agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, - AIAgentActionResultType, AIAgentActionType, FileEdit, RequestCommandOutputResult, - RequestFileEditsResult, + AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode, FileEdit, + RequestCommandOutputResult, RequestFileEditsResult, + WriteToLongRunningShellCommandResult, }, policy_hooks::{ decision::{ @@ -150,6 +151,19 @@ mod policy_hooks { .expect("command action should build a policy action") } + 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, + } + } + #[test] fn policy_denied_result_preserves_command_and_policy_reason() { let action = command_action("rm -rf target"); @@ -216,6 +230,34 @@ mod policy_hooks { ); } + #[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_permission_snapshot_marks_autonomous_denials_terminal() { let snapshot = warp_permission_snapshot_for_policy(false, false, false, true, None); @@ -461,6 +503,21 @@ mod policy_hooks { 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!( diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index e77d75c4f..acab2bc07 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -77,6 +77,7 @@ impl AgentPolicyEvent { #[serde(rename_all = "snake_case")] pub(crate) enum AgentPolicyActionKind { ExecuteCommand, + WriteToLongRunningShellCommand, ReadFiles, WriteFiles, CallMcpTool, @@ -87,6 +88,7 @@ pub(crate) enum AgentPolicyActionKind { #[allow(dead_code)] pub(crate) enum AgentPolicyAction { ExecuteCommand(PolicyExecuteCommandAction), + WriteToLongRunningShellCommand(PolicyWriteToLongRunningShellCommandAction), ReadFiles(PolicyReadFilesAction), WriteFiles(PolicyWriteFilesAction), CallMcpTool(PolicyCallMcpToolAction), @@ -97,6 +99,9 @@ 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, @@ -112,6 +117,7 @@ impl Serialize for AgentPolicyAction { { 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), @@ -155,6 +161,30 @@ impl PolicyExecuteCommandAction { } } +#[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, diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs index 20c7f63a5..8ff251be4 100644 --- a/app/src/ai/policy_hooks/mod.rs +++ b/app/src/ai/policy_hooks/mod.rs @@ -16,6 +16,7 @@ pub(crate) use engine::AgentPolicyHookEngine; pub(crate) use event::{ AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + PolicyWriteToLongRunningShellCommandAction, }; #[cfg(test)] diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index 66f4deea4..7ff653368 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -144,6 +144,17 @@ impl TryFrom ), WriteToLongRunningShellCommandResult::Cancelled => Err(ConvertToAPITypeError::Ignore), + WriteToLongRunningShellCommandResult::PolicyDenied { .. } => { + Ok(api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand( + api::WriteToLongRunningShellCommandResult { + result: Some( + api::write_to_long_running_shell_command_result::Result::Error( + api::ShellCommandError { r#type: None }, + ), + ), + }, + )) + } WriteToLongRunningShellCommandResult::Error(ShellCommandError::BlockNotFound) => { Ok(api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand( api::WriteToLongRunningShellCommandResult { @@ -307,7 +318,7 @@ impl TryFrom for api::request::input::tool_call_result:: api::ApplyFileDiffsResult { result: Some(api::apply_file_diffs_result::Result::Error( api::apply_file_diffs_result::Error { - message: format!("File edits blocked by host policy: {reason}"), + message: format!("{FILE_EDITS_POLICY_DENIED_PREFIX}{reason}"), }, )), }, diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 8c940a0b1..650145992 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -79,3 +79,25 @@ fn policy_denied_file_edit_result_converts_to_policy_error_message() { "File edits blocked by host policy: protected path" ); } + +#[test] +fn policy_denied_write_to_shell_result_converts_to_unlabeled_error() { + 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::Error(error)) = result.result + else { + panic!("expected error result"); + }; + + assert!(error.r#type.is_none()); +} diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index 2ad160c7f..d93ac01a2 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -13,6 +13,8 @@ use crate::{ document::{AIDocumentId, AIDocumentVersion}, }; +pub const FILE_EDITS_POLICY_DENIED_PREFIX: &str = "File edits blocked by host policy: "; + #[derive(Debug, Clone, PartialEq)] pub enum AIAgentActionResultType { /// The output of a requested command. @@ -266,6 +268,9 @@ pub enum WriteToLongRunningShellCommandResult { }, Cancelled, Error(ShellCommandError), + PolicyDenied { + reason: String, + }, } impl Display for WriteToLongRunningShellCommandResult { @@ -283,6 +288,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}" + ), } } } @@ -811,7 +820,11 @@ impl AIAgentActionResultType { pub fn is_failed(&self) -> bool { match self { Self::RequestCommandOutput(r) => r.failed(), - Self::RequestFileEdits( + Self::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Error(_) + | WriteToLongRunningShellCommandResult::PolicyDenied { .. }, + ) + | Self::RequestFileEdits( RequestFileEditsResult::DiffApplicationFailed { .. } | RequestFileEditsResult::PolicyDenied { .. }, ) From d5086da7fea00dd97ab93458de5b6c90230dccf6 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 20:31:29 +0300 Subject: [PATCH 21/40] Restrict hook autoapproval to successful hooks --- app/src/ai/policy_hooks/config.rs | 11 ++++--- app/src/ai/policy_hooks/decision.rs | 11 ++++++- app/src/ai/policy_hooks/tests.rs | 47 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 4d9fe85f8..c041c0f77 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -79,11 +79,12 @@ impl AgentPolicyHookConfig { } pub(crate) fn allow_autoapproval_for_all_hooks(&self) -> bool { - self.allow_hook_autoapproval - || self - .before_action - .iter() - .all(|hook| hook.allow_autoapproval) + !self.before_action.is_empty() + && (self.allow_hook_autoapproval + || self + .before_action + .iter() + .all(|hook| hook.allow_autoapproval)) } } diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index a069db157..437611ce7 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -197,7 +197,9 @@ pub(crate) fn compose_policy_decisions( warp_permission, hook_results, }, - WarpPermissionDecisionKind::Ask if allow_hook_autoapproval && !hook_results.is_empty() => { + 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()), @@ -214,3 +216,10 @@ pub(crate) fn compose_policy_decisions( 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/tests.rs b/app/src/ai/policy_hooks/tests.rs index b84ee693e..9daa5f7a7 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -56,6 +56,34 @@ fn config_enabled_without_hooks_is_active_but_invalid() { 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, + "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_deserializes_stdio_hook_shape() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -385,6 +413,25 @@ fn policy_decision_composition_is_conservative() { 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 { From 667e9fd37ae279ddab519fcc1f0f63c4e7eb2124 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 20:58:46 +0300 Subject: [PATCH 22/40] Defer file edit preprocessing on policy ask --- app/src/ai/blocklist/action_model/execute.rs | 97 +++++++++++++++++-- .../execute/request_file_edits.rs | 5 + .../blocklist/action_model/execute_tests.rs | 39 +++++++- app/src/ai/policy_hooks/config.rs | 96 +++++++++++++++++- app/src/ai/policy_hooks/tests.rs | 32 ++++++ 5 files changed, 258 insertions(+), 11 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 8421839f5..6ef1cb90b 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -351,6 +351,8 @@ pub struct BlocklistAIActionExecutor { 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>, @@ -437,6 +439,8 @@ impl BlocklistAIActionExecutor { 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, @@ -708,7 +712,17 @@ impl BlocklistAIActionExecutor { policy_reason: None, }, }; - } else if !is_user_initiated && !can_auto_execute && is_agent_autonomous { + } + + #[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::NotReady, + }; + } + + 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(); @@ -1147,10 +1161,8 @@ impl BlocklistAIActionExecutor { ctx.spawn( async move { engine.preflight(event, warp_permission).await }, move |me, decision, ctx| { - let denied = matches!( - decision.decision, - AgentPolicyDecisionKind::Deny | AgentPolicyDecisionKind::Unknown - ); + 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, @@ -1161,7 +1173,7 @@ impl BlocklistAIActionExecutor { return; } - if denied { + if !should_preprocess { let _ = done_tx.send(()); return; } @@ -1189,6 +1201,56 @@ impl BlocklistAIActionExecutor { ) } + #[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(&action.id) + }); + 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( @@ -1212,6 +1274,16 @@ impl BlocklistAIActionExecutor { return None; } + if matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) + && self + .confirmed_file_edit_policy_preprocesses + .remove(&(conversation_id, action.id.clone())) + { + return Some(PolicyPreflightState::Allowed { + skip_confirmation: false, + }); + } + let warp_permission = self.warp_permission_snapshot_for_action( action, conversation_id, @@ -1292,6 +1364,8 @@ impl BlocklistAIActionExecutor { .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"))] @@ -1673,6 +1747,17 @@ fn policy_preflight_state_from_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 should_consume_completed_policy_preflight(state: &PolicyPreflightState) -> bool { !matches!(state, PolicyPreflightState::NeedsConfirmation(_)) 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..f79694695 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 @@ -134,6 +134,11 @@ impl RequestFileEditsExecutor { self.diff_views.insert(action_id.clone(), view.clone()); } + pub(super) fn has_preprocessed_action(&self, action_id: &AIAgentActionId) -> bool { + self.diff_views.contains_key(action_id) + || self.diff_application_failures.contains_key(action_id) + } + pub(super) fn execute( &mut self, input: ExecuteActionInput, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 5a44cc02f..ad2f694f4 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -126,7 +126,8 @@ mod policy_hooks { agent_policy_action, complete_policy_preflight_if_pending, normalize_command_for_policy, policy_denied_action_result, policy_preflight_state_from_decision, recompose_completed_policy_decision, should_consume_completed_policy_preflight, - warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, + should_preprocess_file_edits_after_policy_decision, warp_permission_snapshot_for_policy, + PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -164,6 +165,21 @@ mod policy_hooks { } } + 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, + } + } + #[test] fn policy_denied_result_preserves_command_and_policy_reason() { let action = command_action("rm -rf target"); @@ -355,6 +371,27 @@ mod policy_hooks { ); } + #[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 cached_policy_decision_recomposes_against_current_warp_denial() { let cached_decision = compose_policy_decisions( diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index c041c0f77..7f6707936 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -179,7 +179,10 @@ pub(crate) enum AgentPolicyHookTransport { impl AgentPolicyHookTransport { fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { match self { - Self::Stdio { env, .. } => validate_secret_value_map(env)?, + Self::Stdio { args, env, .. } => { + validate_stdio_args(args)?; + validate_secret_value_map(env)?; + } Self::Http { url, headers } => { if http_url_contains_credentials(url) { return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); @@ -195,13 +198,14 @@ impl AgentPolicyHookTransport { match self { Self::Stdio { command, + args, env, working_directory, - .. } => { if command.trim().is_empty() { return Err(AgentPolicyHookConfigError::MissingStdioCommand); } + validate_stdio_args(args)?; validate_secret_value_map(env)?; if working_directory @@ -278,9 +282,21 @@ fn validate_secret_value_map( Ok(()) } +fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError> { + if args.iter().any(|arg| stdio_arg_contains_credentials(arg)) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } + Ok(()) +} + fn http_url_contains_credentials(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { - return !parsed.username().is_empty() || parsed.password().is_some(); + return !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query_pairs().any(|(key, value)| { + text_contains_credentials(&key) || text_contains_credentials(&value) + }) + || parsed.fragment().is_some_and(text_contains_credentials); } let url = url.trim_start(); @@ -304,7 +320,75 @@ fn http_url_contains_credentials(url: &str) -> bool { .map(|offset| authority_start + offset) .unwrap_or(url.len()); - url[authority_start..authority_end].contains('@') + if url[authority_start..authority_end].contains('@') { + return true; + } + + let suffix = &url[authority_end..]; + suffix + .strip_prefix('?') + .or_else(|| suffix.strip_prefix('#')) + .is_some_and(text_contains_credentials) +} + +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 ["apikey", "accesskey"] + .iter() + .any(|keyword| normalized.contains(keyword)) + { + return true; + } + + lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .any(|part| { + matches!( + part, + "token" | "secret" | "password" | "passwd" | "authorization" + ) + }) +} + +fn stdio_arg_contains_credentials(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let contains_env_reference = value.contains('$'); + if !contains_env_reference + && (lower.contains("authorization:") + || lower.contains("bearer ") + || lower.contains("basic ")) + { + return true; + } + + if let Some((name, secret)) = value.split_once('=') { + let secret = secret.trim(); + if text_contains_credentials(name) + && !secret.is_empty() + && !secret.starts_with('$') + && !secret.starts_with("${") + { + return true; + } + } + + value + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_') + .any(|part| { + part.strip_prefix("sk-") + .is_some_and(|token| token.len() >= 12) + || part + .strip_prefix("ghp_") + .is_some_and(|token| token.len() >= 12) + }) } #[derive(Debug, Error, PartialEq, Eq)] @@ -315,6 +399,10 @@ pub(crate) enum AgentPolicyHookConfigError { MissingHookName, #[error("agent policy hook stdio command must not be empty")] MissingStdioCommand, + #[error( + "agent policy hook stdio args must not include credentials; use env secret references" + )] + StdioArgContainsCredentials, #[error( "agent policy hook timeout must be between 1 and {MAX_AGENT_POLICY_HOOK_TIMEOUT_MS} ms" )] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 9daa5f7a7..cc58b584d 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -109,6 +109,34 @@ fn config_deserializes_stdio_hook_shape() { assert!(config.validate().is_ok()); } +#[test] +fn config_rejects_stdio_hook_credential_args() { + for args in [ + json!(["--token=secret"]), + json!(["API_KEY=secret"]), + json!(["Authorization: Bearer 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); + } +} + #[test] fn config_rejects_non_https_remote_http_hooks() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -142,6 +170,10 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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#access_token=secret", + "https://example.com/policy?authorization=Bearer%20secret", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, From 9a67599d19db733001c8c55727685f8fbf0e0657 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 21:27:47 +0300 Subject: [PATCH 23/40] Preserve file edit confirmation after policy preprocessing --- app/src/ai/blocklist/action_model/execute.rs | 11 ++++++++--- app/src/ai/blocklist/action_model/execute_tests.rs | 13 ++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 6ef1cb90b..85b4f0bb2 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1279,9 +1279,7 @@ impl BlocklistAIActionExecutor { .confirmed_file_edit_policy_preprocesses .remove(&(conversation_id, action.id.clone())) { - return Some(PolicyPreflightState::Allowed { - skip_confirmation: false, - }); + return Some(confirmed_file_edit_policy_preprocess_state()); } let warp_permission = self.warp_permission_snapshot_for_action( @@ -1758,6 +1756,13 @@ fn should_preprocess_file_edits_after_policy_decision( ) } +#[cfg(not(target_family = "wasm"))] +fn confirmed_file_edit_policy_preprocess_state() -> PolicyPreflightState { + PolicyPreflightState::Allowed { + skip_confirmation: true, + } +} + #[cfg(not(target_family = "wasm"))] fn should_consume_completed_policy_preflight(state: &PolicyPreflightState) -> bool { !matches!(state, PolicyPreflightState::NeedsConfirmation(_)) diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index ad2f694f4..a8d60759c 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -123,7 +123,8 @@ mod policy_hooks { }; use super::super::{ - agent_policy_action, complete_policy_preflight_if_pending, normalize_command_for_policy, + agent_policy_action, complete_policy_preflight_if_pending, + confirmed_file_edit_policy_preprocess_state, 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, warp_permission_snapshot_for_policy, @@ -392,6 +393,16 @@ mod policy_hooks { )); } + #[test] + fn confirmed_file_edit_policy_preprocess_retry_skips_confirmation() { + assert_eq!( + confirmed_file_edit_policy_preprocess_state(), + PolicyPreflightState::Allowed { + skip_confirmation: true + } + ); + } + #[test] fn cached_policy_decision_recomposes_against_current_warp_denial() { let cached_decision = compose_policy_decisions( From f81b9ccad0234393fbaf67d6d85cc37aebc799e8 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 21:49:09 +0300 Subject: [PATCH 24/40] Tighten policy hook credential and shell denial handling --- app/src/ai/agent/api/convert_conversation.rs | 28 ++++++---- .../agent/api/convert_conversation_tests.rs | 54 +++++++++++++++++-- app/src/ai/policy_hooks/config.rs | 32 +++++++++++ app/src/ai/policy_hooks/tests.rs | 19 +++++++ crates/ai/src/agent/action_result/convert.rs | 10 ++-- .../src/agent/action_result/convert_tests.rs | 12 +++-- crates/ai/src/agent/action_result/mod.rs | 2 + 7 files changed, 136 insertions(+), 21 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 264a0a464..c1be6927f 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -25,6 +25,7 @@ use crate::ai::agent::{ Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, FILE_EDITS_POLICY_DENIED_PREFIX, + WRITE_TO_SHELL_POLICY_DENIED_PREFIX, }; use crate::ai::block_context::BlockContext; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; @@ -642,20 +643,25 @@ 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), - }, + )) => { + if let Some(reason) = + finished.output.strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) + { + WriteToLongRunningShellCommandResult::PolicyDenied { + reason: reason.to_string(), + } + } 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), - Some(api::write_to_long_running_shell_command_result::Result::Error(api::ShellCommandError{ - r#type: None, - })) => WriteToLongRunningShellCommandResult::PolicyDenied { - reason: "blocked by host policy".to_string(), - }, - None => WriteToLongRunningShellCommandResult::Cancelled, + Some(api::write_to_long_running_shell_command_result::Result::Error(_)) | None => WriteToLongRunningShellCommandResult::Cancelled, }; Some(AIAgentInput::ActionResult { diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 753146d51..29545988f 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -168,6 +168,7 @@ fn test_convert_tool_call_result_to_input_preserves_file_edit_policy_denial() { 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, @@ -175,8 +176,16 @@ fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial 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 }, + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: Default::default(), + output: format!( + "{}{}", + crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + policy_reason + ), + exit_code: 126, + }, ), ), }, @@ -197,7 +206,7 @@ fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial crate::ai::agent::AIAgentActionResultType::WriteToLongRunningShellCommand( crate::ai::agent::WriteToLongRunningShellCommandResult::PolicyDenied { reason }, ) => { - assert_eq!(reason, "blocked by host policy"); + assert_eq!(reason, policy_reason); } other => panic!("Expected policy-denied shell write result, got {other:?}"), }, @@ -205,6 +214,45 @@ fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial } } +#[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/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 7f6707936..4ad03a392 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -286,6 +286,11 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError if args.iter().any(|arg| 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); + } Ok(()) } @@ -391,6 +396,33 @@ fn stdio_arg_contains_credentials(value: &str) -> bool { }) } +fn stdio_arg_expects_secret_value(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim_end_matches(':'); + let value = value.trim_start_matches('-'); + let normalized = value.to_ascii_lowercase().replace(['_', '-'], ""); + + matches!( + normalized.as_str(), + "apikey" + | "accesskey" + | "accesstoken" + | "auth" + | "authorization" + | "password" + | "passwd" + | "secret" + | "token" + ) +} + +fn stdio_arg_value_is_literal_secret(value: &str) -> bool { + let value = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); + !value.is_empty() && !value.contains('$') +} + #[derive(Debug, Error, PartialEq, Eq)] pub(crate) enum AgentPolicyHookConfigError { #[error("agent policy hooks are enabled but no before-action hooks are configured")] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index cc58b584d..425495585 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -113,6 +113,9 @@ fn config_deserializes_stdio_hook_shape() { fn config_rejects_stdio_hook_credential_args() { for args in [ json!(["--token=secret"]), + json!(["--token", "secret"]), + json!(["--api-key", "secret"]), + json!(["--authorization", "Bearer secret"]), json!(["API_KEY=secret"]), json!(["Authorization: Bearer secret"]), ] { @@ -137,6 +140,22 @@ fn config_rejects_stdio_hook_credential_args() { } } +#[test] +fn config_allows_stdio_hook_secret_env_reference_args() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard", + "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN"] + }] + })) + .unwrap(); + + assert!(config.validate().is_ok()); +} + #[test] fn config_rejects_non_https_remote_http_hooks() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index 7ff653368..9d631414b 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -144,12 +144,16 @@ impl TryFrom ), WriteToLongRunningShellCommandResult::Cancelled => Err(ConvertToAPITypeError::Ignore), - WriteToLongRunningShellCommandResult::PolicyDenied { .. } => { + 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::Error( - api::ShellCommandError { r#type: None }, + api::write_to_long_running_shell_command_result::Result::CommandFinished( + api::ShellCommandFinished { + command_id: Default::default(), + output: format!("{WRITE_TO_SHELL_POLICY_DENIED_PREFIX}{reason}"), + exit_code: 126, + }, ), ), }, diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 650145992..8d1e27588 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -81,7 +81,7 @@ fn policy_denied_file_edit_result_converts_to_policy_error_message() { } #[test] -fn policy_denied_write_to_shell_result_converts_to_unlabeled_error() { +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(), @@ -94,10 +94,14 @@ fn policy_denied_write_to_shell_result_converts_to_unlabeled_error() { else { panic!("expected write_to_long_running_shell_command result"); }; - let Some(api::write_to_long_running_shell_command_result::Result::Error(error)) = result.result + let Some(api::write_to_long_running_shell_command_result::Result::CommandFinished(finished)) = + result.result else { - panic!("expected error result"); + panic!("expected command_finished result"); }; - assert!(error.r#type.is_none()); + 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 d93ac01a2..a0beb122b 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -14,6 +14,8 @@ use crate::{ }; pub const FILE_EDITS_POLICY_DENIED_PREFIX: &str = "File edits blocked by host policy: "; +pub const WRITE_TO_SHELL_POLICY_DENIED_PREFIX: &str = + "Write to long-running shell command blocked by host policy: "; #[derive(Debug, Clone, PartialEq)] pub enum AIAgentActionResultType { From 1efb850ed6165893df9985dc9ba3f406e65f2ed8 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 22:33:13 +0300 Subject: [PATCH 25/40] Require policy denial markers and split secret redaction --- app/src/ai/agent/api/convert_conversation.rs | 23 +++++----- .../agent/api/convert_conversation_tests.rs | 45 ++++++++++++++++++- app/src/ai/policy_hooks/config.rs | 45 +++++++++++++++---- app/src/ai/policy_hooks/redaction.rs | 16 +++++++ app/src/ai/policy_hooks/tests.rs | 32 ++++++++++++- crates/ai/src/agent/action_result/convert.rs | 2 +- .../src/agent/action_result/convert_tests.rs | 2 +- crates/ai/src/agent/action_result/mod.rs | 1 + 8 files changed, 143 insertions(+), 23 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index c1be6927f..a3b20048b 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -24,8 +24,8 @@ use crate::ai::agent::{ RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, - UploadArtifactResult, WriteToLongRunningShellCommandResult, FILE_EDITS_POLICY_DENIED_PREFIX, - WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + UploadArtifactResult, WriteToLongRunningShellCommandResult, COMMAND_POLICY_DENIED_PREFIX, + FILE_EDITS_POLICY_DENIED_PREFIX, WRITE_TO_SHELL_POLICY_DENIED_PREFIX, }; use crate::ai::block_context::BlockContext; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; @@ -602,16 +602,17 @@ pub(crate) fn convert_tool_call_result_to_input( None => { #[allow(deprecated)] let output = result.output.as_str(); - let reason = output - .strip_prefix("Command blocked by host policy: ") - .unwrap_or(output); - if reason.is_empty() { - RequestCommandOutputResult::CancelledBeforeExecution - } else { - RequestCommandOutputResult::PolicyDenied { - command: result.command.clone(), - reason: reason.to_string(), + if let Some(reason) = output.strip_prefix(COMMAND_POLICY_DENIED_PREFIX) { + if reason.is_empty() { + RequestCommandOutputResult::CancelledBeforeExecution + } else { + RequestCommandOutputResult::PolicyDenied { + command: result.command.clone(), + reason: reason.to_string(), + } } + } else { + RequestCommandOutputResult::CancelledBeforeExecution } } }, diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 29545988f..40bc00322 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -86,7 +86,10 @@ fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { #[allow(deprecated)] let run_shell_result = api::RunShellCommandResult { command: "rm -rf target".to_string(), - output: "Command blocked by host policy: blocked by org policy".to_string(), + output: format!( + "{}blocked by org policy", + crate::ai::agent::COMMAND_POLICY_DENIED_PREFIX + ), exit_code: 0, result: Some(api::run_shell_command_result::Result::PermissionDenied( api::PermissionDenied { reason: None }, @@ -122,6 +125,46 @@ fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { } } +#[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: "generic permission denied".to_string(), + 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()); diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 4ad03a392..f1f67c80c 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -365,11 +365,8 @@ fn text_contains_credentials(value: &str) -> bool { fn stdio_arg_contains_credentials(value: &str) -> bool { let lower = value.to_ascii_lowercase(); - let contains_env_reference = value.contains('$'); - if !contains_env_reference - && (lower.contains("authorization:") - || lower.contains("bearer ") - || lower.contains("basic ")) + if (lower.contains("authorization:") || lower.contains("bearer ") || lower.contains("basic ")) + && !stdio_arg_value_uses_env_secret_reference(value) { return true; } @@ -378,8 +375,7 @@ fn stdio_arg_contains_credentials(value: &str) -> bool { let secret = secret.trim(); if text_contains_credentials(name) && !secret.is_empty() - && !secret.starts_with('$') - && !secret.starts_with("${") + && !stdio_arg_value_uses_env_secret_reference(secret) { return true; } @@ -420,7 +416,40 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { fn stdio_arg_value_is_literal_secret(value: &str) -> bool { let value = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); - !value.is_empty() && !value.contains('$') + !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)] diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index b7922fe2b..7af69f32f 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -33,6 +33,20 @@ static CURL_BASIC_AUTH_RE: Lazy = Lazy::new(|| { .expect("curl basic auth regex should compile") }); +static SPLIT_SECRET_ARG_RE: Lazy = Lazy::new(|| { + Regex::new( + r#"(?i)(^|[\s;&|])(-{1,2}(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\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}(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\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") @@ -48,6 +62,8 @@ pub(crate) fn redact_sensitive_text_for_policy(value: &str) -> String { let value = AUTHORIZATION_BASIC_RE.replace_all(&value, "$1"); 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, ""); truncate_for_policy(&value) } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 425495585..bbd028a22 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -114,10 +114,14 @@ fn config_rejects_stdio_hook_credential_args() { for args in [ json!(["--token=secret"]), json!(["--token", "secret"]), + json!(["--token", "prefix$API_TOKEN"]), json!(["--api-key", "secret"]), json!(["--authorization", "Bearer secret"]), + json!(["--authorization", "Bearer token$with-dollar"]), json!(["API_KEY=secret"]), + json!(["API_KEY=secret$with-dollar"]), json!(["Authorization: Bearer secret"]), + json!(["Authorization: Bearer token$with-dollar"]), ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -148,7 +152,7 @@ fn config_allows_stdio_hook_secret_env_reference_args() { "name": "stdio-guard", "transport": "stdio", "command": "guard", - "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN"] + "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN"] }] })) .unwrap(); @@ -402,6 +406,32 @@ fn command_redaction_handles_url_userinfo_and_basic_auth() { assert!(!redacted.contains("token@example")); } +#[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 ", + "--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("--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")); +} + #[test] fn mcp_tool_action_preserves_only_argument_keys() { let action = PolicyCallMcpToolAction::new( diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index 9d631414b..ab367f74c 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -94,7 +94,7 @@ impl TryFrom for api::request::input::tool_call_resu api::request::input::tool_call_result::Result::RunShellCommand( api::RunShellCommandResult { command, - output: format!("Command blocked by host policy: {reason}"), + output: format!("{COMMAND_POLICY_DENIED_PREFIX}{reason}"), exit_code: Default::default(), result: Some(api::run_shell_command_result::Result::PermissionDenied( api::PermissionDenied { reason: None }, diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 8d1e27588..7a660b6b7 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -53,7 +53,7 @@ fn policy_denied_shell_result_preserves_policy_reason_without_denylist_label() { let output = &result.output; assert_eq!( output.as_str(), - "Command blocked by host policy: blocked by org policy" + format!("{COMMAND_POLICY_DENIED_PREFIX}blocked by org policy") ); assert!(permission_denied.reason.is_none()); } diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index a0beb122b..c5f895de2 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -13,6 +13,7 @@ use crate::{ document::{AIDocumentId, AIDocumentVersion}, }; +pub const COMMAND_POLICY_DENIED_PREFIX: &str = "Command blocked by host policy: "; pub const FILE_EDITS_POLICY_DENIED_PREFIX: &str = "File edits blocked by host policy: "; pub const WRITE_TO_SHELL_POLICY_DENIED_PREFIX: &str = "Write to long-running shell command blocked by host policy: "; From 8ad569bfe1c1d3f476b368e807b3a5148e473d60 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Sun, 3 May 2026 23:45:48 +0300 Subject: [PATCH 26/40] Tighten policy hook secret validation --- app/src/ai/agent/redaction.rs | 68 +++++++- app/src/ai/blocklist/action_model/execute.rs | 3 +- .../blocklist/action_model/execute_tests.rs | 4 +- app/src/ai/policy_hooks/config.rs | 82 ++++++--- app/src/ai/policy_hooks/mod.rs | 2 +- app/src/ai/policy_hooks/redaction.rs | 4 +- app/src/ai/policy_hooks/tests.rs | 159 +++++++++++++++--- 7 files changed, 261 insertions(+), 61 deletions(-) diff --git a/app/src/ai/agent/redaction.rs b/app/src/ai/agent/redaction.rs index 236129208..969c2a2bc 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,11 +110,25 @@ 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 { @@ -408,3 +423,50 @@ 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}; + + #[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)); + } +} diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 85b4f0bb2..9dd4ba150 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -125,6 +125,7 @@ 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, AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, @@ -1680,7 +1681,7 @@ fn policy_denied_action_result( AIAgentActionType::RequestCommandOutput { command, .. } => { AIAgentActionResultType::RequestCommandOutput( RequestCommandOutputResult::PolicyDenied { - command: command.clone(), + command: redact_command_for_policy(command), reason, }, ) diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index a8d60759c..a5790eb94 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -183,7 +183,7 @@ mod policy_hooks { #[test] fn policy_denied_result_preserves_command_and_policy_reason() { - let action = command_action("rm -rf target"); + let action = command_action("OPENAI_API_KEY=sk-secretsecretsecret rm -rf target"); let decision = AgentPolicyEffectiveDecision { decision: AgentPolicyDecisionKind::Deny, reason: Some("blocked".to_string()), @@ -203,7 +203,7 @@ mod policy_hooks { result, AIAgentActionResultType::RequestCommandOutput( RequestCommandOutputResult::PolicyDenied { - command: "rm -rf target".to_string(), + command: "OPENAI_API_KEY= rm -rf target".to_string(), reason: "guard denied the action: dangerous command".to_string(), } ) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index f1f67c80c..b135f46c2 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -260,9 +260,13 @@ impl AgentPolicyHookSecretValue { } fn validate(&self) -> Result<(), AgentPolicyHookConfigError> { - if self.env.trim().is_empty() { + let env = self.env.trim(); + if env.is_empty() { return Err(AgentPolicyHookConfigError::MissingSecretEnvironmentVariableName); } + if !is_env_reference_name(env) || text_contains_common_token(env) { + return Err(AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName); + } Ok(()) } } @@ -331,9 +335,8 @@ fn http_url_contains_credentials(url: &str) -> bool { let suffix = &url[authority_end..]; suffix - .strip_prefix('?') - .or_else(|| suffix.strip_prefix('#')) - .is_some_and(text_contains_credentials) + .find(|ch| matches!(ch, '?' | '#')) + .is_some_and(|offset| text_contains_credentials(&suffix[offset + 1..])) } fn text_contains_credentials(value: &str) -> bool { @@ -346,9 +349,13 @@ fn text_contains_credentials(value: &str) -> bool { } let normalized = lower.replace(['_', '-'], ""); - if ["apikey", "accesskey"] - .iter() - .any(|keyword| normalized.contains(keyword)) + if normalized.contains("apikey") + || normalized.contains("accesskey") + || normalized.ends_with("token") + || normalized.ends_with("secret") + || normalized.ends_with("password") + || normalized.ends_with("passwd") + || normalized.ends_with("authorization") { return true; } @@ -361,11 +368,19 @@ fn text_contains_credentials(value: &str) -> bool { "token" | "secret" | "password" | "passwd" | "authorization" ) }) + || text_contains_common_token(value) } fn stdio_arg_contains_credentials(value: &str) -> bool { let lower = value.to_ascii_lowercase(); - if (lower.contains("authorization:") || lower.contains("bearer ") || lower.contains("basic ")) + 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 (lower.contains("bearer ") || lower.contains("basic ")) && !stdio_arg_value_uses_env_secret_reference(value) { return true; @@ -381,14 +396,25 @@ fn stdio_arg_contains_credentials(value: &str) -> bool { } } + text_contains_common_token(value) +} + +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) - || part - .strip_prefix("ghp_") - .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 == '_') + }) + }) }) } @@ -396,22 +422,26 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { let value = value .trim() .trim_matches(|ch| ch == '"' || ch == '\'') - .trim_end_matches(':'); - let value = value.trim_start_matches('-'); + .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(['_', '-'], ""); - matches!( - normalized.as_str(), - "apikey" - | "accesskey" - | "accesstoken" - | "auth" - | "authorization" - | "password" - | "passwd" - | "secret" - | "token" - ) + normalized.contains("apikey") + || normalized.contains("accesskey") + || normalized.ends_with("token") + || normalized.ends_with("secret") + || normalized.ends_with("password") + || normalized.ends_with("passwd") + || normalized.ends_with("authorization") + || normalized == "auth" } fn stdio_arg_value_is_literal_secret(value: &str) -> bool { @@ -478,6 +508,8 @@ pub(crate) enum AgentPolicyHookConfigError { HttpUrlContainsCredentials, #[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> { diff --git a/app/src/ai/policy_hooks/mod.rs b/app/src/ai/policy_hooks/mod.rs index 8ff251be4..e01218f0f 100644 --- a/app/src/ai/policy_hooks/mod.rs +++ b/app/src/ai/policy_hooks/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod decision; #[cfg(not(target_family = "wasm"))] pub(crate) mod engine; pub(crate) mod event; -mod redaction; +pub(crate) mod redaction; pub(crate) use config::AgentPolicyHookConfig; pub(crate) use decision::{ diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 7af69f32f..4fc82b680 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -35,14 +35,14 @@ static CURL_BASIC_AUTH_RE: Lazy = Lazy::new(|| { static SPLIT_SECRET_ARG_RE: Lazy = Lazy::new(|| { Regex::new( - r#"(?i)(^|[\s;&|])(-{1,2}(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\b\s+)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + r#"(?i)(^|[\s;&|])(-{1,2}[a-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\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}(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\b=)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + r#"(?i)(^|[\s;&|])(-{1,2}[a-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|access[-_]?key|authorization|auth)\b=)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, ) .expect("inline secret arg regex should compile") }); diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index bbd028a22..6d427c0c4 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -116,12 +116,18 @@ fn config_rejects_stdio_hook_credential_args() { json!(["--token", "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"]), ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -152,7 +158,7 @@ fn config_allows_stdio_hook_secret_env_reference_args() { "name": "stdio-guard", "transport": "stdio", "command": "guard", - "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN"] + "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN", "X-API-Key:", "$HEADER_API_KEY", "Authorization:", "Bearer $HEADER_TOKEN"] }] })) .unwrap(); @@ -195,7 +201,17 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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?accessToken=abc123", + "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#state=sk-secretsecretsecret", "https://example.com/policy?authorization=Bearer%20secret", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -208,16 +224,36 @@ fn config_rejects_http_hook_url_embedded_credentials() { })) .unwrap(); - assert!(config.validate().is_err()); + 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_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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": false, @@ -229,36 +265,45 @@ fn config_rejects_disabled_http_hook_url_embedded_credentials() { })) .unwrap(); - assert!(config.validate().is_err()); + assert!(matches!( + config.validate(), + Err(super::config::AgentPolicyHookConfigError::HttpUrlContainsCredentials) + )); } } #[test] fn profile_serialization_sanitizes_disabled_http_hook_url_embedded_credentials() { - let agent_policy_hooks = AgentPolicyHookConfig { - enabled: false, - before_action: vec![AgentPolicyHook { - name: "remote-guard".to_string(), - transport: AgentPolicyHookTransport::Http { - url: "https:user:pass@example.com/policy".to_string(), - headers: Default::default(), - }, + for url in [ + "https:user:pass@example.com/policy", + "https://example .com/policy?q=sk-secretsecretsecret", + ] { + 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() - }], - ..Default::default() - }; - let profile = AIExecutionProfile { - agent_policy_hooks, - ..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('@')); + 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] @@ -299,6 +344,37 @@ fn config_rejects_inline_hook_secret_values() { 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_serialization_preserves_secret_environment_references() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -412,6 +488,8 @@ fn command_redaction_handles_split_secret_args() { "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 ", "--safe visible" ); @@ -423,6 +501,10 @@ fn command_redaction_handles_split_secret_args() { 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("--safe visible")); assert!(!redacted.contains("token-secret")); assert!(!redacted.contains("quoted secret")); @@ -430,6 +512,10 @@ fn command_redaction_handles_split_secret_args() { 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")); } #[test] @@ -818,7 +904,26 @@ async fn http_engine_rejects_oversized_policy_event_before_request() { #[cfg(all(unix, not(target_family = "wasm")))] #[tokio::test] async fn stdio_engine_does_not_inherit_parent_environment() { - std::env::var("HOME").expect("HOME must be set for policy hook environment inheritance test"); + 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": [{ @@ -827,7 +932,7 @@ async fn stdio_engine_does_not_inherit_parent_environment() { "command": "/bin/sh", "args": [ "-c", - "cat >/dev/null; if [ -n \"${HOME+x}\" ]; then printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"deny\",\"reason\":\"inherited HOME\"}'; else printf '%s\\n' '{\"schema_version\":\"warp.agent_policy_hook.v1\",\"decision\":\"allow\"}'; fi" + "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 }] From 5f271ec09c6d3af496fa675bd2c2ef1628f47cbe Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 01:19:12 +0300 Subject: [PATCH 27/40] Close remaining policy hook audit gaps --- app/src/ai/agent/api/convert_conversation.rs | 20 ++- .../agent/api/convert_conversation_tests.rs | 118 ++++++++++++++++- app/src/ai/agent/redaction.rs | 45 ++++++- app/src/ai/blocklist/action_model/execute.rs | 99 +++++++++++++-- .../blocklist/action_model/execute_tests.rs | 119 +++++++++++++++++- app/src/ai/policy_hooks/config.rs | 34 +++-- app/src/ai/policy_hooks/tests.rs | 75 +++++++++++ crates/ai/src/agent/action_result/convert.rs | 4 +- .../src/agent/action_result/convert_tests.rs | 2 + crates/ai/src/agent/action_result/mod.rs | 2 + 10 files changed, 481 insertions(+), 37 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index a3b20048b..591ab0214 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -25,11 +25,13 @@ use crate::ai::agent::{ Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, COMMAND_POLICY_DENIED_PREFIX, - FILE_EDITS_POLICY_DENIED_PREFIX, WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + FILE_EDITS_POLICY_DENIED_PREFIX, 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_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; @@ -645,11 +647,19 @@ pub(crate) fn convert_tool_call_result_to_input( Some(api::write_to_long_running_shell_command_result::Result::CommandFinished( finished, )) => { - if let Some(reason) = - finished.output.strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) - { + 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: reason.to_string(), + reason: redact_sensitive_text_for_policy(reason), } } else { WriteToLongRunningShellCommandResult::CommandFinished { diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 40bc00322..740c328e4 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -221,13 +221,15 @@ fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial result: Some( api::write_to_long_running_shell_command_result::Result::CommandFinished( api::ShellCommandFinished { - command_id: Default::default(), + 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: 126, + exit_code: crate::ai::agent::WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, }, ), ), @@ -257,6 +259,118 @@ fn test_convert_tool_call_result_to_input_preserves_write_to_shell_policy_denial } } +#[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()); diff --git a/app/src/ai/agent/redaction.rs b/app/src/ai/agent/redaction.rs index 969c2a2bc..6e72946e5 100644 --- a/app/src/ai/agent/redaction.rs +++ b/app/src/ai/agent/redaction.rs @@ -134,7 +134,10 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) { match result { Snapshot { grid_contents, .. } => redact_secrets(grid_contents), CommandFinished { output, .. } => redact_secrets(output), - PolicyDenied { reason } => redact_secrets(reason), + PolicyDenied { reason } => { + *reason = redact_sensitive_text_for_policy(reason); + redact_secrets(reason); + } Error(_) | Cancelled => {} } } @@ -430,7 +433,9 @@ mod tests { use super::*; use crate::ai::agent::task::TaskId; - use crate::ai::agent::{AIAgentActionResult, AIAgentActionResultType}; + use crate::ai::agent::{ + AIAgentActionResult, AIAgentActionResultType, WriteToLongRunningShellCommandResult, + }; #[test] fn redact_inputs_redacts_policy_denied_command_result_command() { @@ -469,4 +474,40 @@ mod tests { 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")); + } } diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 9dd4ba150..c99fdb8f0 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -125,7 +125,7 @@ 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, + redaction::{redact_command_for_policy, redact_sensitive_text_for_policy}, AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, @@ -1275,14 +1275,6 @@ impl BlocklistAIActionExecutor { return None; } - if matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) - && self - .confirmed_file_edit_policy_preprocesses - .remove(&(conversation_id, action.id.clone())) - { - return Some(confirmed_file_edit_policy_preprocess_state()); - } - let warp_permission = self.warp_permission_snapshot_for_action( action, conversation_id, @@ -1301,15 +1293,62 @@ impl BlocklistAIActionExecutor { )?; let preflight_key = PolicyPreflightKey::new(conversation_id, action.id.clone(), &event.action); + 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) { + if let Some(decision) = self + .completed_policy_preflights + .get(&preflight_key) + .cloned() + { let decision = recompose_completed_policy_decision( - decision, + &decision, warp_permission, config.allow_autoapproval_for_all_hooks(), ); let state = policy_preflight_state_from_decision(action, &decision, is_user_initiated); - if should_consume_completed_policy_preflight(&state) { + 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(&action.id) + }); + 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); @@ -1676,7 +1715,7 @@ fn policy_denied_action_result( action: &AIAgentAction, decision: &AgentPolicyEffectiveDecision, ) -> AIAgentActionResultType { - let reason = policy_denied_message(decision); + let reason = redact_sensitive_text_for_policy(&policy_denied_message(decision)); match &action.action { AIAgentActionType::RequestCommandOutput { command, .. } => { AIAgentActionResultType::RequestCommandOutput( @@ -1764,6 +1803,40 @@ fn confirmed_file_edit_policy_preprocess_state() -> PolicyPreflightState { } } +#[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(_)) diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index a5790eb94..d1a6e930b 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -124,11 +124,13 @@ mod policy_hooks { use super::super::{ agent_policy_action, complete_policy_preflight_if_pending, - confirmed_file_edit_policy_preprocess_state, 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, warp_permission_snapshot_for_policy, - PolicyPreflightKey, PolicyPreflightState, + confirmed_file_edit_policy_preprocess_state_from_cached_decision, + 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, + warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -395,14 +397,119 @@ mod policy_hooks { #[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(), + 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::PolicyDenied { + reason: "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( diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index b135f46c2..4a8fe5a67 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -4,6 +4,7 @@ use std::{ path::{Path, PathBuf}, }; +use http::header::HeaderName; use serde::{ser::SerializeStruct, Deserialize, Serialize}; use thiserror::Error; @@ -181,13 +182,13 @@ impl AgentPolicyHookTransport { match self { Self::Stdio { args, env, .. } => { validate_stdio_args(args)?; - validate_secret_value_map(env)?; + validate_stdio_secret_value_map(env)?; } Self::Http { url, headers } => { if http_url_contains_credentials(url) { return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); } - validate_secret_value_map(headers)?; + validate_http_secret_value_map(headers)?; } } @@ -206,7 +207,7 @@ impl AgentPolicyHookTransport { return Err(AgentPolicyHookConfigError::MissingStdioCommand); } validate_stdio_args(args)?; - validate_secret_value_map(env)?; + validate_stdio_secret_value_map(env)?; if working_directory .as_deref() @@ -232,7 +233,7 @@ impl AgentPolicyHookTransport { return Err(AgentPolicyHookConfigError::InsecureHttpUrl(url.clone())); } - validate_secret_value_map(headers)?; + validate_http_secret_value_map(headers)?; } } @@ -264,7 +265,7 @@ impl AgentPolicyHookSecretValue { if env.is_empty() { return Err(AgentPolicyHookConfigError::MissingSecretEnvironmentVariableName); } - if !is_env_reference_name(env) || text_contains_common_token(env) { + if env != self.env || !is_env_reference_name(env) || text_contains_common_token(env) { return Err(AgentPolicyHookConfigError::InvalidSecretEnvironmentVariableName); } Ok(()) @@ -277,10 +278,27 @@ impl fmt::Debug for AgentPolicyHookSecretValue { } } -fn validate_secret_value_map( +fn validate_stdio_secret_value_map( values: &BTreeMap, ) -> Result<(), AgentPolicyHookConfigError> { - for value in values.values() { + 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(()) @@ -506,6 +524,8 @@ pub(crate) enum AgentPolicyHookConfigError { 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")] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 6d427c0c4..d969855d4 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -375,6 +375,81 @@ fn config_rejects_object_shaped_hook_secret_literals() { 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!({ diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index ab367f74c..0e8ef62ad 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -150,9 +150,9 @@ impl TryFrom result: Some( api::write_to_long_running_shell_command_result::Result::CommandFinished( api::ShellCommandFinished { - command_id: Default::default(), + command_id: WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID.to_string(), output: format!("{WRITE_TO_SHELL_POLICY_DENIED_PREFIX}{reason}"), - exit_code: 126, + exit_code: WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, }, ), ), diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 7a660b6b7..b7635e249 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -100,6 +100,8 @@ fn policy_denied_write_to_shell_result_converts_to_policy_marker() { 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 c5f895de2..a5a5dc2f4 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -17,6 +17,8 @@ pub const COMMAND_POLICY_DENIED_PREFIX: &str = "Command blocked by host policy: pub const FILE_EDITS_POLICY_DENIED_PREFIX: &str = "File edits blocked by host policy: "; 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; #[derive(Debug, Clone, PartialEq)] pub enum AIAgentActionResultType { From 70e6dea0f98f4be4ce274365fad210377a29c04c Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 01:31:53 +0300 Subject: [PATCH 28/40] Harden policy hook restore and MCP redaction --- app/src/ai/agent/api/convert_conversation.rs | 16 ++------ .../agent/api/convert_conversation_tests.rs | 19 +++++---- app/src/ai/policy_hooks/event.rs | 9 +++-- app/src/ai/policy_hooks/redaction.rs | 2 +- app/src/ai/policy_hooks/tests.rs | 40 ++++++++++++++++++- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 591ab0214..d877fb461 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -25,8 +25,8 @@ use crate::ai::agent::{ Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, COMMAND_POLICY_DENIED_PREFIX, - FILE_EDITS_POLICY_DENIED_PREFIX, WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, - WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + 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}; @@ -803,16 +803,8 @@ pub(crate) fn convert_tool_call_result_to_input( } } Some(api::apply_file_diffs_result::Result::Error(error)) => { - if let Some(reason) = - error.message.strip_prefix(FILE_EDITS_POLICY_DENIED_PREFIX) - { - RequestFileEditsResult::PolicyDenied { - reason: reason.to_string(), - } - } else { - RequestFileEditsResult::DiffApplicationFailed { - error: error.message.clone(), - } + 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 740c328e4..280dfe142 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -166,9 +166,13 @@ fn test_convert_tool_call_result_to_input_treats_unmarked_permission_denied_as_c } #[test] -fn test_convert_tool_call_result_to_input_preserves_file_edit_policy_denial() { +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, @@ -176,10 +180,7 @@ fn test_convert_tool_call_result_to_input_preserves_file_edit_policy_denial() { api::ApplyFileDiffsResult { result: Some(api::apply_file_diffs_result::Result::Error( api::apply_file_diffs_result::Error { - message: format!( - "{}protected path", - crate::ai::agent::FILE_EDITS_POLICY_DENIED_PREFIX - ), + message: error.clone(), }, )), }, @@ -197,11 +198,13 @@ fn test_convert_tool_call_result_to_input_preserves_file_edit_policy_denial() { match input { AIAgentInput::ActionResult { result, .. } => match result.result { crate::ai::agent::AIAgentActionResultType::RequestFileEdits( - crate::ai::agent::RequestFileEditsResult::PolicyDenied { reason }, + crate::ai::agent::RequestFileEditsResult::DiffApplicationFailed { + error: actual_error, + }, ) => { - assert_eq!(reason, "protected path"); + assert_eq!(actual_error, error); } - other => panic!("Expected policy-denied file edit result, got {other:?}"), + other => panic!("Expected diff-application failure result, got {other:?}"), }, other => panic!("Expected action-result input, got {other:?}"), } diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index acab2bc07..e18c4c8e5 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize, Serializer}; use super::{ decision::WarpPermissionSnapshot, redaction::{ - capped_policy_items, mcp_argument_keys, redact_command_for_policy, truncate_for_policy, + capped_policy_items, mcp_argument_keys, redact_command_for_policy, + redact_sensitive_text_for_policy, truncate_for_policy, }, }; @@ -254,7 +255,7 @@ impl PolicyCallMcpToolAction { let tool_name = tool_name.into(); Self { server_id, - tool_name: truncate_for_policy(&tool_name), + tool_name: redact_sensitive_text_for_policy(&tool_name), argument_keys, omitted_argument_key_count, } @@ -279,8 +280,8 @@ impl PolicyReadMcpResourceAction { let name = name.into(); Self { server_id, - name: truncate_for_policy(&name), - uri: uri.map(|uri| truncate_for_policy(&uri)), + name: redact_sensitive_text_for_policy(&name), + uri: uri.map(|uri| redact_sensitive_text_for_policy(&uri)), } } } diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 4fc82b680..9b78958a7 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -76,7 +76,7 @@ pub(crate) fn mcp_argument_keys(arguments: &serde_json::Value) -> (Vec, let mut keys = map .keys() .take(MAX_POLICY_COLLECTION_ITEMS) - .map(|key| truncate_for_policy(key)) + .map(|key| redact_sensitive_text_for_policy(key)) .collect::>(); keys.sort(); let omitted_count = map.len().saturating_sub(keys.len()); diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index d969855d4..9e8a5dfc0 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -15,7 +15,7 @@ use super::{ }, event::{ AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, - PolicyReadFilesAction, AGENT_POLICY_SCHEMA_VERSION, + PolicyReadFilesAction, PolicyReadMcpResourceAction, AGENT_POLICY_SCHEMA_VERSION, }, redaction::{redact_command_for_policy, MAX_POLICY_COLLECTION_ITEMS}, }; @@ -609,6 +609,44 @@ fn mcp_tool_action_preserves_only_argument_keys() { 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) From 84ddf35e0cebe0b2374cdfb6aa5dd7bde2c7abe2 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 01:49:57 +0300 Subject: [PATCH 29/40] Preserve file edit policy denials with marker --- app/src/ai/agent/api/convert_conversation.rs | 38 ++++---- .../agent/api/convert_conversation_tests.rs | 86 +++++++++++++++++++ crates/ai/src/agent/action_result/convert.rs | 2 +- .../src/agent/action_result/convert_tests.rs | 7 +- crates/ai/src/agent/action_result/mod.rs | 23 +++++ .../ai/src/agent/action_result/mod_tests.rs | 35 +++++++- 6 files changed, 170 insertions(+), 21 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index d877fb461..2e0a68d25 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -13,20 +13,20 @@ use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::agent::task::TaskId; use crate::ai::agent::todos::AIAgentTodoList; use crate::ai::agent::{ - AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentExchange, - AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputStatus, - CallMCPToolResult, CancellationReason, CloneRepositoryURL, CreateDocumentsResult, - DocumentContext, EditDocumentsResult, FileContext, FileGlobResult, FileGlobV2Match, - FileGlobV2Result, FinishedAIAgentOutput, GrepFileMatch, GrepLineMatch, GrepResult, - ImageContext, InsertReviewCommentsResult, OutputModelInfo, PassiveCodeDiffEntry, - PassiveSuggestionResultType, PassiveSuggestionTrigger, ReadDocumentsResult, ReadFilesResult, - ReadMCPResourceResult, ReadShellCommandOutputResult, RequestCommandOutputResult, - RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, - Shared, ShellCommandCompletedTrigger, ShellCommandError, SuggestNewConversationResult, - SuggestPromptResult, TransferShellCommandControlToUserResult, UpdatedFileContext, - UploadArtifactResult, WriteToLongRunningShellCommandResult, COMMAND_POLICY_DENIED_PREFIX, - WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, - WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + decode_file_edits_policy_denied_reason, AIAgentActionResult, AIAgentActionResultType, + AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput, + AIAgentOutputMessage, AIAgentOutputStatus, CallMCPToolResult, CancellationReason, + CloneRepositoryURL, CreateDocumentsResult, DocumentContext, EditDocumentsResult, FileContext, + FileGlobResult, FileGlobV2Match, FileGlobV2Result, FinishedAIAgentOutput, GrepFileMatch, + GrepLineMatch, GrepResult, ImageContext, InsertReviewCommentsResult, OutputModelInfo, + PassiveCodeDiffEntry, PassiveSuggestionResultType, PassiveSuggestionTrigger, + ReadDocumentsResult, ReadFilesResult, ReadMCPResourceResult, ReadShellCommandOutputResult, + RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseFailureReason, + SearchCodebaseResult, ServerOutputId, Shared, ShellCommandCompletedTrigger, ShellCommandError, + SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, + UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, + COMMAND_POLICY_DENIED_PREFIX, 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}; @@ -803,8 +803,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 280dfe142..4304ade3f 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -165,6 +165,92 @@ fn test_convert_tool_call_result_to_input_treats_unmarked_permission_denied_as_c } } +#[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()); diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index 0e8ef62ad..ef208ef1d 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -322,7 +322,7 @@ impl TryFrom for api::request::input::tool_call_result:: api::ApplyFileDiffsResult { result: Some(api::apply_file_diffs_result::Result::Error( api::apply_file_diffs_result::Error { - message: format!("{FILE_EDITS_POLICY_DENIED_PREFIX}{reason}"), + message: encode_file_edits_policy_denied_message(&reason), }, )), }, diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index b7635e249..448078f97 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -59,7 +59,7 @@ fn policy_denied_shell_result_preserves_policy_reason_without_denylist_label() { } #[test] -fn policy_denied_file_edit_result_converts_to_policy_error_message() { +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(), @@ -75,9 +75,10 @@ fn policy_denied_file_edit_result_converts_to_policy_error_message() { }; assert_eq!( - error.message, - "File edits blocked by host policy: protected path" + decode_file_edits_policy_denied_reason(&error.message).as_deref(), + Some("protected path") ); + assert!(!error.message.starts_with(FILE_EDITS_POLICY_DENIED_PREFIX)); } #[test] diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index a5a5dc2f4..f86ed1353 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -15,11 +15,34 @@ use crate::{ pub const COMMAND_POLICY_DENIED_PREFIX: &str = "Command blocked by host policy: "; 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, +} + +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. 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() { From 6f5802b0779df0a91eaa2638e40b0319beff99a5 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 02:09:48 +0300 Subject: [PATCH 30/40] Harden policy preflight caching and denials --- app/src/ai/agent/conversation_yaml.rs | 4 +- app/src/ai/agent/conversation_yaml_tests.rs | 35 ++++ app/src/ai/blocklist/action_model/execute.rs | 81 ++++++- .../blocklist/action_model/execute_tests.rs | 198 +++++++++++++++++- app/src/ai/policy_hooks/config.rs | 8 +- app/src/ai/policy_hooks/decision.rs | 2 +- 6 files changed, 306 insertions(+), 22 deletions(-) diff --git a/app/src/ai/agent/conversation_yaml.rs b/app/src/ai/agent/conversation_yaml.rs index 40359ef32..bb02baf09 100644 --- a/app/src/ai/agent/conversation_yaml.rs +++ b/app/src/ai/agent/conversation_yaml.rs @@ -15,6 +15,8 @@ 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}; const BASE_DIR_NAME: &str = "warp_conversation_search"; @@ -555,7 +557,7 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) let output = &r.output; if !output.is_empty() { out.push_str("reason: |\n"); - write_block_scalar(out, output); + write_block_scalar(out, &redact_sensitive_text_for_policy(output)); } } } diff --git a/app/src/ai/agent/conversation_yaml_tests.rs b/app/src/ai/agent/conversation_yaml_tests.rs index 22aa4002e..39099d56f 100644 --- a/app/src/ai/agent/conversation_yaml_tests.rs +++ b/app/src/ai/agent/conversation_yaml_tests.rs @@ -283,6 +283,41 @@ 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: "blocked PASSWORD=hunter2 --token raw-token".to_string(), + 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 server_tool_calls_are_skipped() { let task_id = "root"; diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index c99fdb8f0..a08e38d16 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -127,9 +127,9 @@ use crate::ai::policy_hooks::{ decision::{compose_policy_decisions, WarpPermissionDecisionKind}, redaction::{redact_command_for_policy, redact_sensitive_text_for_policy}, AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, AgentPolicyEvent, - AgentPolicyHookEngine, PolicyCallMcpToolAction, PolicyExecuteCommandAction, - PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, - PolicyWriteToLongRunningShellCommandAction, WarpPermissionSnapshot, + AgentPolicyHookConfig, AgentPolicyHookEngine, PolicyCallMcpToolAction, + PolicyExecuteCommandAction, PolicyReadFilesAction, PolicyReadMcpResourceAction, + PolicyWriteFilesAction, PolicyWriteToLongRunningShellCommandAction, WarpPermissionSnapshot, }; /// Types of actions that can be executed in parallel. @@ -166,7 +166,11 @@ struct PreprocessActionInput<'a> { struct PolicyPreflightKey { conversation_id: AIConversationId, action_id: AIAgentActionId, + working_directory: Option, + run_until_completion: bool, + active_profile_id: Option, action: AgentPolicyAction, + hook_config: AgentPolicyHookConfig, } #[cfg(not(target_family = "wasm"))] @@ -174,12 +178,17 @@ impl PolicyPreflightKey { fn new( conversation_id: AIConversationId, action_id: AIAgentActionId, - action: &AgentPolicyAction, + event: &AgentPolicyEvent, + hook_config: &AgentPolicyHookConfig, ) -> Self { Self { conversation_id, action_id, - action: action.clone(), + working_directory: event.working_directory.clone(), + run_until_completion: event.run_until_completion, + active_profile_id: event.active_profile_id.clone(), + action: event.action.clone(), + hook_config: hook_config.clone(), } } @@ -1153,7 +1162,7 @@ impl BlocklistAIActionExecutor { let action = (*input.action).clone(); let conversation_id = input.conversation_id; let preflight_key = - PolicyPreflightKey::new(conversation_id, action.id.clone(), &event.action); + PolicyPreflightKey::new(conversation_id, action.id.clone(), &event, &config); let (done_tx, done_rx) = oneshot::channel(); let engine = AgentPolicyHookEngine::new(config); self.remove_policy_preflights_for_action(conversation_id, &action.id); @@ -1292,7 +1301,7 @@ impl BlocklistAIActionExecutor { ctx, )?; let preflight_key = - PolicyPreflightKey::new(conversation_id, action.id.clone(), &event.action); + PolicyPreflightKey::new(conversation_id, action.id.clone(), &event, &config); let confirmed_file_edit_policy_preprocess = matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) && self @@ -1715,6 +1724,10 @@ 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, .. } => { @@ -1748,6 +1761,60 @@ fn policy_denied_action_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: command.clone(), + }) + } + 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, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index d1a6e930b..c2b7a72a8 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -100,7 +100,10 @@ mod binary_detection { #[cfg(not(target_family = "wasm"))] mod policy_hooks { - use std::collections::{HashMap, HashSet}; + use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + }; use crate::{ ai::{ @@ -117,6 +120,7 @@ mod policy_hooks { WarpPermissionDecisionKind, WarpPermissionSnapshot, }, AgentPolicyAction, AgentPolicyDecisionKind, AgentPolicyEffectiveDecision, + AgentPolicyEvent, AgentPolicyHookConfig, }, }, terminal::shell::ShellType, @@ -155,6 +159,28 @@ mod policy_hooks { .expect("command action should build a policy action") } + fn policy_preflight_key( + conversation_id: AIConversationId, + action_id: AIAgentActionId, + action: AgentPolicyAction, + ) -> PolicyPreflightKey { + let event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + None, + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + action, + ); + PolicyPreflightKey::new( + conversation_id, + action_id, + &event, + &AgentPolicyHookConfig::default(), + ) + } + fn write_to_shell_action(input: &str) -> AIAgentAction { AIAgentAction { id: AIAgentActionId::from("action_1".to_string()), @@ -277,6 +303,64 @@ mod policy_hooks { ); } + #[test] + fn warp_denied_command_result_preserves_denylisted_variant() { + let action = command_action("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: "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); @@ -449,8 +533,8 @@ mod policy_hooks { assert_eq!( state, PolicyPreflightState::Denied(AIAgentActionResultType::RequestFileEdits( - RequestFileEditsResult::PolicyDenied { - reason: "managed policy changed".to_string() + RequestFileEditsResult::DiffApplicationFailed { + error: "Blocked by Warp permissions: managed policy changed".to_string() } )) ); @@ -573,8 +657,9 @@ mod policy_hooks { let policy_action = policy_command_action("ls"); let conversation_one = AIConversationId::new(); let conversation_two = AIConversationId::new(); - let key_one = PolicyPreflightKey::new(conversation_one, action_id.clone(), &policy_action); - let key_two = PolicyPreflightKey::new(conversation_two, action_id, &policy_action); + let key_one = + policy_preflight_key(conversation_one, action_id.clone(), policy_action.clone()); + let key_two = policy_preflight_key(conversation_two, action_id, policy_action); assert_ne!(key_one, key_two); @@ -590,20 +675,115 @@ mod policy_hooks { let old_action = policy_command_action("echo old"); let new_action = policy_command_action("echo new"); - let old_key = PolicyPreflightKey::new(conversation_id, action_id.clone(), &old_action); - let new_key = PolicyPreflightKey::new(conversation_id, action_id.clone(), &new_action); + 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_scopes_policy_event_context() { + let conversation_id = AIConversationId::new(); + let action_id = AIAgentActionId::from("action_1".to_string()); + let action = policy_command_action("ls"); + 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), + 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), + 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), + 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), + action, + ); + + let base_key = + PolicyPreflightKey::new(conversation_id, action_id.clone(), &base_event, &config); + + assert_ne!( + base_key, + PolicyPreflightKey::new(conversation_id, action_id.clone(), &changed_cwd, &config) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new( + conversation_id, + action_id.clone(), + &changed_run_mode, + &config + ) + ); + assert_ne!( + base_key, + PolicyPreflightKey::new(conversation_id, action_id, &changed_profile, &config) + ); + } + + #[test] + fn policy_preflight_key_scopes_hook_config() { + let conversation_id = AIConversationId::new(); + let action_id = AIAgentActionId::from("action_1".to_string()); + let event = AgentPolicyEvent::new( + conversation_id.to_string(), + action_id.to_string(), + None, + false, + Some("profile_default".to_string()), + WarpPermissionSnapshot::allow(None), + policy_command_action("ls"), + ); + 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(), &event, &old_config), + PolicyPreflightKey::new(conversation_id, action_id, &event, &new_config) + ); + } + #[test] fn cancelled_policy_preflight_completion_is_not_cached() { let action_id = AIAgentActionId::from("action_1".to_string()); - let preflight_key = PolicyPreflightKey::new( + let preflight_key = policy_preflight_key( AIConversationId::new(), action_id, - &policy_command_action("ls"), + policy_command_action("ls"), ); let decision = AgentPolicyEffectiveDecision { decision: AgentPolicyDecisionKind::Allow, diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 4a8fe5a67..4bcff6255 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -13,7 +13,7 @@ use super::decision::AgentPolicyUnavailableDecision; 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, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)] #[serde(default)] pub(crate) struct AgentPolicyHookConfig { pub enabled: bool, @@ -112,7 +112,7 @@ impl Serialize for AgentPolicyHookConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub(crate) struct AgentPolicyHook { pub name: String, @@ -158,7 +158,7 @@ impl Default for AgentPolicyHook { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(tag = "transport", rename_all = "snake_case")] pub(crate) enum AgentPolicyHookTransport { Stdio { @@ -243,7 +243,7 @@ impl AgentPolicyHookTransport { /// 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, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct AgentPolicyHookSecretValue { env: String, diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index 437611ce7..2980290a9 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -12,7 +12,7 @@ pub(crate) enum AgentPolicyDecisionKind { Unknown, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AgentPolicyUnavailableDecision { Allow, From d069f47d99d76768be454ab0dcc533e129b7b5f0 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 02:25:28 +0300 Subject: [PATCH 31/40] Validate stdio hook command credentials --- app/src/ai/policy_hooks/config.rs | 25 ++++++++++++++++- app/src/ai/policy_hooks/tests.rs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 4bcff6255..966797a62 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -180,7 +180,10 @@ pub(crate) enum AgentPolicyHookTransport { impl AgentPolicyHookTransport { fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { match self { - Self::Stdio { args, env, .. } => { + Self::Stdio { + command, args, env, .. + } => { + validate_stdio_command(command)?; validate_stdio_args(args)?; validate_stdio_secret_value_map(env)?; } @@ -206,6 +209,7 @@ impl AgentPolicyHookTransport { if command.trim().is_empty() { return Err(AgentPolicyHookConfigError::MissingStdioCommand); } + validate_stdio_command(command)?; validate_stdio_args(args)?; validate_stdio_secret_value_map(env)?; @@ -316,6 +320,21 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError Ok(()) } +fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigError> { + if stdio_arg_contains_credentials(command) { + return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + } + + let words = command.split_ascii_whitespace().collect::>(); + if words.windows(2).any(|words| { + stdio_arg_expects_secret_value(words[0]) && stdio_arg_value_is_literal_secret(words[1]) + }) { + return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + } + + Ok(()) +} + fn http_url_contains_credentials(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { return !parsed.username().is_empty() @@ -508,6 +527,10 @@ pub(crate) enum AgentPolicyHookConfigError { MissingHookName, #[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" )] diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 9e8a5dfc0..0048fee82 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -150,6 +150,37 @@ fn config_rejects_stdio_hook_credential_args() { } } +#[test] +fn config_rejects_stdio_hook_credential_command() { + for command in [ + "guard --token secret", + "guard --authorization 'Bearer raw-token'", + "API_KEY=secret guard", + "guard sk-secretsecretsecret", + "guard ghp_secretsecretsecret", + ] { + 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")); + } +} + #[test] fn config_allows_stdio_hook_secret_env_reference_args() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ @@ -166,6 +197,21 @@ fn config_allows_stdio_hook_secret_env_reference_args() { assert!(config.validate().is_ok()); } +#[test] +fn config_allows_stdio_hook_secret_env_reference_command() { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": "guard --token $API_TOKEN" + }] + })) + .unwrap(); + + assert!(config.validate().is_ok()); +} + #[test] fn config_rejects_non_https_remote_http_hooks() { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ From f2a8afb35a1e40d023c772403309e535412077c4 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 02:43:38 +0300 Subject: [PATCH 32/40] Use raw actions for policy preflight cache keys --- app/src/ai/agent/api/convert_conversation.rs | 8 +- .../agent/api/convert_conversation_tests.rs | 52 +++++++ app/src/ai/blocklist/action_model/execute.rs | 54 ++++++- .../blocklist/action_model/execute_tests.rs | 137 ++++++++++++++---- 4 files changed, 214 insertions(+), 37 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 2e0a68d25..7d17dcf7d 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -31,7 +31,9 @@ use crate::ai::agent::{ 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_sensitive_text_for_policy; +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; @@ -609,8 +611,8 @@ pub(crate) fn convert_tool_call_result_to_input( RequestCommandOutputResult::CancelledBeforeExecution } else { RequestCommandOutputResult::PolicyDenied { - command: result.command.clone(), - reason: reason.to_string(), + command: redact_command_for_policy(&result.command), + reason: redact_sensitive_text_for_policy(reason), } } } else { diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 4304ade3f..1a253385a 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -125,6 +125,58 @@ fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { } } +#[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: format!( + "{}blocked PASSWORD=hunter2 --token raw-token", + 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::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()); diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index a08e38d16..048b00c98 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -169,7 +169,7 @@ struct PolicyPreflightKey { working_directory: Option, run_until_completion: bool, active_profile_id: Option, - action: AgentPolicyAction, + raw_action: String, hook_config: AgentPolicyHookConfig, } @@ -178,6 +178,7 @@ impl PolicyPreflightKey { fn new( conversation_id: AIConversationId, action_id: AIAgentActionId, + action: &AIAgentAction, event: &AgentPolicyEvent, hook_config: &AgentPolicyHookConfig, ) -> Self { @@ -187,7 +188,7 @@ impl PolicyPreflightKey { working_directory: event.working_directory.clone(), run_until_completion: event.run_until_completion, active_profile_id: event.active_profile_id.clone(), - action: event.action.clone(), + raw_action: raw_policy_action_key(action), hook_config: hook_config.clone(), } } @@ -1162,7 +1163,7 @@ impl BlocklistAIActionExecutor { let action = (*input.action).clone(); let conversation_id = input.conversation_id; let preflight_key = - PolicyPreflightKey::new(conversation_id, action.id.clone(), &event, &config); + 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); @@ -1301,7 +1302,7 @@ impl BlocklistAIActionExecutor { ctx, )?; let preflight_key = - PolicyPreflightKey::new(conversation_id, action.id.clone(), &event, &config); + PolicyPreflightKey::new(conversation_id, action.id.clone(), action, &event, &config); let confirmed_file_edit_policy_preprocess = matches!(action.action, AIAgentActionType::RequestFileEdits { .. }) && self @@ -1707,6 +1708,51 @@ fn policy_path( )) } +#[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 { diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index c2b7a72a8..eb7bdc6ea 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -154,16 +154,13 @@ mod policy_hooks { } } - fn policy_command_action(command: &str) -> AgentPolicyAction { - agent_policy_action(&command_action(command), None, &None, &None) - .expect("command action should build a policy action") - } - fn policy_preflight_key( conversation_id: AIConversationId, action_id: AIAgentActionId, - action: AgentPolicyAction, + 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(), @@ -171,11 +168,12 @@ mod policy_hooks { false, Some("profile_default".to_string()), WarpPermissionSnapshot::allow(None), - action, + policy_action, ); PolicyPreflightKey::new( conversation_id, action_id, + &action, &event, &AgentPolicyHookConfig::default(), ) @@ -209,6 +207,19 @@ mod policy_hooks { } } + 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"); @@ -654,12 +665,11 @@ mod policy_hooks { #[test] fn policy_preflight_key_scopes_same_action_id_by_conversation() { let action_id = AIAgentActionId::from("action_1".to_string()); - let policy_action = policy_command_action("ls"); + 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(), policy_action.clone()); - let key_two = policy_preflight_key(conversation_two, action_id, policy_action); + 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); @@ -672,8 +682,8 @@ mod policy_hooks { 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 = policy_command_action("echo old"); - let new_action = policy_command_action("echo 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); @@ -682,11 +692,56 @@ mod policy_hooks { 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_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 = policy_command_action("ls"); + 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(), @@ -695,7 +750,7 @@ mod policy_hooks { false, Some("profile_a".to_string()), WarpPermissionSnapshot::allow(None), - action.clone(), + policy_action.clone(), ); let changed_cwd = AgentPolicyEvent::new( conversation_id.to_string(), @@ -704,7 +759,7 @@ mod policy_hooks { false, Some("profile_a".to_string()), WarpPermissionSnapshot::allow(None), - action.clone(), + policy_action.clone(), ); let changed_run_mode = AgentPolicyEvent::new( conversation_id.to_string(), @@ -713,7 +768,7 @@ mod policy_hooks { true, Some("profile_a".to_string()), WarpPermissionSnapshot::allow(None), - action.clone(), + policy_action.clone(), ); let changed_profile = AgentPolicyEvent::new( conversation_id.to_string(), @@ -722,28 +777,46 @@ mod policy_hooks { false, Some("profile_b".to_string()), WarpPermissionSnapshot::allow(None), - action, + policy_action, ); - let base_key = - PolicyPreflightKey::new(conversation_id, action_id.clone(), &base_event, &config); + 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(), &changed_cwd, &config) + 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, &changed_profile, &config) + PolicyPreflightKey::new( + conversation_id, + action_id, + &action, + &changed_profile, + &config + ) ); } @@ -751,6 +824,7 @@ mod policy_hooks { 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(), @@ -758,7 +832,7 @@ mod policy_hooks { false, Some("profile_default".to_string()), WarpPermissionSnapshot::allow(None), - policy_command_action("ls"), + agent_policy_action(&action, None, &None, &None).unwrap(), ); let old_config = AgentPolicyHookConfig { enabled: true, @@ -772,19 +846,22 @@ mod policy_hooks { }; assert_ne!( - PolicyPreflightKey::new(conversation_id, action_id.clone(), &event, &old_config), - PolicyPreflightKey::new(conversation_id, action_id, &event, &new_config) + 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, - policy_command_action("ls"), - ); + let preflight_key = + policy_preflight_key(AIConversationId::new(), action_id, command_action("ls")); let decision = AgentPolicyEffectiveDecision { decision: AgentPolicyDecisionKind::Allow, reason: None, From de1f1055b2a4ed6014507385fc082a5e9e29a609 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 02:58:14 +0300 Subject: [PATCH 33/40] Decode HTTP hook fragments for credential checks --- app/src/ai/policy_hooks/config.rs | 16 ++++++++++++++-- app/src/ai/policy_hooks/tests.rs | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 966797a62..641c670ef 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -342,7 +342,9 @@ fn http_url_contains_credentials(url: &str) -> bool { || parsed.query_pairs().any(|(key, value)| { text_contains_credentials(&key) || text_contains_credentials(&value) }) - || parsed.fragment().is_some_and(text_contains_credentials); + || parsed + .fragment() + .is_some_and(url_component_contains_credentials); } let url = url.trim_start(); @@ -373,7 +375,17 @@ fn http_url_contains_credentials(url: &str) -> bool { let suffix = &url[authority_end..]; suffix .find(|ch| matches!(ch, '?' | '#')) - .is_some_and(|offset| text_contains_credentials(&suffix[offset + 1..])) + .is_some_and(|offset| url_component_contains_credentials(&suffix[offset + 1..])) +} + +fn url_component_contains_credentials(value: &str) -> bool { + if text_contains_credentials(value) { + return true; + } + + urlencoding::decode(value) + .ok() + .is_some_and(|decoded| text_contains_credentials(decoded.as_ref())) } fn text_contains_credentials(value: &str) -> bool { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 0048fee82..6d9a037e0 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -257,7 +257,10 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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#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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ From c30229adc52592e3fd37e61de8e31a50e29d1fdf Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 03:14:00 +0300 Subject: [PATCH 34/40] Require per-hook autoapproval opt-in --- app/src/ai/policy_hooks/config.rs | 9 ++++----- app/src/ai/policy_hooks/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 641c670ef..f69f12046 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -81,11 +81,10 @@ impl AgentPolicyHookConfig { pub(crate) fn allow_autoapproval_for_all_hooks(&self) -> bool { !self.before_action.is_empty() - && (self.allow_hook_autoapproval - || self - .before_action - .iter() - .all(|hook| hook.allow_autoapproval)) + && self + .before_action + .iter() + .all(|hook| hook.allow_autoapproval) } } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 6d9a037e0..02039eaab 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -84,6 +84,23 @@ fn config_nonempty_hook_list_can_be_autoapproval_capable() { 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!({ From 830e024c214b43ad5d68ce5cdf2586207193bb9b Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 04:19:15 +0300 Subject: [PATCH 35/40] Harden policy hook audit edge cases --- app/src/ai/agent/conversation_yaml.rs | 40 ++- app/src/ai/agent/conversation_yaml_tests.rs | 81 +++++ app/src/ai/agent/redaction.rs | 46 ++- app/src/ai/agent_sdk/driver/output.rs | 48 ++- app/src/ai/blocklist/action_model/execute.rs | 41 ++- .../execute/request_file_edits.rs | 215 ++++++++++--- .../blocklist/action_model/execute_tests.rs | 126 +++++++- app/src/ai/policy_hooks/config.rs | 114 +++++-- app/src/ai/policy_hooks/decision.rs | 6 +- app/src/ai/policy_hooks/engine.rs | 36 ++- app/src/ai/policy_hooks/event.rs | 29 ++ app/src/ai/policy_hooks/tests.rs | 304 +++++++++++++++++- 12 files changed, 983 insertions(+), 103 deletions(-) diff --git a/app/src/ai/agent/conversation_yaml.rs b/app/src/ai/agent/conversation_yaml.rs index bb02baf09..8ad315e76 100644 --- a/app/src/ai/agent/conversation_yaml.rs +++ b/app/src/ai/agent/conversation_yaml.rs @@ -18,6 +18,10 @@ use api::message::Message; use crate::ai::policy_hooks::redaction::redact_sensitive_text_for_policy; use super::task::helper::{SubagentExt, ToolExt}; +use super::{ + 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"; @@ -682,7 +686,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)); } } } @@ -749,7 +754,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"); @@ -1050,6 +1058,34 @@ 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 = command.command_id == WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + && command.exit_code == WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE; + if is_policy_denial { + let reason = command + .output + .strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) + .unwrap_or(&command.output); + 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 39099d56f..b63440daf 100644 --- a/app/src/ai/agent/conversation_yaml_tests.rs +++ b/app/src/ai/agent/conversation_yaml_tests.rs @@ -3,6 +3,10 @@ use std::path::Path; use warp_multi_agent_api as api; +use crate::ai::agent::{ + 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, }; @@ -318,6 +322,83 @@ fn permission_denied_tool_call_result_redacts_deprecated_output() { 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 server_tool_calls_are_skipped() { let task_id = "root"; diff --git a/app/src/ai/agent/redaction.rs b/app/src/ai/agent/redaction.rs index 6e72946e5..4729dbfe4 100644 --- a/app/src/ai/agent/redaction.rs +++ b/app/src/ai/agent/redaction.rs @@ -211,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) => { @@ -434,7 +440,8 @@ mod tests { use super::*; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ - AIAgentActionResult, AIAgentActionResultType, WriteToLongRunningShellCommandResult, + AIAgentActionResult, AIAgentActionResultType, RequestFileEditsResult, + WriteToLongRunningShellCommandResult, }; #[test] @@ -510,4 +517,41 @@ mod tests { 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 61360e401..f97431412 100644 --- a/app/src/ai/agent_sdk/driver/output.rs +++ b/app/src/ai/agent_sdk/driver/output.rs @@ -836,7 +836,9 @@ pub mod json { }), RequestCommandOutputResult::PolicyDenied { reason, .. } => { Some(JsonMessage::ToolError { - error: Cow::Borrowed(reason.as_str()), + error: Cow::Owned(format!( + "Command was blocked by host policy: {reason}" + )), }) } }, @@ -863,7 +865,9 @@ pub mod json { } WriteToLongRunningShellCommandResult::PolicyDenied { reason } => { Some(JsonMessage::ToolError { - error: Cow::Borrowed(reason.as_str()), + error: Cow::Owned(format!( + "Writing to command blocked by host policy: {reason}" + )), }) } WriteToLongRunningShellCommandResult::Cancelled => { @@ -1310,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/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 048b00c98..0805e0aaf 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -106,7 +106,7 @@ use crate::{ agent::{ conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode, CancellationReason, - FileContext, FileLocations, ServerOutputId, + FileContext, FileEdit, FileLocations, ServerOutputId, }, ambient_agents::AmbientAgentTaskId, get_relevant_files::controller::GetRelevantFilesController, @@ -119,6 +119,8 @@ 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; @@ -170,6 +172,8 @@ struct PolicyPreflightKey { run_until_completion: bool, active_profile_id: Option, raw_action: String, + policy_action: AgentPolicyAction, + warp_permission: WarpPermissionSnapshot, hook_config: AgentPolicyHookConfig, } @@ -189,6 +193,8 @@ impl PolicyPreflightKey { 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(), } } @@ -1028,9 +1034,9 @@ impl BlocklistAIActionExecutor { } } AIAgentActionType::RequestFileEdits { file_edits, .. } => { - let paths = file_edits - .iter() - .filter_map(|edit| edit.file().map(PathBuf::from)) + let paths = file_edit_paths(file_edits) + .into_iter() + .map(PathBuf::from) .collect::>(); match BlocklistAIPermissions::as_ref(ctx).can_write_files( &conversation_id, @@ -1226,7 +1232,7 @@ impl BlocklistAIActionExecutor { let already_preprocessed = self .request_file_edits_executor .update(ctx, |executor, _ctx| { - executor.has_preprocessed_action(&action.id) + executor.has_preprocessed_action(conversation_id, action) }); if already_preprocessed { return false; @@ -1348,7 +1354,7 @@ impl BlocklistAIActionExecutor { ) && self .request_file_edits_executor .update(ctx, |executor, _ctx| { - executor.has_preprocessed_action(&action.id) + executor.has_preprocessed_action(conversation_id, action) }); let should_preserve_for_file_edit_preprocess = should_preserve_completed_policy_preflight_for_file_edit_preprocess( @@ -1660,9 +1666,8 @@ fn agent_policy_action( ))) } AIAgentActionType::RequestFileEdits { file_edits, .. } => { - let paths = file_edits - .iter() - .filter_map(|edit| edit.file()) + 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, @@ -1686,6 +1691,22 @@ fn agent_policy_action( } } +#[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 { @@ -1835,7 +1856,7 @@ fn warp_denied_action_result( Some(match &action.action { AIAgentActionType::RequestCommandOutput { command, .. } => { AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Denylisted { - command: command.clone(), + command: redact_command_for_policy(command), }) } AIAgentActionType::ReadFiles(_) => { 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 f79694695..e3ba0fe5c 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::{ @@ -52,11 +53,115 @@ 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, +} + +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, +) -> FileEditPreprocessFingerprint { + FileEditPreprocessFingerprint { + conversation_id, + action_payload: format!("{:?}", action.action), + } +} + +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"); + + assert_ne!( + file_edit_preprocess_fingerprint(conversation_one, &old_action), + file_edit_preprocess_fingerprint(conversation_two, &old_action) + ); + assert_ne!( + file_edit_preprocess_fingerprint(conversation_one, &old_action), + file_edit_preprocess_fingerprint(conversation_one, &new_action) + ); + } + + #[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 old_fingerprint = file_edit_preprocess_fingerprint(conversation_id, &old_action); + let new_fingerprint = file_edit_preprocess_fingerprint(conversation_id, &new_action); + 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 +174,7 @@ impl RequestFileEditsExecutor { apply_diff_model, diff_views: HashMap::new(), diff_application_failures: HashMap::new(), + preprocessed_actions: HashMap::new(), terminal_view_id, } } @@ -79,21 +185,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 +213,11 @@ 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 fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); 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,9 +238,22 @@ impl RequestFileEditsExecutor { self.diff_views.insert(action_id.clone(), view.clone()); } - pub(super) fn has_preprocessed_action(&self, action_id: &AIAgentActionId) -> bool { - self.diff_views.contains_key(action_id) - || self.diff_application_failures.contains_key(action_id) + pub(super) fn has_preprocessed_action( + &self, + conversation_id: AIConversationId, + action: &AIAgentAction, + ) -> bool { + let fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); + 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( @@ -145,14 +262,14 @@ impl RequestFileEditsExecutor { 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; }; @@ -163,7 +280,17 @@ impl RequestFileEditsExecutor { }; // If diff application failed, early exit. - if let Some(errors) = self.diff_application_failures.remove(id) { + let fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); + 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), @@ -172,9 +299,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() }); @@ -217,7 +344,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(), @@ -321,6 +448,7 @@ impl RequestFileEditsExecutor { let (tx, rx) = oneshot::channel(); let files = file_edits.clone(); let id = id.clone(); + let fingerprint = file_edit_preprocess_fingerprint(input.conversation_id, input.action); let apply_future = self.apply_diff_model.update(ctx, |model, ctx| { model.apply_diffs(files, &ai_identifiers, passive_diff, ctx) @@ -329,10 +457,10 @@ impl RequestFileEditsExecutor { ctx.spawn( async move { let applied_diffs = apply_future.await; - (applied_diffs, id, tx) + (applied_diffs, id, fingerprint, tx) }, - |me, (diffs, id, tx), ctx| { - me.on_diffs_applied(diffs, id, tx, ctx); + |me, (diffs, id, fingerprint, tx), ctx| { + me.on_diffs_applied(diffs, id, fingerprint, tx, ctx); }, ); @@ -346,10 +474,13 @@ impl RequestFileEditsExecutor { &mut self, applied_diffs: Result, Vec1>, id: AIAgentActionId, + fingerprint: FileEditPreprocessFingerprint, 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!( @@ -363,8 +494,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) => { @@ -372,10 +508,17 @@ 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; } }; + self.diff_application_failures.remove(&id); let current_working_directory = self .active_session diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index eb7bdc6ea..1c65ca50d 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -105,6 +105,8 @@ mod policy_hooks { path::PathBuf, }; + use ai::diff_validation::{ParsedDiff, V4AHunk}; + use crate::{ ai::{ agent::task::TaskId, @@ -128,7 +130,7 @@ mod policy_hooks { use super::super::{ agent_policy_action, complete_policy_preflight_if_pending, - confirmed_file_edit_policy_preprocess_state_from_cached_decision, + 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, @@ -207,6 +209,28 @@ mod policy_hooks { } } + 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()), @@ -316,7 +340,7 @@ mod policy_hooks { #[test] fn warp_denied_command_result_preserves_denylisted_variant() { - let action = command_action("rm -rf target"); + 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(), @@ -336,7 +360,7 @@ mod policy_hooks { assert_eq!( result, AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Denylisted { - command: "rm -rf target".to_string(), + command: "OPENAI_API_KEY= rm -rf target".to_string(), }) ); } @@ -662,6 +686,56 @@ mod policy_hooks { ); } + #[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()); @@ -777,6 +851,30 @@ mod policy_hooks { 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, ); @@ -812,12 +910,32 @@ mod policy_hooks { base_key, PolicyPreflightKey::new( conversation_id, - action_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] diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index f69f12046..590978492 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -76,11 +76,16 @@ impl AgentPolicyHookConfig { &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.before_action.is_empty() + self.allow_hook_autoapproval + && !self.before_action.is_empty() && self .before_action .iter() @@ -316,17 +321,36 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError }) { 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); + } Ok(()) } fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigError> { - if stdio_arg_contains_credentials(command) { + let words = shell_words::split(command).unwrap_or_else(|_| { + command + .split_ascii_whitespace() + .map(ToString::to_string) + .collect() + }); + if words + .iter() + .any(|word| stdio_arg_contains_credentials(word)) + { + return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + } + if words.windows(2).any(|words| { + stdio_arg_expects_secret_value(&words[0]) && stdio_arg_value_is_literal_secret(&words[1]) + }) { return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); } - - let words = command.split_ascii_whitespace().collect::>(); if words.windows(2).any(|words| { - stdio_arg_expects_secret_value(words[0]) && stdio_arg_value_is_literal_secret(words[1]) + stdio_arg_expects_header_value(&words[0]) + && stdio_header_value_contains_credentials(&words[1]) }) { return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); } @@ -338,8 +362,10 @@ fn http_url_contains_credentials(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { return !parsed.username().is_empty() || parsed.password().is_some() + || url_component_contains_credentials(parsed.path()) || parsed.query_pairs().any(|(key, value)| { - text_contains_credentials(&key) || text_contains_credentials(&value) + url_component_contains_credentials(&key) + || url_component_contains_credentials(&value) }) || parsed .fragment() @@ -372,19 +398,26 @@ fn http_url_contains_credentials(url: &str) -> bool { } let suffix = &url[authority_end..]; - suffix - .find(|ch| matches!(ch, '?' | '#')) - .is_some_and(|offset| url_component_contains_credentials(&suffix[offset + 1..])) + url_component_contains_credentials(suffix) } fn url_component_contains_credentials(value: &str) -> bool { - if text_contains_credentials(value) { - return true; + let mut current = std::borrow::Cow::Borrowed(value); + for _ in 0..=3 { + 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()); } - urlencoding::decode(value) - .ok() - .is_some_and(|decoded| text_contains_credentials(decoded.as_ref())) + false } fn text_contains_credentials(value: &str) -> bool { @@ -421,6 +454,19 @@ fn text_contains_credentials(value: &str) -> bool { fn stdio_arg_contains_credentials(value: &str) -> bool { 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) { @@ -434,19 +480,36 @@ fn stdio_arg_contains_credentials(value: &str) -> bool { 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; - } - } - 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_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 != '_') @@ -484,6 +547,9 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { normalized.contains("apikey") || normalized.contains("accesskey") + || normalized == "u" + || normalized == "user" + || normalized == "proxyuser" || normalized.ends_with("token") || normalized.ends_with("secret") || normalized.ends_with("password") diff --git a/app/src/ai/policy_hooks/decision.rs b/app/src/ai/policy_hooks/decision.rs index 2980290a9..0d328e60d 100644 --- a/app/src/ai/policy_hooks/decision.rs +++ b/app/src/ai/policy_hooks/decision.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::redaction::redact_sensitive_text_for_policy; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AgentPolicyDecisionKind { Allow, @@ -43,7 +43,7 @@ pub(crate) struct AgentPolicyHookResponse { pub external_audit_id: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum WarpPermissionDecisionKind { Allow, @@ -51,7 +51,7 @@ pub(crate) enum WarpPermissionDecisionKind { Deny, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub(crate) struct WarpPermissionSnapshot { pub decision: WarpPermissionDecisionKind, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/app/src/ai/policy_hooks/engine.rs b/app/src/ai/policy_hooks/engine.rs index 19776d8f3..62bbb6813 100644 --- a/app/src/ai/policy_hooks/engine.rs +++ b/app/src/ai/policy_hooks/engine.rs @@ -99,12 +99,15 @@ impl AgentPolicyHookEngine { hook.name.clone(), redact_hook_response_configured_secrets(response, hook), ), - Err(failure) => AgentPolicyHookEvaluation::unavailable( - hook.name.clone(), - self.config.hook_unavailable_decision(hook).decision_kind(), - failure.kind, - failure.detail, - ), + 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, + ) + } } } @@ -423,6 +426,22 @@ fn redact_hook_response_configured_secrets( } } +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, @@ -533,10 +552,7 @@ fn parse_hook_response(stdout: &[u8]) -> Result { serde_json::from_slice(stdout).context("parse JSON response")?; if response.schema_version != AGENT_POLICY_SCHEMA_VERSION { - return Err(anyhow!( - "unsupported schema_version {:?}", - response.schema_version - )); + return Err(anyhow!("unsupported schema_version")); } if response.decision == AgentPolicyDecisionKind::Unknown { diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index e18c4c8e5..cb5db6288 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -38,6 +38,7 @@ impl AgentPolicyEvent { 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(), @@ -109,6 +110,34 @@ impl AgentPolicyAction { 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 { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 02039eaab..61b6f5daa 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -72,6 +72,7 @@ fn config_empty_hook_list_is_not_autoapproval_capable() { 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", @@ -84,6 +85,22 @@ fn config_nonempty_hook_list_can_be_autoapproval_capable() { 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!({ @@ -126,6 +143,26 @@ fn config_deserializes_stdio_hook_shape() { 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 [ @@ -145,6 +182,11 @@ fn config_rejects_stdio_hook_credential_args() { 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!(["-H", "X-Api-Key: abc123def456"]), + json!(["--header=X-Api-Key: abc123def456"]), ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -175,6 +217,11 @@ fn config_rejects_stdio_hook_credential_command() { "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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -206,7 +253,7 @@ fn config_allows_stdio_hook_secret_env_reference_args() { "name": "stdio-guard", "transport": "stdio", "command": "guard", - "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN", "X-API-Key:", "$HEADER_API_KEY", "Authorization:", "Bearer $HEADER_TOKEN"] + "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN", "X-API-Key:", "$HEADER_API_KEY", "Authorization:", "Bearer $HEADER_TOKEN", "-H", "X-Api-Key: $HEADER_API_KEY", "--header=Authorization: Bearer $HEADER_TOKEN"] }] })) .unwrap(); @@ -216,17 +263,23 @@ fn config_allows_stdio_hook_secret_env_reference_args() { #[test] fn config_allows_stdio_hook_secret_env_reference_command() { - let config: AgentPolicyHookConfig = serde_json::from_value(json!({ - "enabled": true, - "before_action": [{ - "name": "stdio-guard", - "transport": "stdio", - "command": "guard --token $API_TOKEN" - }] - })) - .unwrap(); + 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", + ] { + let config: AgentPolicyHookConfig = serde_json::from_value(json!({ + "enabled": true, + "before_action": [{ + "name": "stdio-guard", + "transport": "stdio", + "command": command + }] + })) + .unwrap(); - assert!(config.validate().is_ok()); + assert!(config.validate().is_ok()); + } } #[test] @@ -279,6 +332,10 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -320,6 +377,8 @@ fn config_rejects_disabled_http_hook_url_embedded_credentials() { "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", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": false, @@ -343,6 +402,7 @@ 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", ] { let agent_policy_hooks = AgentPolicyHookConfig { enabled: false, @@ -874,6 +934,33 @@ fn audit_record_uses_redacted_policy_event_payload() { 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() { @@ -915,6 +1002,55 @@ async fn stdio_engine_can_deny_before_action() { ); } +#[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() { @@ -1223,6 +1359,74 @@ async fn stdio_engine_redacts_configured_secret_hook_reason() { ); } +#[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() { @@ -1493,6 +1697,84 @@ async fn http_engine_redacts_configured_header_secret_hook_reason() { ); } +#[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() { From 9e6fe1745d1e276be644444cee287e9bd938b92b Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 04:32:47 +0300 Subject: [PATCH 36/40] Inspect stdio hook command fragments --- app/src/ai/policy_hooks/config.rs | 16 ++++++++++++++++ app/src/ai/policy_hooks/tests.rs | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 590978492..d8f2f2591 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -330,7 +330,16 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError Ok(()) } +const MAX_STDIO_COMMAND_FRAGMENT_DEPTH: usize = 3; + fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigError> { + validate_stdio_command_fragment(command, 0) +} + +fn validate_stdio_command_fragment( + command: &str, + depth: usize, +) -> Result<(), AgentPolicyHookConfigError> { let words = shell_words::split(command).unwrap_or_else(|_| { command .split_ascii_whitespace() @@ -354,6 +363,13 @@ fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigErro }) { return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); } + if depth < MAX_STDIO_COMMAND_FRAGMENT_DEPTH { + for word in &words { + if word.split_ascii_whitespace().nth(1).is_some() { + validate_stdio_command_fragment(word, depth + 1)?; + } + } + } Ok(()) } diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 61b6f5daa..19847caa6 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -222,6 +222,8 @@ fn config_rejects_stdio_hook_credential_command() { "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'", + "bash -lc \"curl -H 'X-Api-Key: abc123def456' https://example.com\"", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -242,6 +244,8 @@ fn config_rejects_stdio_hook_credential_command() { 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")); } } @@ -267,6 +271,8 @@ fn config_allows_stdio_hook_secret_env_reference_command() { "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'", + "bash -lc \"curl -H 'X-Api-Key: $HEADER_API_KEY' https://example.com\"", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, From 8c0e9f97e069355dbe1bc3b20b39fa8294d9c1fa Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 04:59:23 +0300 Subject: [PATCH 37/40] Harden stdio hook credential validation --- app/src/ai/policy_hooks/config.rs | 76 ++++++++++++++++++++++++++----- app/src/ai/policy_hooks/tests.rs | 57 ++++++++++++++++++----- 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index d8f2f2591..6643bcf14 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -327,19 +327,27 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError }) { return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); } + if args.iter().any(|arg| { + arg.split_ascii_whitespace().nth(1).is_some() + && !stdio_arg_is_env_secret_reference_container(arg) + && stdio_command_fragment_contains_credentials(arg, 0) + }) { + return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); + } Ok(()) } -const MAX_STDIO_COMMAND_FRAGMENT_DEPTH: usize = 3; +const MAX_STDIO_COMMAND_FRAGMENT_DEPTH: usize = 8; fn validate_stdio_command(command: &str) -> Result<(), AgentPolicyHookConfigError> { - validate_stdio_command_fragment(command, 0) + if stdio_command_fragment_contains_credentials(command, 0) { + return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + } + + Ok(()) } -fn validate_stdio_command_fragment( - command: &str, - depth: usize, -) -> Result<(), AgentPolicyHookConfigError> { +fn stdio_command_fragment_contains_credentials(command: &str, depth: usize) -> bool { let words = shell_words::split(command).unwrap_or_else(|_| { command .split_ascii_whitespace() @@ -350,32 +358,43 @@ fn validate_stdio_command_fragment( .iter() .any(|word| stdio_arg_contains_credentials(word)) { - return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + return true; } if words.windows(2).any(|words| { stdio_arg_expects_secret_value(&words[0]) && stdio_arg_value_is_literal_secret(&words[1]) }) { - return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + return true; } if words.windows(2).any(|words| { stdio_arg_expects_header_value(&words[0]) && stdio_header_value_contains_credentials(&words[1]) }) { - return Err(AgentPolicyHookConfigError::StdioCommandContainsCredentials); + return true; } if depth < MAX_STDIO_COMMAND_FRAGMENT_DEPTH { for word in &words { if word.split_ascii_whitespace().nth(1).is_some() { - validate_stdio_command_fragment(word, depth + 1)?; + if stdio_command_fragment_contains_credentials(word, depth + 1) { + return true; + } } } + } else if words + .iter() + .any(|word| word.split_ascii_whitespace().nth(1).is_some()) + { + return true; } - Ok(()) + false } fn http_url_contains_credentials(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { + if !matches!(parsed.scheme(), "http" | "https") { + return false; + } + return !parsed.username().is_empty() || parsed.password().is_some() || url_component_contains_credentials(parsed.path()) @@ -469,6 +488,10 @@ fn text_contains_credentials(value: &str) -> bool { } fn stdio_arg_contains_credentials(value: &str) -> bool { + if 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(); @@ -526,6 +549,33 @@ fn stdio_header_value_contains_credentials(value: &str) -> bool { && !stdio_arg_value_uses_env_secret_reference(secret) } +fn stdio_arg_is_env_secret_reference_container(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|ch| ch == '"' || ch == '\'') + .trim(); + if stdio_arg_value_uses_env_secret_reference(value) { + return true; + } + + 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) { + return stdio_arg_value_uses_env_secret_reference(secret); + } + } + + if let Some((name, secret)) = value.split_once(':') { + return text_contains_credentials(name) + && stdio_arg_value_uses_env_secret_reference(secret.trim()); + } + + false +} + fn text_contains_common_token(value: &str) -> bool { value .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_') @@ -576,7 +626,9 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { 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) + !value.is_empty() + && !value.starts_with('%') + && !stdio_arg_value_uses_env_secret_reference(value) } fn stdio_arg_value_uses_env_secret_reference(value: &str) -> bool { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 19847caa6..217c929da 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -187,6 +187,13 @@ fn config_rejects_stdio_hook_credential_args() { json!(["--proxy-user=proxy:secret"]), json!(["-H", "X-Api-Key: abc123def456"]), json!(["--header=X-Api-Key: abc123def456"]), + json!(["-c", "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, @@ -206,6 +213,8 @@ fn config_rejects_stdio_hook_credential_args() { 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")); } } @@ -224,6 +233,8 @@ fn config_rejects_stdio_hook_credential_command() { "curl --header='X-Api-Key: abc123def456' https://example.com", "sh -c 'guard --token raw-secret'", "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, @@ -251,18 +262,41 @@ fn config_rejects_stdio_hook_credential_command() { #[test] fn config_allows_stdio_hook_secret_env_reference_args() { - let config: AgentPolicyHookConfig = serde_json::from_value(json!({ - "enabled": true, - "before_action": [{ - "name": "stdio-guard", - "transport": "stdio", - "command": "guard", - "args": ["--token", "$API_TOKEN", "--api-key=${POLICY_API_KEY}", "--authorization", "Bearer $POLICY_TOKEN", "--auth", "Basic ${POLICY_AUTH}", "Authorization: BEARER $HEADER_TOKEN", "X-API-Key:", "$HEADER_API_KEY", "Authorization:", "Bearer $HEADER_TOKEN", "-H", "X-Api-Key: $HEADER_API_KEY", "--header=Authorization: Bearer $HEADER_TOKEN"] - }] - })) - .unwrap(); + 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!([ + "-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(); - assert!(config.validate().is_ok()); + let validation = config.validate(); + assert!( + validation.is_ok(), + "expected args {args_debug:?} to validate, got {validation:?}" + ); + } } #[test] @@ -273,6 +307,7 @@ fn config_allows_stdio_hook_secret_env_reference_command() { "curl --header='Authorization: Bearer $HEADER_TOKEN' https://example.com", "sh -c '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, From 6f91a8c64b3f8488a3258666a8baa134535b8ed2 Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 05:40:55 +0300 Subject: [PATCH 38/40] Reject persisted policy hook credential bypasses --- app/src/ai/policy_hooks/config.rs | 137 +++++++++++++++++------------- app/src/ai/policy_hooks/tests.rs | 21 ++++- 2 files changed, 96 insertions(+), 62 deletions(-) diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 6643bcf14..9bafd95be 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -313,7 +313,12 @@ fn validate_http_secret_value_map( } fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError> { - if args.iter().any(|arg| stdio_arg_contains_credentials(arg)) { + 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| { @@ -327,10 +332,9 @@ fn validate_stdio_args(args: &[String]) -> Result<(), AgentPolicyHookConfigError }) { return Err(AgentPolicyHookConfigError::StdioArgContainsCredentials); } - if args.iter().any(|arg| { - arg.split_ascii_whitespace().nth(1).is_some() - && !stdio_arg_is_env_secret_reference_container(arg) - && stdio_command_fragment_contains_credentials(arg, 0) + 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); } @@ -354,10 +358,12 @@ fn stdio_command_fragment_contains_credentials(command: &str, depth: usize) -> b .map(ToString::to_string) .collect() }); - if words - .iter() - .any(|word| stdio_arg_contains_credentials(word)) - { + 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| { @@ -372,16 +378,16 @@ fn stdio_command_fragment_contains_credentials(command: &str, depth: usize) -> b return true; } if depth < MAX_STDIO_COMMAND_FRAGMENT_DEPTH { - for word in &words { - if word.split_ascii_whitespace().nth(1).is_some() { - if stdio_command_fragment_contains_credentials(word, depth + 1) { - return true; - } + 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 - .iter() - .any(|word| word.split_ascii_whitespace().nth(1).is_some()) + .windows(2) + .any(|words| stdio_arg_is_shell_fragment_flag(&words[0])) { return true; } @@ -391,20 +397,7 @@ fn stdio_command_fragment_contains_credentials(command: &str, depth: usize) -> b fn http_url_contains_credentials(url: &str) -> bool { if let Ok(parsed) = url::Url::parse(url) { - if !matches!(parsed.scheme(), "http" | "https") { - return false; - } - - return !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); + return parsed_url_contains_credentials(&parsed); } let url = url.trim_start(); @@ -436,6 +429,27 @@ fn http_url_contains_credentials(url: &str) -> bool { 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..=3 { @@ -488,7 +502,7 @@ fn text_contains_credentials(value: &str) -> bool { } fn stdio_arg_contains_credentials(value: &str) -> bool { - if http_url_contains_credentials(value) { + if stdio_http_url_contains_credentials(value) { return true; } @@ -513,6 +527,16 @@ fn stdio_arg_contains_credentials(value: &str) -> bool { } } + 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) { @@ -532,6 +556,26 @@ fn stdio_arg_expects_header_value(value: &str) -> bool { ) } +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() @@ -549,33 +593,6 @@ fn stdio_header_value_contains_credentials(value: &str) -> bool { && !stdio_arg_value_uses_env_secret_reference(secret) } -fn stdio_arg_is_env_secret_reference_container(value: &str) -> bool { - let value = value - .trim() - .trim_matches(|ch| ch == '"' || ch == '\'') - .trim(); - if stdio_arg_value_uses_env_secret_reference(value) { - return true; - } - - 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) { - return stdio_arg_value_uses_env_secret_reference(secret); - } - } - - if let Some((name, secret)) = value.split_once(':') { - return text_contains_credentials(name) - && stdio_arg_value_uses_env_secret_reference(secret.trim()); - } - - false -} - fn text_contains_common_token(value: &str) -> bool { value .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_') @@ -626,9 +643,7 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { fn stdio_arg_value_is_literal_secret(value: &str) -> bool { let value = value.trim().trim_matches(|ch| ch == '"' || ch == '\''); - !value.is_empty() - && !value.starts_with('%') - && !stdio_arg_value_uses_env_secret_reference(value) + !value.is_empty() && !stdio_arg_value_uses_env_secret_reference(value) } fn stdio_arg_value_uses_env_secret_reference(value: &str) -> bool { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 217c929da..bf2c79336 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -168,6 +168,8 @@ 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"]), @@ -188,6 +190,7 @@ fn config_rejects_stdio_hook_credential_args() { 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" @@ -222,6 +225,8 @@ fn config_rejects_stdio_hook_credential_args() { 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", @@ -232,6 +237,8 @@ fn config_rejects_stdio_hook_credential_command() { "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\"", "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'", @@ -273,6 +280,7 @@ fn config_allows_stdio_hook_secret_env_reference_args() { 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" @@ -306,6 +314,8 @@ fn config_allows_stdio_hook_secret_env_reference_command() { "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'", ] { @@ -319,7 +329,11 @@ fn config_allows_stdio_hook_secret_env_reference_command() { })) .unwrap(); - assert!(config.validate().is_ok()); + let validation = config.validate(); + assert!( + validation.is_ok(), + "expected command {command:?} to validate, got {validation:?}" + ); } } @@ -377,6 +391,8 @@ fn config_rejects_http_hook_url_embedded_credentials() { "https://example.com/hooks/Authorization%3A%20Bearer%20secret", "https://example.com/policy?api%255Fkey=abc123def456", "https://example.com/policy?api%252Dkey=abc123def456", + "ftp://user:pass@example.com/policy", + "custom://example.com/policy?token=secret", ] { let config: AgentPolicyHookConfig = serde_json::from_value(json!({ "enabled": true, @@ -420,6 +436,8 @@ fn config_rejects_disabled_http_hook_url_embedded_credentials() { "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, @@ -444,6 +462,7 @@ fn profile_serialization_sanitizes_disabled_http_hook_url_embedded_credentials() "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, From fe01e1452c6451e5ff1ecc947e129335e9f4b3ad Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 06:09:19 +0300 Subject: [PATCH 39/40] Harden policy hook redaction and autoapproval --- app/src/ai/blocklist/action_model/execute.rs | 54 +++++++++++------- .../blocklist/action_model/execute_tests.rs | 47 ++++++++++++++- app/src/ai/policy_hooks/event.rs | 5 +- app/src/ai/policy_hooks/redaction.rs | 25 ++++++++ app/src/ai/policy_hooks/tests.rs | 57 ++++++++++++++++++- 5 files changed, 162 insertions(+), 26 deletions(-) diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 0805e0aaf..4cd95361d 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1011,27 +1011,18 @@ impl BlocklistAIActionExecutor { return None; } - let escape_char = self - .active_session - .as_ref(ctx) - .shell_type(ctx) - .map(|shell_type| ShellFamily::from(shell_type).escape_char())?; - let permission = 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, - ); - - match permission { - CommandExecutionPermission::Denied( - CommandExecutionPermissionDeniedReason::ExplicitlyDenylisted, - ) => Some("command is explicitly denylisted by Warp permissions".to_string()), - _ => 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) @@ -1585,6 +1576,27 @@ impl BlocklistAIActionExecutor { } } +#[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, diff --git a/app/src/ai/blocklist/action_model/execute_tests.rs b/app/src/ai/blocklist/action_model/execute_tests.rs index 1c65ca50d..52cda5a0b 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -116,6 +116,9 @@ mod policy_hooks { RequestCommandOutputResult, RequestFileEditsResult, WriteToLongRunningShellCommandResult, }, + blocklist::permissions::{ + CommandExecutionPermission, CommandExecutionPermissionDeniedReason, + }, policy_hooks::{ decision::{ compose_policy_decisions, AgentPolicyHookEvaluation, @@ -136,7 +139,8 @@ mod policy_hooks { should_consume_completed_policy_preflight, should_preprocess_file_edits_after_policy_decision, should_preserve_completed_policy_preflight_for_file_edit_preprocess, - warp_permission_snapshot_for_policy, PolicyPreflightKey, PolicyPreflightState, + terminal_command_denial_reason_for_policy, warp_permission_snapshot_for_policy, + PolicyPreflightKey, PolicyPreflightState, }; fn command_action(command: &str) -> AIAgentAction { @@ -434,6 +438,47 @@ mod policy_hooks { ); } + #[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"); diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index cb5db6288..c59ce2264 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -317,9 +317,10 @@ impl PolicyReadMcpResourceAction { fn truncate_policy_path(path: PathBuf) -> PathBuf { let path_text = path.to_string_lossy(); - if path_text.len() <= super::redaction::MAX_POLICY_STRING_BYTES { + 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; } - PathBuf::from(truncate_for_policy(&path_text)) + PathBuf::from(redacted_path) } diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index 9b78958a7..e0f076884 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -21,6 +21,13 @@ static AUTHORIZATION_BASIC_RE: Lazy = Lazy::new(|| { .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") @@ -60,6 +67,7 @@ pub(crate) fn redact_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"); @@ -68,6 +76,23 @@ pub(crate) fn redact_sensitive_text_for_policy(value: &str) -> String { truncate_for_policy(&value) } +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); diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index bf2c79336..5bd275781 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -15,7 +15,8 @@ use super::{ }, event::{ AgentPolicyAction, AgentPolicyEvent, PolicyCallMcpToolAction, PolicyExecuteCommandAction, - PolicyReadFilesAction, PolicyReadMcpResourceAction, AGENT_POLICY_SCHEMA_VERSION, + PolicyReadFilesAction, PolicyReadMcpResourceAction, PolicyWriteFilesAction, + AGENT_POLICY_SCHEMA_VERSION, }, redaction::{redact_command_for_policy, MAX_POLICY_COLLECTION_ITEMS}, }; @@ -743,6 +744,31 @@ fn command_redaction_handles_url_userinfo_and_basic_auth() { 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!( @@ -852,6 +878,31 @@ fn policy_action_collections_are_capped() { 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"), + ]); + 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("raw-path-token")); +} + #[test] fn policy_decision_composition_is_conservative() { let hook_allow = AgentPolicyHookEvaluation { @@ -936,7 +987,7 @@ fn hook_response_strings_are_redacted_and_capped() { schema_version: AGENT_POLICY_SCHEMA_VERSION.to_string(), decision: AgentPolicyDecisionKind::Deny, reason: Some(format!( - "OPENAI_API_KEY=sk-secretsecretsecret {}", + "OPENAI_API_KEY=sk-secretsecretsecret X-API-Key: abc123def456 {}", "x".repeat(10_000) )), external_audit_id: Some("audit-ghp_secretsecretsecret".to_string()), @@ -945,7 +996,9 @@ fn hook_response_strings_are_redacted_and_capped() { 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(), From 97c11d30db62905b8b61ef9963a975e1b9ecfe4f Mon Sep 17 00:00:00 2001 From: etherman-os Date: Mon, 4 May 2026 06:43:17 +0300 Subject: [PATCH 40/40] Audit harden policy hook edge cases --- app/src/ai/agent/api/convert_conversation.rs | 33 ++-- .../agent/api/convert_conversation_tests.rs | 15 +- app/src/ai/agent/conversation_yaml.rs | 24 ++- app/src/ai/agent/conversation_yaml_tests.rs | 41 ++++- app/src/ai/blocklist/action_model/execute.rs | 28 ++-- .../execute/request_file_edits.rs | 114 ++++++++++--- .../blocklist/action_model/execute_tests.rs | 38 +++++ app/src/ai/policy_hooks/audit.rs | 15 +- app/src/ai/policy_hooks/config.rs | 108 ++++++++---- app/src/ai/policy_hooks/event.rs | 26 ++- app/src/ai/policy_hooks/redaction.rs | 37 ++++- app/src/ai/policy_hooks/tests.rs | 155 ++++++++++++++++++ crates/ai/src/agent/action_result/convert.rs | 2 +- .../src/agent/action_result/convert_tests.rs | 5 +- crates/ai/src/agent/action_result/mod.rs | 23 +++ specs/GH9914/product.md | 4 +- specs/GH9914/tech.md | 2 +- 17 files changed, 548 insertions(+), 122 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index 7d17dcf7d..2c3b92842 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -13,20 +13,21 @@ use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::agent::task::TaskId; use crate::ai::agent::todos::AIAgentTodoList; use crate::ai::agent::{ - decode_file_edits_policy_denied_reason, AIAgentActionResult, AIAgentActionResultType, - AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput, - AIAgentOutputMessage, AIAgentOutputStatus, CallMCPToolResult, CancellationReason, - CloneRepositoryURL, CreateDocumentsResult, DocumentContext, EditDocumentsResult, FileContext, - FileGlobResult, FileGlobV2Match, FileGlobV2Result, FinishedAIAgentOutput, GrepFileMatch, - GrepLineMatch, GrepResult, ImageContext, InsertReviewCommentsResult, OutputModelInfo, - PassiveCodeDiffEntry, PassiveSuggestionResultType, PassiveSuggestionTrigger, - ReadDocumentsResult, ReadFilesResult, ReadMCPResourceResult, ReadShellCommandOutputResult, - RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseFailureReason, - SearchCodebaseResult, ServerOutputId, Shared, ShellCommandCompletedTrigger, ShellCommandError, - SuggestNewConversationResult, SuggestPromptResult, TransferShellCommandControlToUserResult, - UpdatedFileContext, UploadArtifactResult, WriteToLongRunningShellCommandResult, - COMMAND_POLICY_DENIED_PREFIX, WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID, - WRITE_TO_SHELL_POLICY_DENIED_EXIT_CODE, WRITE_TO_SHELL_POLICY_DENIED_PREFIX, + decode_command_policy_denied_reason, decode_file_edits_policy_denied_reason, + AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentExchange, + AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputStatus, + CallMCPToolResult, CancellationReason, CloneRepositoryURL, CreateDocumentsResult, + DocumentContext, EditDocumentsResult, FileContext, FileGlobResult, FileGlobV2Match, + FileGlobV2Result, FinishedAIAgentOutput, GrepFileMatch, GrepLineMatch, GrepResult, + ImageContext, InsertReviewCommentsResult, OutputModelInfo, PassiveCodeDiffEntry, + PassiveSuggestionResultType, PassiveSuggestionTrigger, ReadDocumentsResult, ReadFilesResult, + ReadMCPResourceResult, ReadShellCommandOutputResult, RequestCommandOutputResult, + RequestFileEditsResult, SearchCodebaseFailureReason, SearchCodebaseResult, ServerOutputId, + 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}; @@ -606,13 +607,13 @@ pub(crate) fn convert_tool_call_result_to_input( None => { #[allow(deprecated)] let output = result.output.as_str(); - if let Some(reason) = output.strip_prefix(COMMAND_POLICY_DENIED_PREFIX) { + 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), + reason: redact_sensitive_text_for_policy(&reason), } } } else { diff --git a/app/src/ai/agent/api/convert_conversation_tests.rs b/app/src/ai/agent/api/convert_conversation_tests.rs index 1a253385a..2bbd13f86 100644 --- a/app/src/ai/agent/api/convert_conversation_tests.rs +++ b/app/src/ai/agent/api/convert_conversation_tests.rs @@ -86,10 +86,7 @@ fn test_convert_tool_call_result_to_input_preserves_host_policy_denial() { #[allow(deprecated)] let run_shell_result = api::RunShellCommandResult { command: "rm -rf target".to_string(), - output: format!( - "{}blocked by org policy", - crate::ai::agent::COMMAND_POLICY_DENIED_PREFIX - ), + 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 }, @@ -132,9 +129,8 @@ fn test_convert_tool_call_result_to_input_redacts_host_policy_denial() { #[allow(deprecated)] let run_shell_result = api::RunShellCommandResult { command: "OPENAI_API_KEY=sk-secretsecretsecret guard --token raw-token".to_string(), - output: format!( - "{}blocked PASSWORD=hunter2 --token raw-token", - crate::ai::agent::COMMAND_POLICY_DENIED_PREFIX + 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( @@ -184,7 +180,10 @@ fn test_convert_tool_call_result_to_input_treats_unmarked_permission_denied_as_c #[allow(deprecated)] let run_shell_result = api::RunShellCommandResult { command: "rm -rf target".to_string(), - output: "generic permission denied".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 }, diff --git a/app/src/ai/agent/conversation_yaml.rs b/app/src/ai/agent/conversation_yaml.rs index 8ad315e76..1c82c2023 100644 --- a/app/src/ai/agent/conversation_yaml.rs +++ b/app/src/ai/agent/conversation_yaml.rs @@ -19,8 +19,9 @@ use crate::ai::policy_hooks::redaction::redact_sensitive_text_for_policy; use super::task::helper::{SubagentExt, ToolExt}; use super::{ - 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, + 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"; @@ -560,8 +561,10 @@ fn write_tool_call_result_content(out: &mut String, result: &ToolCallResultType) #[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)); + write_block_scalar(out, &redact_sensitive_text_for_policy(&output)); } } } @@ -1070,13 +1073,16 @@ fn yaml_safe_apply_file_diffs_error(message: &str) -> String { } fn yaml_safe_shell_command_output(command: &api::ShellCommandFinished) -> String { - let is_policy_denial = command.command_id == WRITE_TO_SHELL_POLICY_DENIED_COMMAND_ID + 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 is_policy_denial { - let reason = command - .output - .strip_prefix(WRITE_TO_SHELL_POLICY_DENIED_PREFIX) - .unwrap_or(&command.output); + 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) diff --git a/app/src/ai/agent/conversation_yaml_tests.rs b/app/src/ai/agent/conversation_yaml_tests.rs index b63440daf..a287f39cd 100644 --- a/app/src/ai/agent/conversation_yaml_tests.rs +++ b/app/src/ai/agent/conversation_yaml_tests.rs @@ -4,8 +4,9 @@ use std::path::Path; use warp_multi_agent_api as api; use crate::ai::agent::{ - 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, + 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, @@ -293,7 +294,7 @@ fn permission_denied_tool_call_result_redacts_deprecated_output() { #[allow(deprecated)] let result = api::RunShellCommandResult { command: "dangerous".to_string(), - output: "blocked PASSWORD=hunter2 --token raw-token".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 }, @@ -399,6 +400,40 @@ fn write_to_shell_policy_denial_marker_result_redacts_yaml_output() { 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/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 4cd95361d..30c6028bd 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -1154,6 +1154,7 @@ impl BlocklistAIActionExecutor { input.conversation_id, Some(active_profile.id().to_string()), warp_permission.clone(), + config.allow_autoapproval_for_all_hooks(), ctx, )?; @@ -1223,7 +1224,7 @@ impl BlocklistAIActionExecutor { let already_preprocessed = self .request_file_edits_executor .update(ctx, |executor, _ctx| { - executor.has_preprocessed_action(conversation_id, action) + executor.has_preprocessed_action(conversation_id, action, _ctx) }); if already_preprocessed { return false; @@ -1296,6 +1297,7 @@ impl BlocklistAIActionExecutor { conversation_id, Some(active_profile.id().to_string()), warp_permission.clone(), + config.allow_autoapproval_for_all_hooks(), ctx, )?; let preflight_key = @@ -1345,7 +1347,7 @@ impl BlocklistAIActionExecutor { ) && self .request_file_edits_executor .update(ctx, |executor, _ctx| { - executor.has_preprocessed_action(conversation_id, action) + executor.has_preprocessed_action(conversation_id, action, _ctx) }); let should_preserve_for_file_edit_preprocess = should_preserve_completed_policy_preflight_for_file_edit_preprocess( @@ -1420,6 +1422,7 @@ impl BlocklistAIActionExecutor { conversation_id: AIConversationId, active_profile_id: Option, warp_permission: WarpPermissionSnapshot, + hook_autoapproval_enabled: bool, ctx: &mut ModelContext, ) -> Option { let current_working_directory = self @@ -1436,15 +1439,18 @@ impl BlocklistAIActionExecutor { 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, - )) + 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( 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 e3ba0fe5c..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 @@ -43,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, }; @@ -63,6 +66,23 @@ pub struct RequestFileEditsExecutor { 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 { @@ -80,10 +100,23 @@ fn file_edit_preprocess_failure_matches( 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), } } @@ -128,14 +161,28 @@ mod tests { 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), - file_edit_preprocess_fingerprint(conversation_two, &old_action) + 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), - file_edit_preprocess_fingerprint(conversation_one, &new_action) + 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) ); } @@ -144,8 +191,11 @@ mod tests { 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 old_fingerprint = file_edit_preprocess_fingerprint(conversation_id, &old_action); - let new_fingerprint = file_edit_preprocess_fingerprint(conversation_id, &new_action); + 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], @@ -213,7 +263,9 @@ 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 fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); + 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 .get(&action.id) @@ -242,8 +294,11 @@ impl RequestFileEditsExecutor { &self, conversation_id: AIConversationId, action: &AIAgentAction, + ctx: &mut ModelContext, ) -> bool { - let fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); + 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) @@ -280,7 +335,9 @@ impl RequestFileEditsExecutor { }; // If diff application failed, early exit. - let fingerprint = file_edit_preprocess_fingerprint(conversation_id, action); + 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) @@ -448,7 +505,9 @@ impl RequestFileEditsExecutor { let (tx, rx) = oneshot::channel(); let files = file_edits.clone(); let id = id.clone(); - let fingerprint = file_edit_preprocess_fingerprint(input.conversation_id, input.action); + 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) @@ -457,10 +516,10 @@ impl RequestFileEditsExecutor { ctx.spawn( async move { let applied_diffs = apply_future.await; - (applied_diffs, id, fingerprint, tx) + (applied_diffs, id, fingerprint, session_context, tx) }, - |me, (diffs, id, fingerprint, tx), ctx| { - me.on_diffs_applied(diffs, id, fingerprint, tx, ctx); + |me, (diffs, id, fingerprint, session_context, tx), ctx| { + me.on_diffs_applied(diffs, id, fingerprint, session_context, tx, ctx); }, ); @@ -475,6 +534,7 @@ impl RequestFileEditsExecutor { applied_diffs: Result, Vec1>, id: AIAgentActionId, fingerprint: FileEditPreprocessFingerprint, + session_context: FileEditPreprocessContext, tx: oneshot::Sender<()>, ctx: &mut ModelContext, ) { @@ -520,20 +580,12 @@ impl RequestFileEditsExecutor { }; self.diff_application_failures.remove(&id); - 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); - 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); @@ -541,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()), @@ -554,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 52cda5a0b..d55aa4ed9 100644 --- a/app/src/ai/blocklist/action_model/execute_tests.rs +++ b/app/src/ai/blocklist/action_model/execute_tests.rs @@ -828,6 +828,44 @@ mod policy_hooks { 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()); diff --git a/app/src/ai/policy_hooks/audit.rs b/app/src/ai/policy_hooks/audit.rs index 2ef3b6dc8..8ba480b41 100644 --- a/app/src/ai/policy_hooks/audit.rs +++ b/app/src/ai/policy_hooks/audit.rs @@ -6,11 +6,11 @@ use std::{ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Serialize, Serializer}; use super::{ decision::AgentPolicyEffectiveDecision, - event::{AgentPolicyAction, AgentPolicyActionKind, AgentPolicyEvent}, + event::{redact_policy_path, AgentPolicyAction, AgentPolicyActionKind, AgentPolicyEvent}, }; #[cfg(not(test))] @@ -27,6 +27,7 @@ struct AgentPolicyAuditRecord<'a> { 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")] @@ -120,6 +121,16 @@ pub(crate) fn audit_record_json_line( 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)] { diff --git a/app/src/ai/policy_hooks/config.rs b/app/src/ai/policy_hooks/config.rs index 9bafd95be..6d69a1f5c 100644 --- a/app/src/ai/policy_hooks/config.rs +++ b/app/src/ai/policy_hooks/config.rs @@ -8,7 +8,9 @@ use http::header::HeaderName; use serde::{ser::SerializeStruct, Deserialize, Serialize}; use thiserror::Error; -use super::decision::AgentPolicyUnavailableDecision; +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; @@ -129,6 +131,9 @@ pub(crate) struct AgentPolicyHook { 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() } @@ -136,6 +141,9 @@ impl AgentPolicyHook { 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)?; @@ -185,16 +193,18 @@ impl AgentPolicyHookTransport { fn validate_safe_to_persist(&self) -> Result<(), AgentPolicyHookConfigError> { match self { Self::Stdio { - command, args, env, .. + 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 } => { - if http_url_contains_credentials(url) { - return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); - } + validate_http_url(url)?; validate_http_secret_value_map(headers)?; } } @@ -225,22 +235,10 @@ impl AgentPolicyHookTransport { Path::new("").to_path_buf(), )); } + validate_stdio_working_directory_safe_to_persist(working_directory)?; } Self::Http { url, headers } => { - if http_url_contains_credentials(url) { - return Err(AgentPolicyHookConfigError::HttpUrlContainsCredentials); - } - - let parsed = url::Url::parse(url) - .map_err(|_| AgentPolicyHookConfigError::InvalidHttpUrl(url.clone()))?; - - 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.clone())); - } - + validate_http_url(url)?; validate_http_secret_value_map(headers)?; } } @@ -312,6 +310,39 @@ fn validate_http_secret_value_map( 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 @@ -452,7 +483,7 @@ fn parsed_url_contains_credentials(parsed: &url::Url) -> bool { fn url_component_contains_credentials(value: &str) -> bool { let mut current = std::borrow::Cow::Borrowed(value); - for _ in 0..=3 { + for _ in 0..=4 { if text_contains_credentials(current.as_ref()) { return true; } @@ -466,7 +497,7 @@ fn url_component_contains_credentials(value: &str) -> bool { current = std::borrow::Cow::Owned(decoded.into_owned()); } - false + text_contains_credentials(current.as_ref()) } fn text_contains_credentials(value: &str) -> bool { @@ -479,14 +510,7 @@ fn text_contains_credentials(value: &str) -> bool { } let normalized = lower.replace(['_', '-'], ""); - if normalized.contains("apikey") - || normalized.contains("accesskey") - || normalized.ends_with("token") - || normalized.ends_with("secret") - || normalized.ends_with("password") - || normalized.ends_with("passwd") - || normalized.ends_with("authorization") - { + if normalized_contains_credential_marker(&normalized) { return true; } @@ -501,6 +525,16 @@ fn text_contains_credentials(value: &str) -> bool { || 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; @@ -628,19 +662,17 @@ fn stdio_arg_expects_secret_value(value: &str) -> bool { let value = value.trim_start_matches('-').trim_end_matches(':'); let normalized = value.to_ascii_lowercase().replace(['_', '-'], ""); - normalized.contains("apikey") - || normalized.contains("accesskey") + normalized_contains_credential_marker(&normalized) || normalized == "u" || normalized == "user" || normalized == "proxyuser" - || normalized.ends_with("token") - || normalized.ends_with("secret") - || normalized.ends_with("password") - || normalized.ends_with("passwd") - || normalized.ends_with("authorization") || 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) @@ -685,6 +717,8 @@ pub(crate) enum AgentPolicyHookConfigError { 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( @@ -695,6 +729,8 @@ pub(crate) enum AgentPolicyHookConfigError { "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" )] diff --git a/app/src/ai/policy_hooks/event.rs b/app/src/ai/policy_hooks/event.rs index c59ce2264..3b3cb0267 100644 --- a/app/src/ai/policy_hooks/event.rs +++ b/app/src/ai/policy_hooks/event.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize, Serializer}; @@ -20,8 +20,10 @@ pub(crate) struct AgentPolicyEvent { 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, @@ -47,12 +49,18 @@ impl AgentPolicyEvent { 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, @@ -315,11 +323,25 @@ impl PolicyReadMcpResourceAction { } } +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; + return path.to_path_buf(); } PathBuf::from(redacted_path) diff --git a/app/src/ai/policy_hooks/redaction.rs b/app/src/ai/policy_hooks/redaction.rs index e0f076884..a10fd1eab 100644 --- a/app/src/ai/policy_hooks/redaction.rs +++ b/app/src/ai/policy_hooks/redaction.rs @@ -23,7 +23,7 @@ static AUTHORIZATION_BASIC_RE: Lazy = Lazy::new(|| { 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]+)"#, + 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") }); @@ -42,14 +42,14 @@ static CURL_BASIC_AUTH_RE: Lazy = Lazy::new(|| { 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)\b\s+)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + 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)\b=)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|bearer\s+[^\s;&|]+|basic\s+[^\s;&|]+|[^\s;&|]+)"#, + 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") }); @@ -64,6 +64,12 @@ pub(crate) fn redact_command_for_policy(command: &str) -> String { } 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"); @@ -73,7 +79,30 @@ pub(crate) fn redact_sensitive_text_for_policy(value: &str) -> String { 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, ""); - truncate_for_policy(&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 { diff --git a/app/src/ai/policy_hooks/tests.rs b/app/src/ai/policy_hooks/tests.rs index 5bd275781..281a69a75 100644 --- a/app/src/ai/policy_hooks/tests.rs +++ b/app/src/ai/policy_hooks/tests.rs @@ -188,6 +188,9 @@ fn config_rejects_stdio_hook_credential_args() { 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"]), @@ -240,6 +243,9 @@ fn config_rejects_stdio_hook_credential_command() { "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'", @@ -374,7 +380,10 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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", @@ -383,6 +392,7 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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", @@ -392,6 +402,7 @@ fn config_rejects_http_hook_url_embedded_credentials() { "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", ] { @@ -427,6 +438,46 @@ fn config_allows_http_hook_url_non_credential_query_values() { 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 [ @@ -493,6 +544,40 @@ fn profile_serialization_sanitizes_disabled_http_hook_url_embedded_credentials() } } +#[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!({ @@ -693,6 +778,7 @@ fn event_serializes_redacted_command_shape() { 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(); @@ -702,6 +788,45 @@ fn event_serializes_redacted_command_shape() { 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!( @@ -777,6 +902,8 @@ fn command_redaction_handles_split_secret_args() { "--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" ); @@ -792,6 +919,9 @@ fn command_redaction_handles_split_secret_args() { 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")); @@ -803,6 +933,9 @@ fn command_redaction_handles_split_secret_args() { 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] @@ -883,6 +1016,7 @@ 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( @@ -900,9 +1034,28 @@ fn policy_file_paths_are_redacted_before_serialization() { 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 { @@ -1044,6 +1197,8 @@ fn audit_record_uses_redacted_policy_event_payload() { 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")); } diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index ef208ef1d..33b39d4c4 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -94,7 +94,7 @@ impl TryFrom for api::request::input::tool_call_resu api::request::input::tool_call_result::Result::RunShellCommand( api::RunShellCommandResult { command, - output: format!("{COMMAND_POLICY_DENIED_PREFIX}{reason}"), + output: encode_command_policy_denied_message(&reason), exit_code: Default::default(), result: Some(api::run_shell_command_result::Result::PermissionDenied( api::PermissionDenied { reason: None }, diff --git a/crates/ai/src/agent/action_result/convert_tests.rs b/crates/ai/src/agent/action_result/convert_tests.rs index 448078f97..ef2bb56d5 100644 --- a/crates/ai/src/agent/action_result/convert_tests.rs +++ b/crates/ai/src/agent/action_result/convert_tests.rs @@ -52,9 +52,10 @@ fn policy_denied_shell_result_preserves_policy_reason_without_denylist_label() { #[allow(deprecated)] let output = &result.output; assert_eq!( - output.as_str(), - format!("{COMMAND_POLICY_DENIED_PREFIX}blocked by org policy") + 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()); } diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index f86ed1353..8768bfdf4 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -14,6 +14,7 @@ use crate::{ }; 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 = @@ -30,6 +31,28 @@ struct FileEditsPolicyDeniedApiMessage { 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(), diff --git a/specs/GH9914/product.md b/specs/GH9914/product.md index 2d9d9c315..969964ba8 100644 --- a/specs/GH9914/product.md +++ b/specs/GH9914/product.md @@ -92,7 +92,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove 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 and can be configured to `deny` by managed policy. +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. @@ -108,7 +108,7 @@ When hooks are enabled, Warp writes a redacted local audit record for every gove - **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. +- **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 diff --git a/specs/GH9914/tech.md b/specs/GH9914/tech.md index 2201cdb2e..0fa2476ed 100644 --- a/specs/GH9914/tech.md +++ b/specs/GH9914/tech.md @@ -133,7 +133,7 @@ Effective decision rules: 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`. +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.