#16577

hooks: scaffold canonical model and interception points

Draftvorporealwants to merge intofrom

Description

Summary

First PR of the agent hooks Phase 1 stack. It creates the skeleton every later PR fills in and wires every Phase 1 interception point into the agent loop. With the feature flag off, or with no hook sources active, nothing observable changes. With a scripted deny from the test doubles, a shell command is denied with a reason the model can read.

  • crates/ai/src/hooks: the canonical model (HookBoundary, HookEnvelope and per-boundary payloads in Warp's own types, CanonicalToolName, the five-valued HookVerdict, HookEffects, reduce_effects, BindingCapabilities and validate_capabilities, HookDiagnostic, default timeouts), the HookFormatAdapter, HookRunner, HookMessenger, and TrustPolicy traits, a HookRegistry singleton with revisioned snapshots, a HookDispatcher that runs matching bindings one at a time and logs one line per invocation, HookPermissionPolicy (tightening only), a deny-all trust placeholder, the log-only messenger, and test doubles (RecordingAdapter, ScriptedRunner, RecordingMessenger, StaticTrust). Nothing loads from disk or spawns a process.
  • FeatureFlag::AgentHooks, off on every channel.
  • RequestCommandOutputResult::HookDenied { command, reason } and its conversion to PermissionDenied.hook_denied, with the proto pinned to the commit that adds that member.
  • app/src/ai/blocklist/hooks: the HookRuntime singleton, envelope builders, the pending-hook state for tool.pre, the completion funnel for tool.result and tool.failure, and observers for prompt.submit, turn.stop, session.start, response.complete, and reasoning.complete.
  • Call sites in execute.rs, action_model.rs, controller.rs, and history_model.rs. Every one starts with HookRuntime::is_active(ctx), which is a feature flag check followed by a registry emptiness check, and returns early.

How the pre-tool hook works: try_to_execute_action asks pre_tool::begin_or_lookup before Warp's own permission check. On first sight of an action it checks that some tool.pre binding exists at all, then builds the envelope, checks that an active binding matches, starts the dispatch in the background, and returns NotExecuted with the new NotExecutedReason::WaitingOnHooks, so the action model re-queues the action at the same index. Each dispatch carries a DispatchTicket; when it resolves, pre_tool::on_resolved stores the verdict only if the entry is still waiting for that exact dispatch, and the executor emits BlocklistAIActionExecutorEvent::HookVerdictResolved; the action model handles it by trying the queue again (or, if the user had already approved the action, by re-running the user-initiated path). On the retry the cached verdict is found, Warp's own check runs once, and HookPermissionPolicy::compose decides. A deny finishes the action without running it; anything else falls through to today's logic unchanged.

Decisions

Pre-tool pending state

  • Pending reason: added NotExecutedReason::WaitingOnHooks rather than reusing NotReady (§6.1). NotReady is what executors return when they cannot run yet (busy terminal, missing block), and logging or a later UI should be able to tell "waiting on a hook" apart from that. The cost is one more arm in handle_not_executed_action, which is now an exhaustive match.
  • Re-entry (§6.1): the executor spawns the dispatch on its own context and emits HookVerdictResolved; the action model's resume_action_after_hooks routes through try_to_execute_available_actions (or execute_action when the user had already approved the action while the verdict was pending), so parallel-phase rules and the one-at-a-time rule for user approvals are the existing ones. pre_tool::on_resolved does not touch the action model directly.
  • Verdict cache key (§6.1): VerdictKey is the conversation id plus the action id. The cache lives on the process-wide HookRuntime and tool call ids are only unique within a conversation, so the action id alone would let two conversations share an entry. The entry remembers whether any lookup was user-initiated so that approval survives the round trip. Verdicts are dropped when the action reaches a terminal result (handle_action_result), when an accepted command's text differs from the text the hooks saw (handle_requested_command_accepted; an unedited accept reuses the verdict), when a run_agents request accepted from the card differs from the queued one (execute_run_agents; likewise an unedited accept reuses the verdict), and when the conversation's pending actions are cancelled. Widening the key to argument revision, cwd, and registry revision is PR 6.
  • Dispatch identity (§6.1): a dispatch that is invalidated while in flight keeps running until the dispatcher's next check, so a second dispatch for the same action can start before the first resolves. PendingDispatch carries a DispatchTicket (the conversation id, action id, and that dispatch's own cancel flag), and on_resolved drops a verdict whose ticket is cancelled or whose flag is not the one the current entry holds. Without this a stale verdict for the old command text could be applied to the edited one.
  • Dispatcher cancellation (§6.1, §9.1): HookDispatcher::dispatch takes a per-firing Arc<AtomicBool> instead of holding one flag on the dispatcher, so cancelling one conversation's pending hooks cannot cancel another's. A verdict that arrives after cancellation is discarded in pre_tool::on_resolved. The dispatch future also sets its own flag if it is dropped before finishing (an executor dropped on an abnormal close), so a runner polling the flag still stops. Disabling the flag at runtime is not supported in Phase 1; there is no setting.
  • Denied-result placement: the per-family choice of result for a denied action lives in pre_tool::denied_result, which PR 6 owns next, so PR 6 replaces one function body instead of editing the frozen execute.rs.

