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,HookEnvelopeand per-boundary payloads in Warp's own types,CanonicalToolName, the five-valuedHookVerdict,HookEffects,reduce_effects,BindingCapabilitiesandvalidate_capabilities,HookDiagnostic, default timeouts), theHookFormatAdapter,HookRunner,HookMessenger, andTrustPolicytraits, aHookRegistrysingleton with revisioned snapshots, aHookDispatcherthat 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 toPermissionDenied.hook_denied, with the proto pinned to the commit that adds that member.app/src/ai/blocklist/hooks: theHookRuntimesingleton, envelope builders, the pending-hook state fortool.pre, the completion funnel fortool.resultandtool.failure, and observers forprompt.submit,turn.stop,session.start,response.complete, andreasoning.complete.- Call sites in
execute.rs,action_model.rs,controller.rs, andhistory_model.rs. Every one starts withHookRuntime::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::WaitingOnHooksrather than reusingNotReady(§6.1).NotReadyis 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 inhandle_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'sresume_action_after_hooksroutes throughtry_to_execute_available_actions(orexecute_actionwhen 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_resolveddoes not touch the action model directly. - Verdict cache key (§6.1):
VerdictKeyis the conversation id plus the action id. The cache lives on the process-wideHookRuntimeand 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 arun_agentsrequest 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.
PendingDispatchcarries aDispatchTicket(the conversation id, action id, and that dispatch's own cancel flag), andon_resolveddrops 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::dispatchtakes a per-firingArc<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 inpre_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 frozenexecute.rs.
Permission and denial
- Permission policy (§6.5):
denyalways 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.askhandling, what happens toaskon a surface with no user to ask, and named floors are PR 6;PermissionFloorsis an empty struct the policy destructures so new fields cannot be ignored. Also recorded inquestions.md. - Unsupported rewrites (§4.3, §6.2): a merged
replacement_inputontool.predenies 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 inquestions.md. - Denied results for non-shell families (§9.3): only shell gets
HookDenied; every other action finishes with its existingcancelled_result(). The user still sees the reason through aDeniedhook notice. PR 6 adds the text-bearing variants. Also recorded inquestions.md. - Reason attribution:
HookReason.bindingisOption<HookBindingId>so Warp can write a reason of its own (rewrite denial now, the no-useraskcase later);reduce_effectsfills in the binding for reasons an adapter left blank. - Winning binding:
MergedEffects.verdict_bindingrecords which binding's verdict won the merge, so a default deny reason can still point at a source. Defer(§4.3):HookVerdict::severityreturnsNonefor it;reduce_effectsreports it as anUnsupportedEffectand 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_jsoncannot fail on aserde_json::Valuetoday, 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:
AIAgentExchangeIdlives inappand wraps aUuidwithout exposing it, so the envelope carriesexchange_id: Option<Uuid>and the builder parses the id's string form.turn_idis alwaysNone; PR 7 allocates it inprompt.submit. - Envelope common fields: cwd comes from the terminal view's
ActiveSession, which the executor now keeps a handle to and passes down;rootsis the detected git root for that cwd;surfaceisGui,Tui, orCliRunfromAppExecutionMode, andOzRunonce run metadata is set throughHookRuntime::set_run_metadata(PR 8);permission_factsis left default because hooks are consulted beforeshould_autoexecuteruns, which avoids calling it twice.session.starthas no cwd and no history lookup because it fires from inside the history model's own update.tool.resultandtool.failurecarry adurationslot that isNoneuntil PR 7 measures it. - Version fields:
ENVELOPE_SCHEMA_VERSION(the envelope's shape, read by adapters) andHOOK_PROTOCOL_VERSION(the stdin and stdout contract scripts see) are both defined inmodel.rs, both 1. - Cancellation reason names:
tool.failurecarries 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. HookRuntimesetters:set_runner,set_messenger, andset_run_metadataexist 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_installedandis_active(§3):is_installedisFeatureFlag::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_activeaddssnapshot not empty. The flag check comes first, so the flag-off path is one atomic load.begin_or_lookupadditionally returnsInactivebefore building an envelope when the snapshot has notool.prebinding at all, so a run with only post-tool or turn hooks does not pay the envelope cost on every action attempt.HookSourceKindleaves outUserCursorFile; nothing in Phase 1 needs it for precedence tests.- Adapter table (§3):
adapter_forreturnsOption<Arc<dyn HookFormatAdapter>>andActiveBindingcarries its adapter as anArc, 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/planor/orchestrateprefix is kept; the copy is only made whenHookRuntime::is_activeis true, so the inactive path adds nothing beyond that check), after the previous turn's pending actions have been cancelled so theirtool.failureenvelopes precede the new turn'sprompt.submit. It is not fired for custom inputs (code review, passive suggestions, agent wakes), for slash commands (/compact,/init), which return fromsend_querybefore 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 guardsturn.stopand the stream observers; the completion funnel checksexecutes_tool_calls_locally;session.startuses the constructor flags.turn.stop: fires at the two places the turn's success is decided: the action-model subscription that choosesConversationStatus::Success, andAfterStreamFinishedwhen the stream ended cleanly, left the conversation inSuccess, and queued no actions.continuation_countis always 0 in this PR.response.completeheuristic (§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 whenfinished_durationarrives. Only a message that is new to the stream ends the text; anUpdateTaskMessagethat 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 ofAfterStreamFinished, before the conversation lookup, so a stream whose conversation is gone still drops its state.- Funnel classification:
tool.failurewhen the result is failed, cancelled, or carries a cancellation reason;tool.resultotherwise.
Placeholders and pins
- Production runner:
UnavailableRunneruntil PR 2; every invocation reportsSpawnFailed. 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.tomlgainsai_typesfor the conversation and action id types. - Proto pin:
warp-proto-apisis pinned to3a84b9a36c4a00565da68ae18810a174be2d04be, the commit ondavid/hook-denied-reasonthat addshook_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):PathCaptureincrates/ai/src/hooks/runner/mod.rsbecameArc<dyn Fn() -> Option<OsString> + Send + Sync>instead of a function pointer. The login shell PATH lives onLocalShellState, 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):HookConfigErrorincrates/ai/src/hooks/adapters/mod.rsgained anUnreadablevariant for a source file that cannot be read before parsing starts. - Amendment to PR 1 from PR 3 (
79fbe1a2e, formatting6fc1bab54):HookFormatAdapter::decode_outputincrates/ai/src/hooks/adapters/mod.rstakes apolicy: &DecodePolicy(the strict hook policy setting, which only the adapter can apply because whether a schema-acceptedaskis honored or a failed hook blocks depends on the vendor event), andDeclaredBindinggainedloop_limit: Option<u32>so a stop hook's continuation cap travels with the binding. The dispatcher passesDecodePolicy::default()until PR 6 wires the setting;RecordingAdapterrecords the policies it is given. - Amendment to PR 1 from PR 3 review round 1 (
c41714aaa):HookEffectsincrates/ai/src/hooks/model.rsgainedunsupported: Vec<UnsupportedEffect>, andreduce_effectsextends 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.RecordingAdapterfills the field with an empty list. - Amendment to PR 1 from PR 4 (
17c9e32b1):HookProcessOutcomeincrates/ai/src/hooks/runner/mod.rsgainedstdout_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.rsgainedHOOK_TIMEOUT_CEILING(600 seconds), the longest per-definitiontimeouta source may declare; the registry applies it at activation in PR 5.ScriptedRunnerandUnavailableRunnerset the field tofalse. - Amendment to PR 1 from PR 5 (
80cdb1627):surfaceinapp/src/ai/blocklist/hooks/envelope.rsispub(super), soHookRuntimecan tell the trust policy which surface the process fires boundaries from when it pins the run. - Amendment to PR 1 from PR 6 (
9db79f047;30a77c620renames the fieldheadlesstois_headlessso it reads like its neighbors): the permission policy's input and output types incrates/ai/src/hooks/permission.rs.RunFacts { is_headless, is_strict }carries what the policy needs to know about the run;PermissionFloorshas named fields (command_denylist_hit,protected_write_path,mcp_server_denylisted,always_ask_setting) andany();PermissionDecision::Denycarries aDenyCause(Hook,RewriteUnsupported,ApprovalUnavailable,DeferralUnsupported) next to its reason, andPermissionDecision::strictness()orders decisions so tightening can be asserted.crates/ai/src/hooks/model.rsgainedHookSurface::is_headless()(OzRun,CliRun,LocalChild).app/src/ai/blocklist/hooks/envelope.rsgainedsurface_for_conversation, which reportsLocalChildfor a conversation with a parent in a desktop or TUI process, andbuild()uses it, so a hidden local child's envelopes no longer sayGui. Atesting::add_child_conversationhelper was added for the tests. - Amendment to PR 1 from PR 7 (
35faca3a5, formattingb7e173404):crates/ai/src/hooks/model.rsgainedHOOK_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 aprovenancefield onHookPayload::PromptSubmitso a hook can tell a person's prompt from a follow-up a stop hook asked for,HookEnvelope::session_env(the variables a conversation'ssession.starthooks set, which the runner applies to every later invocation in that conversation), andMergedEffects::continuation_binding(filled byreduce_effects, so the stop step can charge a follow-up to the binding that asked for it).hooks/mod.rsre-exports them;test_util/mod.rssetssession_envon the test envelope;RecordingAdapterdecodes"env": [[key, value]]intosession_envand gives a continuation the binding'sloop_limit; this PR'senvelope.rs,prompt.rs, andprompt_tests.rspassPromptProvenance::User. - §9.3 asks the client conversion to stop dropping
CancelledBeforeExecutionfrom 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_agentsedit, 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_conversationcreates every conversation asNativeandbegin_external_harness_turnswitches the driver afterwards, sosession.startfires 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.resultholds the result beforehandle_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
HookDeniedvariant: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-specorready-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— passedcargo check -p ai --features local_fs,test-util— passedcargo check -p warp— passedcargo 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— passedcargo clippy -p warp --all-targets --tests -- -D warnings— passedcargo 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,deferreported 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_tooland 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 withHookDenied(plain and user-initiated) and converts topermission_denied::Reason::HookDeniedin 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 notool.prebinding 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_agentsrequest 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 theOzRunsurface; 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.submitwhile a custom input does not; a clean stream end with no actions firesresponse.completeandturn.stop; a stream that queues a tool call does not fireturn.stop;session.startthroughstart_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
37af6f00bThis pull request is a draft
Merging is unavailable until it is marked ready for review.
masterThis stack of pull requests is managed by Graphite. Learn more about stacking.