Description
Summary
Eighth PR of the agent hooks Phase 1 stack. It makes the two Phase 1 surfaces real: Oz cloud runs and warp agent run. Until now the hook runtime ran in those processes, but nothing told it which run it was in, nothing loaded the sources a run was configured with, notices only went to the log, and the strict-policy and fail-the-run settings could not be set. This PR wires all of that through the agent SDK path, so a hook can run, deny, and be reported in a headless run end to end.
- Run identity and surface. Before its first request the driver records the run on
HookRuntime(run_idandtask_idare the task id,parent_run_idis the parent run), with its surface:OzRunwhen the server dispatched the run (--task-idwas passed),CliRunotherwise. The surface now travels with the run (RunIdentity { metadata, surface }) instead of being inferred from whether run metadata exists, because a localwarp agent runcreates a task too. - Explicit sources.
WARP_HOOK_DIRS, the new hidden--hook-dirflag, and a newhookskey in thewarp agent runconfig file become explicit sources. They are loaded once, after environment preparation and skill loading and before the first request, through PR 5'sload_explicit_sources.hooksentries takepath, optionalformat(onlycursor), and optionalscope(run,factory_agent,factory_wide). They are validated when the file loads, and relative paths resolve against the config file's directory. - Policies.
--hooks-strictandhooks_strictturn on PR 6's strict hook policy.--hooks-on-ask <deny|fail-run>andhooks_on_ask: deny | fail_runchoose PR 6'sHeadlessAskBehavior. The command line wins over the file. - Messenger.
OzHookMessenger(app/src/ai/agent_sdk/driver/hooks.rs) writes each notice and diagnostic to the run's stdout as one record: ahook_noticeorhook_diagnosticJSON record injson/ndjsonoutput, or onehook:line intext/prettyoutput. It also logs each one. Diagnostics about a repository's own hook file are written when the run pins, and again whenever a hook file is found, changed, or broken after the pin and so can no longer take effect. - Pinning at
execute_run. The run's hook set is pinned inexecute_runbefore any request goes out. An Oz run's first prompt is resolved on the server, so noprompt.submitmarks the start of the run on the client.execute_rundoes not firesession.start; PR 7's barrier inprepare_turnowns it. - Missing sources. A source that is missing, unreadable, invalid, or held back is a
hook_diagnostic. Under the strict policy it fails the run before the first request (AgentDriverError::HookSourcesUnavailable, reported to the server as FAILED with one line per source). - Fail the run. Under
fail-run, a hook'saskstill denies the action (PR 6). The driver then fails the conversation through the same controller path a shell exit uses, with the action and the hook's reason in the message, so the server sees a normal failed run. - Remote children. The remote launch request cannot carry hook sources (see Departures). A strict run refuses to start a remote child when the run has explicit sources. Any other run starts the child and warns the parent conversation that the child only runs its own repository's hooks.
Decisions
Sources and trust (§7.2, §7.3, §8)
-
Relative path base (§7.3, "the declaring directory for ... other explicit sources"): a config-file
hooksentry resolves against the config file's directory, which is canonicalized when the file loads.WARP_HOOK_DIRSand--hook-direntries resolve against the run's working directory (--cwd, else the process directory), the same wayWARP_SKILL_DIRSdoes.~is expanded in config entries andWARP_HOOK_DIRSand left to the shell for--hook-dir. -
Where
WARP_HOOK_DIRSis read from: the variables the run hands its terminal session (the execution's environment variables, managed secrets, and task variables) when they set it, otherwise the process environmentwarpwas started with. Whether the Oz worker exports an environment's variables into thewarpprocess itself is a server fact this branch does not show; reading the session variables first covers the case where the client receives them.WARP_SKILL_DIRSfollows the same process-environment precedent. -
Who is trusted by construction (§8): explicit sources activate without the trust table. That trusts whoever set
WARP_HOOK_DIRS(in the run's session variables or the process environment) or passed--hook-dir, and whoever wrote the config file passed to--config. -
A config file can be committed in a repository, which would make the repository's author the configurer (contract attack list item 1). So a config-file entry that belongs to a git repository activates only when the posture rule would activate that repository's own
.cursor/hooks.json. Otherwise it is not loaded and a diagnostic says why. An entry belongs to a repository when:- the file really is inside one once symbolic links are followed, or the path as written is inside one; or
- the path cannot be resolved, and the path with
..segments removed is inside one, or else inside the deepest detected project root.
Links and
..therefore cannot take a file out of the rule.WARP_HOOK_DIRSand--hook-dirare not checked: they come from whoever started the process, and PR 5 relies onWARP_HOOK_DIRS=<root>/.cursorto force a repository's hooks on. A config entry that names the same file as a hook directory is covered by the hook directory. An entry that passes the rule is still loaded as a run-delivered source, so it ranks above the repository's own.cursor/hooks.jsonin merge order and applies at every working directory, not only under that root. Hooks can only tighten, so this cannot loosen anything. Also inquestions.md. -
Missing-source rule (§8, "a missing required source blocks launch"): every explicit source is required under the strict policy. A missing, unreadable, invalid, or held-back source then fails the run before the first request. Without strict it is a diagnostic and the run continues. Also in
questions.md. -
Hooks are not active in the GUI or TUI (§10): only the agent SDK path loads config files and hook directories, and only
AgentDriverrecords a run. A process with no recorded run keeps PR 5's surface rule (Gui/Tuifor interactive processes), where project files stay inert. -
Hook setup is Oz-harness only, like skill loading, because third-party harnesses run their own tools and hooks (§2 non-goals).
warp agent run --harness <other>rejects the hook flags and the config keys with a message saying so, rather than silently ignoring them.
Surfaces, pinning, and the run (§4.2, §8, §9.2, §10)
-
Surface rule (§4.2):
OzRunwhen--task-idwas passed (the server dispatched the run),CliRunotherwise. The contract's rule ("OzRunwhentask_idis set or the mode is autonomous") would label every local runOzRun:warp agent runcreates a server task of its own, and the command-line mode is always autonomous. A task resumed locally withwarp agent run --task-idis thereforeOzRun(contract attack list item 8). Either way the run is headless; the envelope'ssurfaceis what changes. -
Pin moment (§8, §10 item 8):
execute_runpins the hook set before it submits anything. At that point:- the explicit sources have loaded;
- environment preparation has registered every cloned repository:
prepare_workspaceawaits eachregister_cloned_repo,DetectedRepositoriesemits its event before the detection future resolves, and the watcher reads the hook file synchronously when a root is added; - the driver has explicitly waited for detection of the working directory's repository, so a
warp agent runstarted in a repository finds that repository's hook file before pinning.
A repository cloned later by the agent (a deferred repository) is found after pinning. The registry refuses it, as PR 5 decided, and a
hook_diagnosticrecord in the run's output says so (contract attack list item 3). PR 5's first-AppendedExchangepin is still in place; whichever comes first wins, and here that is alwaysexecute_run. -
What is installed when (§3): run identity, strict policy, ask behavior, and the messenger are set on
HookRuntimejust before explicit sources load, so load failures go through the messenger. Diagnostics for the repositories' own hook files (malformed, or inert under the posture rule) are written when the run pins. -
session.start(§9.2):execute_rundoes not fire it. The test isexecute_run_does_not_fire_session_start. -
Fail the run (§6.5): a hook's
askunderfail-runfails the conversation throughBlocklistAIController::fail_conversation_due_to_hook. This shares its body withfail_conversation_due_to_shell_exit: in-flight streams are marked errored and cancelled, pending actions are cancelled, and the status becomesError. It uses a newCancellationReason::HookEndedRun, which, like a shell exit, is finalized outside the cancellation path, so the status is not overwritten withCancelled. The error isRenderableAIError::other(message, true), so the task is FAILED withInvalidRequest. Everything after that is the normal conversation-error path, the same as any other failure (contract attack list item 4): the driver's status subscription ends the run,--idle-on-failis honored, the snapshot uploads, andLocalAgentTaskSyncModelandreport_driver_errorreport the failure. The denial's event reaches the driver before the action model reports the denied action finished, so the controller'sFinishedActionhandler now sends nothing for a conversation that is alreadyError. Without that, the denied result went out as one more request after the run had failed. An ask in a conversation on another surface (a hidden child) fails the run's own conversation; the child is not stopped directly, but its denied action is already applied and the run's exit ends the process. Also inquestions.md.
Settings and flags (§3 "Activation controls", §6.4, §6.5)
- Flags added to
warp agent run, all hidden, and rejected as unexpected arguments whenFeatureFlag::AgentHooksis off:--hooks-strictturns the strict policy on. It is a plain switch, so it can turn strict on over a file'shooks_strict: falsebut cannot turn it off overhooks_strict: true: the command line can only make a run stricter.--hooks-on-ask <deny|fail-run>replaces the file'shooks_on_ask.--hook-dir <PATH>can be repeated. Its entries are added after theWARP_HOOK_DIRSentries, not in place of them (contract attack list item 9). Skills do the opposite (server-sent factory skill directories replaceWARP_SKILL_DIRS), but explicit hook sources all apply together (§7.4), and replacing would let a flag silently drop a hook directory the environment set.--hook-direxists mainly for the manual test in PR 9.
- Hook settings live in a client-only
HookRunOptionsonAgentDriverOptions, not inAgentConfigSnapshot. It holds the run's hook directories (hook_dirs: the session'sWARP_HOOK_DIRSentries, then--hook-direntries), the configuration file's sources, and the strict, on-ask, and run surface choices.agent_setup::setup_hooks_for_runloads them afterprepare_workspaceand beforeexecute_runpins. The snapshot is sent to the server:initialize_new_taskpasses it tocreate_agent_taskon every local run, and scheduled agents store it. New fields there would have uploaded the user's local hook directory paths, plus the strict and on-ask choices, as part of the task configuration on everywarp agent runwith hooks. The server could not use them, because cloud workers pass no--config(factory hooks proposal §2). - Config keys outside
agent run:run-cloud,schedule, andintegrationload the same config file throughload_config_file, which rejectshooks,hooks_strict, andhooks_on_askwith a message saying they apply only toagent run. These commands already rejected the keys as unknown, so their behavior does not change, but hook settings never silently disappear. With the feature flag off,agent runrejects them too. An older CLI rejects a config file that uses the new keys, like every other new key (deny_unknown_fields; contract attack list item 2). - No per-run timeout override was added (contract attack list item 10). Under strict, a hook that times out (30 s default before an operation) denies the operation. A run-level timeout setting is a follow-up if slow cloud filesystems make that a problem.
Output records (§3 "Notices")
- JSON and NDJSON:
{"type":"hook_notice","kind":"denied|warning|info","conversation_id":"…"|null,"local_conversation_id":"…","action_id":"…"|null,"text":"…"}and{"type":"hook_diagnostic","source":"…","severity":"error|warning|info","message":"…"}. These are added to the existingtype-tagged message enum indriver/output.rs, so existing records and their tests are unchanged. Inhook_notice,conversation_idis the server conversation token, the same idconversation_startedreports. It is null until the server assigns one (for example, a prompt hook that halts the first prompt). The client's own id is always inlocal_conversation_id. - Text and pretty:
hook: <kind>: <text>andhook: <severity>: <source>: <message>, with the text's lines joined by spaces so each record stays on one line. - Each record is written with one
with_stdout_bufferedcall, which holds the stdout lock for the whole record. Records from the dispatcher's background thread therefore never interleave with the conversation's own records (contract attack list item 5). - Secrets (contract attack list item 6, §12 "Hook payloads are not redacted"): a notice carries the hook's reason, which can echo command text, and a diagnostic carries the source path. Both go to Oz stdout, which the server stores. This is consistent with the existing tool-call records, which already print commands unredacted.
Departures from architecture.md
- §8 "Children": remote children do not receive an explicit source set.
SpawnAgentRequesthas no field for hook sources or environment variables, and the worker starts the child withwarp agent run --task-id, server-chosen variables, and no--config, so nothing the client adds would reach the child. Instead, a strict run refuses to start a remote child when the run has explicit sources (PrepareRemoteChildLaunchError::HookSourcesNotForwarded), and any other run starts the child and sends the parent conversation a warning notice. The parent's strict and on-ask settings do not reach the child either. The child still runs its own checkout's project hooks under the posture rule. Proposed server path: a hook-source field onSpawnAgentRequest, stored onAmbientAgentConfigSnapshotand delivered throughExecutionConfiguration, which is the same route the factory hooks proposal (§3 Option A2, §4) recommends for factory sources. Also inquestions.md(contract attack list item 7: under strict every remote child fails to launch, with a message naming the sources). - §7.2 and §8 call every explicit source approved by construction. A config-file entry inside a git repository is held to the posture rule instead (see Decisions).
- §3 "Notices" names
AgentDriver::write_outputas the Oz implementation. The messenger writes through the same output layer (driver/output.rs:with_stdout_bufferedand thejson/textformatters) without going through the driver. It is called from the dispatcher's background thread, andwrite_outputis a&selfmethod on the driver that formats agent output. - Files outside this PR's ownership that changed:
app/src/ai/agent/mod.rs:CancellationReason::HookEndedRun(a Display arm, and theFinalizedExternallyoutcome).app/src/ai/blocklist/controller.rs:fail_conversation_due_to_shell_exit's body moved into a privatefail_conversation(conversation_id, error, reason), with a newfail_conversation_due_to_hook; the shell-exit behavior is unchanged. TheFinishedActionsubscription returns early for a conversation that is alreadyError(see Decisions).app/src/ai/blocklist/hooks/mod.rs:RunSurface,RunIdentity, andset_run(replacingset_run_metadata);set_delivered_sourcesanddelivered_sources;command_execution_postureexposed;surfacemadepub(crate); PR 1's#[allow(dead_code)]markers removed now that the setters have callers; project-file diagnostics that appear after the pin are forwarded to the messenger.app/src/ai/blocklist/hooks/envelope.rsandenvelope_tests.rs:surfacetakes the run surface; thehook_ended_runcancellation name.app/src/ai/agent_sdk/driver/output.rs: the two record kinds. The contract namedagent_sdk/output.rsandoutput/, but the run's records live here.app/src/ai/agent_sdk/driver/error_classification.rs: theHookSourcesUnavailablearm.app/src/ai/agent_sdk/mod_tests.rs: the newAgentDriverOptionsfield in the test helper.
- Not changed, although the contract allowed it:
AgentConfigSnapshot(see Decisions) andapp/src/ai/blocklist/child_agent_launch.rs(local children are Phase 1.5 and share the parent's runtime, so they already see its strict and on-ask settings). - No amendments to PR 1's shared types.
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
cargo nextest run -p warp_cli -E 'test(hook_flags)': passed, 2 tests.cargo nextest run -p warp_cli: passed, 290 tests.WARP_LOCAL_SKIP_METAL=1 cargo check -p warp: passed.WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp --no-run: passed.WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp --no-fail-fast -E 'test(hooks) | test(hook) | test(config_file) | test(agent_setup) | test(output) | test(remote_child) | test(envelope)': passed, 357 tests.WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp --no-fail-fast -E 'test(execute_run_does_not_fire_session_start) | test(unanswerable_ask) | test(explicit_sources_load_before) | test(hook_setup_records) | test(warp_skill_dirs) | test(driver_without_a_task) | test(cancellation_reason) | test(shell_exit)': passed, 15 tests (this run includes the existing shell-exit controller test, which still passes after the controller change).WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp --no-fail-fast -E 'test(agent_sdk) | test(controller) | test(orchestration) | test(local_agent_task_sync)': 1429 tests, 1428 passed. The one failure,agent_sdk::driver::cloud_provider::gcp::tests::best_effort_command_is_killed_on_timeout, is a process-timing test in code this PR does not touch; it also fails alone on this machine, and PR 7 recorded the same test as unrelated.- Review round 1:
WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp --no-fail-fast -E 'test(hooks) | test(hook) | test(config_file) | test(agent_setup) | test(output) | test(remote_child) | test(envelope) | test(agent_sdk) | test(controller) | test(orchestration) | test(local_agent_task_sync) | test(shell_exit)': 1664 tests, 1663 passed; the same gcp timing test failed. WARP_LOCAL_SKIP_METAL=1 cargo clippy -p warp --all-targets --tests -- -D warnings: passed (also after review round 1).cargo clippy -p warp_cli --all-targets --tests -- -D warnings: passed../script/format: run last; the formatting commit is on the branch.
Acceptance criteria, by test file:
-
1:
config_file_tests.rs(hook_entries_resolve_against_the_config_files_directory,unknown_hook_format_scope_and_on_ask_are_rejected_with_the_supported_values,supported_keys_list_the_hook_keys). -
2 and 7:
agent_setup_tests.rs, plus theWARP_HOOK_DIRSparsing indriver/hooks_tests.rs. -
3 and 5:
driver_tests.rshook_setup_records_the_run_its_surface_and_its_policies, andenvelope_tests.rsrun_metadata_reaches_the_envelope_and_the_run_surface_decides_oz_or_cli. CLI-over-file precedence is inconfig_file_tests.rscommand_line_hook_settings_win_over_the_file. -
4:
driver/hooks_tests.rs. -
6:
driver_tests.rsa_scripted_ask_in_a_fail_run_run_ends_the_run_with_the_hook_reason. It drives a shell tool call through the action model, a scriptedaskthrough the runner, the denial, and the driver's failure path, and checks that no second request goes out; it failed with two exchanges before theFinishedActionchange. The default deny-and-continue half is PR 6'spre_tool_tests.rs. -
8:
driver_tests.rsexplicit_sources_load_before_the_run_pins_and_later_ones_cannot_join, plusa_repository_found_after_the_run_pins_is_reported_in_the_run_outputfor the late-clone diagnostic. -
9:
remote_child_tests.rs(no hook field in the payload, the strict refusal, and the warning). -
10:
driver_tests.rsexecute_run_does_not_fire_session_start. -
I have manually tested my changes locally with
./script/run
Manual testing is deferred to the PR 9 instructions; the feature flag is off.
Agent Mode
- Warp Agent Mode - This PR was created via Warp's AI Agent Mode
CHANGELOG-NONE
Activity
13491e3e6This 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.