Permission and denial

  • Permission policy (§6.5): deny always denies, with the denying binding's own reason when it gave one, otherwise the first reason any binding gave, otherwise a fixed "An agent hook denied this action." text. allow, ask, defer, and no opinion leave Warp's own decision in place. ask handling, what happens to ask on a surface with no user to ask, and named floors are PR 6; PermissionFloors is an empty struct the policy destructures so new fields cannot be ignored. Also recorded in questions.md.
  • Unsupported rewrites (§4.3, §6.2): a merged replacement_input on tool.pre denies with a Warp-written reason instead of running the original input. The contract's PR 1 rule did not mention rewrites; the architecture says dropping one silently is the one failure that must not happen. Also recorded in questions.md.
  • Denied results for non-shell families (§9.3): only shell gets HookDenied; every other action finishes with its existing cancelled_result(). The user still sees the reason through a Denied hook notice. PR 6 adds the text-bearing variants. Also recorded in questions.md.
  • Reason attribution: HookReason.binding is Option<HookBindingId> so Warp can write a reason of its own (rewrite denial now, the no-user ask case later); reduce_effects fills in the binding for reasons an adapter left blank.
  • Winning binding: MergedEffects.verdict_binding records which binding's verdict won the merge, so a default deny reason can still point at a source.
  • Defer (§4.3): HookVerdict::severity returns None for it; reduce_effects reports it as an UnsupportedEffect and the dispatcher turns that into a warning diagnostic. It is never merged.
  • Input that cannot be encoded (§6.3): if the adapter's stdin JSON cannot be serialized, the dispatcher logs the error, sends the source an error diagnostic, and applies the binding's declared failure policy: fail-closed denies with a Warp-written reason, fail-open has no opinion. serde_json cannot fail on a serde_json::Value today, so the branch is not reachable through the current adapter trait; it exists so a future change fails closed instead of silently. The failure policy for process outcomes is still the adapter's job until PR 6.

Envelope and surfaces

  • Exchange id: AIAgentExchangeId lives in app and wraps a Uuid without exposing it, so the envelope carries exchange_id: Option<Uuid> and the builder parses the id's string form. turn_id is always None; PR 7 allocates it in prompt.submit.
  • Envelope common fields: cwd comes from the terminal view's ActiveSession, which the executor now keeps a handle to and passes down; roots is the detected git root for that cwd; surface is Gui, Tui, or CliRun from AppExecutionMode, and OzRun once run metadata is set through HookRuntime::set_run_metadata (PR 8); permission_facts is left default because hooks are consulted before should_autoexecute runs, which avoids calling it twice. session.start has no cwd and no history lookup because it fires from inside the history model's own update. tool.result and tool.failure carry a duration slot that is None until PR 7 measures it.
  • Version fields: ENVELOPE_SCHEMA_VERSION (the envelope's shape, read by adapters) and HOOK_PROTOCOL_VERSION (the stdin and stdout contract scripts see) are both defined in model.rs, both 1.
  • Cancellation reason names: tool.failure carries the cancellation reason as a fixed snake_case name per variant (user_cancelled, cloud_handoff, follow_up_submitted, user_ran_command, reverted, conversation_deleted, command_finished, user_took_control, shell_exited) rather than the enum's Debug text, since scripts match on it. A test pins every string.
  • HookRuntime setters: set_runner, set_messenger, and set_run_metadata exist now so PR 2 (command runner), PR 8 (run metadata, Oz messenger), and the TUI can install their pieces without editing this module. Each rebuilds the dispatcher as needed; dispatches already in flight keep the runner they started with.
  • is_installed and is_active (§3): is_installed is FeatureFlag::AgentHooks.is_enabled() && runtime registered, used by the entry points that only clear state (invalidate, cancel_all_for_conversation, on_resolved) and must do so even when no binding is active any more. is_active adds snapshot not empty. The flag check comes first, so the flag-off path is one atomic load. begin_or_lookup additionally returns Inactive before building an envelope when the snapshot has no tool.pre binding at all, so a run with only post-tool or turn hooks does not pay the envelope cost on every action attempt.
  • HookSourceKind leaves out UserCursorFile; nothing in Phase 1 needs it for precedence tests.
  • Adapter table (§3): adapter_for returns Option<Arc<dyn HookFormatAdapter>> and ActiveBinding carries its adapter as an Arc, so the registry resolves the adapter once at activation and tests can install the recording adapter without a static table entry. The table is empty until PR 2.

Observers

  • prompt.submit: fires only for prompts the user typed (InputQueryType::UserSubmittedQueryFromInput), with the text as typed (a /plan or /orchestrate prefix is kept; the copy is only made when HookRuntime::is_active is true, so the inactive path adds nothing beyond that check), after the previous turn's pending actions have been cancelled so their tool.failure envelopes precede the new turn's prompt.submit. It is not fired for custom inputs (code review, passive suggestions, agent wakes), for slash commands (/compact, /init), which return from send_query before the call, or for conversations this client does not drive (ConversationDriver::owns_turn_lifecycle). PR 7 revisits the text and ordering when it makes the boundary blocking and allocates the turn id there. The same driver check guards turn.stop and the stream observers; the completion funnel checks executes_tool_calls_locally; session.start uses the constructor flags.
  • turn.stop: fires at the two places the turn's success is decided: the action-model subscription that chooses ConversationStatus::Success, and AfterStreamFinished when the stream ended cleanly, left the conversation in Success, and queued no actions. continuation_count is always 0 in this PR.
  • response.complete heuristic (§9.2): per response stream, a text message is final when a different message (text, reasoning, or tool call) starts or the stream ends; reasoning is final when finished_duration arrives. Only a message that is new to the stream ends the text; an UpdateTaskMessage that replaces an earlier tool-call message mid-text does not. A cancelled stream drops its partial text instead of emitting it. Late updates to an already-emitted message are ignored. On a conversation split, messages after the split are attributed to the conversation the stream maps to after the batch is applied. Observer state is flushed at the top of AfterStreamFinished, before the conversation lookup, so a stream whose conversation is gone still drops its state.
  • Funnel classification: tool.failure when the result is failed, cancelled, or carries a cancellation reason; tool.result otherwise.

Placeholders and pins

  • Production runner: UnavailableRunner until PR 2; every invocation reports SpawnFailed. No binding can be active in production yet, so it is never reached.
  • Per-invocation environment: empty; PR 4 owns env.rs.
  • Stdin payload: the adapter's JSON, serialized once per invocation; the dispatcher clones the envelope per binding to stamp a fresh invocation_id.
  • Dependency: crates/ai/Cargo.toml gains ai_types for the conversation and action id types.
  • Proto pin: warp-proto-apis is pinned to 3a84b9a36c4a00565da68ae18810a174be2d04be, the commit on david/hook-denied-reason that adds hook_denied, rather than that branch's later head (99a5cf5c, a doc-comment-only change with identical generated names). The whole stack re-pins to the merged revision when warp-proto-apis#404 lands.

Departures from architecture.md

  • Amendment to PR 1 from PR 2 (dc9114902): PathCapture in crates/ai/src/hooks/runner/mod.rs became Arc<dyn Fn() -> Option<OsString> + Send + Sync> instead of a function pointer. The login shell PATH lives on LocalShellState, which the app can only read with a model context after startup, so the app fills a shared slot the closure reads.
  • Amendment to PR 1 from PR 2 (dcd155bf8): HookConfigError in crates/ai/src/hooks/adapters/mod.rs gained an Unreadable variant for a source file that cannot be read before parsing starts.
  • Amendment to PR 1 from PR 3 (79fbe1a2e, formatting 6fc1bab54): HookFormatAdapter::decode_output in crates/ai/src/hooks/adapters/mod.rs takes a policy: &DecodePolicy (the strict hook policy setting, which only the adapter can apply because whether a schema-accepted ask is honored or a failed hook blocks depends on the vendor event), and DeclaredBinding gained loop_limit: Option<u32> so a stop hook's continuation cap travels with the binding. The dispatcher passes DecodePolicy::default() until PR 6 wires the setting; RecordingAdapter records the policies it is given.
  • Amendment to PR 1 from PR 3 review round 1 (c41714aaa): HookEffects in crates/ai/src/hooks/model.rs gained unsupported: Vec<UnsupportedEffect>, and reduce_effects extends the merged list with each binding's entries, so an adapter can report a control effect its event cannot produce and the dispatcher's existing diagnostic path delivers it to the messenger. RecordingAdapter fills the field with an empty list.
  • Amendment to PR 1 from PR 4 (17c9e32b1): HookProcessOutcome in crates/ai/src/hooks/runner/mod.rs gained stdout_truncated: bool, set by the runner when a hook printed more than the stdout limit and only the start was kept, so the adapter can treat a cut-off response as malformed output instead of reading the kept prefix as a decision. crates/ai/src/hooks/model.rs gained HOOK_TIMEOUT_CEILING (600 seconds), the longest per-definition timeout a source may declare; the registry applies it at activation in PR 5. ScriptedRunner and UnavailableRunner set the field to false.
  • Amendment to PR 1 from PR 5 (80cdb1627): surface in app/src/ai/blocklist/hooks/envelope.rs is pub(super), so HookRuntime can tell the trust policy which surface the process fires boundaries from when it pins the run.
  • Amendment to PR 1 from PR 6 (9db79f047; 30a77c620 renames the field headless to is_headless so it reads like its neighbors): the permission policy's input and output types in crates/ai/src/hooks/permission.rs. RunFacts { is_headless, is_strict } carries what the policy needs to know about the run; PermissionFloors has named fields (command_denylist_hit, protected_write_path, mcp_server_denylisted, always_ask_setting) and any(); PermissionDecision::Deny carries a DenyCause (Hook, RewriteUnsupported, ApprovalUnavailable, DeferralUnsupported) next to its reason, and PermissionDecision::strictness() orders decisions so tightening can be asserted. crates/ai/src/hooks/model.rs gained HookSurface::is_headless() (OzRun, CliRun, LocalChild). app/src/ai/blocklist/hooks/envelope.rs gained surface_for_conversation, which reports LocalChild for a conversation with a parent in a desktop or TUI process, and build() uses it, so a hidden local child's envelopes no longer say Gui. A testing::add_child_conversation helper was added for the tests.
  • Amendment to PR 1 from PR 7 (35faca3a5, formatting b7e173404): crates/ai/src/hooks/model.rs gained HOOK_FOLLOW_UP_CAP: u32 = 8 (the most follow-ups stop hooks may add to one turn, all bindings together), FollowUpChainId, PromptProvenance { User, HookFollowUp { binding, chain_id } } with a provenance field on HookPayload::PromptSubmit so a hook can tell a person's prompt from a follow-up a stop hook asked for, HookEnvelope::session_env (the variables a conversation's session.start hooks set, which the runner applies to every later invocation in that conversation), and MergedEffects::continuation_binding (filled by reduce_effects, so the stop step can charge a follow-up to the binding that asked for it). hooks/mod.rs re-exports them; test_util/mod.rs sets session_env on the test envelope; RecordingAdapter decodes "env": [[key, value]] into session_env and gives a continuation the binding's loop_limit; this PR's envelope.rs, prompt.rs, and prompt_tests.rs pass PromptProvenance::User.
  • §9.3 asks the client conversion to stop dropping CancelledBeforeExecution from what is sent to the server. Deferred: it changes behavior for cancellations that have nothing to do with hooks. Not in this PR.
  • §6.1 says the cached verdict is keyed by action id, argument revision, cwd, and registry revision. This PR keys by conversation id and action id, and invalidates on command edit, run_agents edit, terminal result, and cancellation; the wider key is PR 6.
  • §2 and §10 say no native hooks apply to a conversation an external harness drives. start_new_conversation creates every conversation as Native and begin_external_harness_turn switches the driver afterwards, so session.start fires for a Codex or Claude Code harness run before the driver is known. The constructor flags cannot see that switch. PR 7's barrier before the first request is the first point where the driver is known; it moves the firing there (once per conversation) and closes this gap.
  • §3 and §6.3 describe the dispatcher running matches concurrently under a bound with deadlines. This PR runs them one at a time with no deadline of its own (the runner enforces the invocation timeout). Limits to add in PR 6: a concurrency bound, per-firing deadline, output limits, and per-binding failure policy for process outcomes.
  • §5 says tool.result holds the result before handle_action_result. In this PR the funnel is observational and never holds the result; PR 7 adds holding where needed.
  • Files outside the contract's ownership table that needed an arm for the new HookDenied variant: app/src/ai/agent/mod.rs, app/src/ai/agent/redaction.rs, app/src/ai/agent_sdk/driver/output.rs. Each arm treats the result like a denial, as the contract asks.

Linked Issue

No linked issue. Part of the agent hooks Phase 1 stack; design is in specs/agent-hooks/architecture.md.

  • The linked issue is labeled ready-to-spec or ready-to-implement.
  • Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes).

Testing

All commands run with CARGO_TARGET_DIR=/Users/david/src/warp/target. cargo check -p warp and the warp test and clippy runs also set WARP_LOCAL_SKIP_METAL=1, which this machine needs because it has no Metal toolchain.

  • cargo check -p ai — passed
  • cargo check -p ai --features local_fs,test-util — passed
  • cargo check -p warp — passed
  • cargo nextest run -p ai --features local_fs -E 'test(hooks)' — passed (39 tests)
  • cargo nextest run -p ai --features local_fs --no-fail-fast -E 'test(hooks) | test(action_result) | test(action)' — passed (55 tests, before review round 1)
  • cargo nextest run -p warp -E 'test(hooks)' --no-fail-fast — passed (51 tests)
  • cargo nextest run -p warp --no-fail-fast -E 'test(ai::blocklist::action_model) | test(ai::blocklist::controller) | test(ai::blocklist::hooks) | test(ai::blocklist::history_model) | test(ai::agent::conversation)' — passed (473 tests, before the style round)
  • cargo clippy -p ai --features local_fs --all-targets --tests -- -D warnings — passed
  • cargo clippy -p warp --all-targets --tests -- -D warnings — passed
  • cargo clippy -p warp_features --all-targets --tests -- -D warnings — passed
  • ./script/format — ran last

New tests, by module:

  • model: effect reduction (severity order, halt any-wins, concatenation order, scalar first-wins with a recorded conflict, defer reported as unsupported); capability validation; the canonical tool name for every action family.
  • permission: every verdict against every combination of the three Warp facts; the rewrite denial; a deny reported with the denying binding's reason rather than an earlier binding's.
  • dispatch: no bindings skips the runner; a deny binding denies with its reason; two bindings reduce in priority order; a pre-set cancel flag runs nothing; a binding whose input cannot be encoded follows its failure policy.
  • registry: empty snapshot; root and host filtering; priority ordering.
  • pre_tool and the executor: flag off never consults hooks; flag on with an empty registry produces the same executor events as flag off; a scripted deny finishes a shell action with HookDenied (plain and user-initiated) and converts to permission_denied::Reason::HookDenied in the request sent to the server; allow and no-opinion leave Warp's decision in place for an action Warp would ask about and one it would run; cancelling a conversation drops a verdict that arrives afterwards; a replaced dispatch cannot resolve the entry that replaced it; the same action id in two conversations gets two entries and approval in one does not leak into the other; a lookup with no tool.pre binding never builds an envelope; dropping an unfinished dispatch sets its cancel flag.
  • Action model: the queued path re-enters through the action model; editing a command runs the hook again on the new text while an unedited accept reuses the verdict; editing a run_agents request from the card runs the hook again on the new request while an unedited one reuses the verdict.
  • tool_result: the funnel fires once per terminal path (sync completion, async completion, hook denial, cancelling a running action, cancelling a pending action) and not at all on a shared-session viewer.
  • envelope: common fields; result and failure payloads; run metadata reaches the envelope and selects the OzRun surface; every cancellation reason name is pinned.
  • stream: once-per-message emission across three interleavings and a conversation split; an update to an earlier tool call does not end the streaming text.
  • Controller: a typed prompt fires prompt.submit while a custom input does not; a clean stream end with no actions fires response.complete and turn.stop; a stream that queues a tool call does not fire turn.stop; session.start through start_new_conversation.

Not covered by a test: the denylist short-circuit's funnel call. It shares finish_sync with the hook-denial path, which is tested, but reaching the denylist branch needs an autonomous-mode app with a denylisted command, which the shared test setup cannot build.

  • I have manually tested my changes locally with ./script/run

Manual testing is deferred to the PR 9 instructions; the flag is off and no source can activate yet.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-NONE

Activity

37
vorporeal10d ago

WARNING

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite. Learn more

  • #16605
  • #16602
  • #16593
  • #16590
  • #16588
  • #16583
  • #16582
  • #16579
  • #16577 👈 (View in Graphite)
  • #16558
  • master

This stack of pull requests is managed by Graphite. Learn more about stacking.

D
8052f77

This pull request is a draft

Merging is unavailable until it is marked ready for review.