#16602

hooks: deliver hook sources and settings to Oz and CLI runs

Draftvorporealwants to merge intofrom

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_id and task_id are the task id, parent_run_id is the parent run), with its surface: OzRun when the server dispatched the run (--task-id was passed), CliRun otherwise. The surface now travels with the run (RunIdentity { metadata, surface }) instead of being inferred from whether run metadata exists, because a local warp agent run creates a task too.
  • Explicit sources. WARP_HOOK_DIRS, the new hidden --hook-dir flag, and a new hooks key in the warp agent run config file become explicit sources. They are loaded once, after environment preparation and skill loading and before the first request, through PR 5's load_explicit_sources. hooks entries take path, optional format (only cursor), and optional scope (run, factory_agent, factory_wide). They are validated when the file loads, and relative paths resolve against the config file's directory.
  • Policies. --hooks-strict and hooks_strict turn on PR 6's strict hook policy. --hooks-on-ask <deny|fail-run> and hooks_on_ask: deny | fail_run choose PR 6's HeadlessAskBehavior. 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: a hook_notice or hook_diagnostic JSON record in json/ndjson output, or one hook: line in text/pretty output. 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 in execute_run before any request goes out. An Oz run's first prompt is resolved on the server, so no prompt.submit marks the start of the run on the client. execute_run does not fire session.start; PR 7's barrier in prepare_turn owns 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's ask still 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 hooks entry resolves against the config file's directory, which is canonicalized when the file loads. WARP_HOOK_DIRS and --hook-dir entries resolve against the run's working directory (--cwd, else the process directory), the same way WARP_SKILL_DIRS does. ~ is expanded in config entries and WARP_HOOK_DIRS and left to the shell for --hook-dir.

  • Where WARP_HOOK_DIRS is 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 environment warp was started with. Whether the Oz worker exports an environment's variables into the warp process 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_DIRS follows 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_DIRS and --hook-dir are not checked: they come from whoever started the process, and PR 5 relies on WARP_HOOK_DIRS=<root>/.cursor to 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.json in merge order and applies at every working directory, not only under that root. Hooks can only tighten, so this cannot loosen anything. Also in questions.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 AgentDriver records a run. A process with no recorded run keeps PR 5's surface rule (Gui/Tui for 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): OzRun when --task-id was passed (the server dispatched the run), CliRun otherwise. The contract's rule ("OzRun when task_id is set or the mode is autonomous") would label every local run OzRun: warp agent run creates a server task of its own, and the command-line mode is always autonomous. A task resumed locally with warp agent run --task-id is therefore OzRun (contract attack list item 8). Either way the run is headless; the envelope's surface is what changes.

  • Pin moment (§8, §10 item 8): execute_run pins the hook set before it submits anything. At that point:

    • the explicit sources have loaded;
    • environment preparation has registered every cloned repository: prepare_workspace awaits each register_cloned_repo, DetectedRepositories emits 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 run started 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_diagnostic record in the run's output says so (contract attack list item 3). PR 5's first-AppendedExchange pin is still in place; whichever comes first wins, and here that is always execute_run.

  • What is installed when (§3): run identity, strict policy, ask behavior, and the messenger are set on HookRuntime just 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_run does not fire it. The test is execute_run_does_not_fire_session_start.

  • Fail the run (§6.5): a hook's ask under fail-run fails the conversation through BlocklistAIController::fail_conversation_due_to_hook. This shares its body with fail_conversation_due_to_shell_exit: in-flight streams are marked errored and cancelled, pending actions are cancelled, and the status becomes Error. It uses a new CancellationReason::HookEndedRun, which, like a shell exit, is finalized outside the cancellation path, so the status is not overwritten with Cancelled. The error is RenderableAIError::other(message, true), so the task is FAILED with InvalidRequest. 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-fail is honored, the snapshot uploads, and LocalAgentTaskSyncModel and report_driver_error report the failure. The denial's event reaches the driver before the action model reports the denied action finished, so the controller's FinishedAction handler now sends nothing for a conversation that is already Error. 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 in questions.md.

Settings and flags (§3 "Activation controls", §6.4, §6.5)

  • Flags added to warp agent run, all hidden, and rejected as unexpected arguments when FeatureFlag::AgentHooks is off:
    • --hooks-strict turns the strict policy on. It is a plain switch, so it can turn strict on over a file's hooks_strict: false but cannot turn it off over hooks_strict: true: the command line can only make a run stricter.
    • --hooks-on-ask <deny|fail-run> replaces the file's hooks_on_ask.
    • --hook-dir <PATH> can be repeated. Its entries are added after the WARP_HOOK_DIRS entries, not in place of them (contract attack list item 9). Skills do the opposite (server-sent factory skill directories replace WARP_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-dir exists mainly for the manual test in PR 9.
  • Hook settings live in a client-only HookRunOptions on AgentDriverOptions, not in AgentConfigSnapshot. It holds the run's hook directories (hook_dirs: the session's WARP_HOOK_DIRS entries, then --hook-dir entries), the configuration file's sources, and the strict, on-ask, and run surface choices. agent_setup::setup_hooks_for_run loads them after prepare_workspace and before execute_run pins. The snapshot is sent to the server: initialize_new_task passes it to create_agent_task on 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 every warp agent run with 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, and integration load the same config file through load_config_file, which rejects hooks, hooks_strict, and hooks_on_ask with a message saying they apply only to agent 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 run rejects 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 existing type-tagged message enum in driver/output.rs, so existing records and their tests are unchanged. In hook_notice, conversation_id is the server conversation token, the same id conversation_started reports. 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 in local_conversation_id.
  • Text and pretty: hook: <kind>: <text> and hook: <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_buffered call, 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. SpawnAgentRequest has no field for hook sources or environment variables, and the worker starts the child with warp 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 on SpawnAgentRequest, stored on AmbientAgentConfigSnapshot and delivered through ExecutionConfiguration, which is the same route the factory hooks proposal (§3 Option A2, §4) recommends for factory sources. Also in questions.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_output as the Oz implementation. The messenger writes through the same output layer (driver/output.rs: with_stdout_buffered and the json/text formatters) without going through the driver. It is called from the dispatcher's background thread, and write_output is a &self method 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 the FinalizedExternally outcome).
    • app/src/ai/blocklist/controller.rs: fail_conversation_due_to_shell_exit's body moved into a private fail_conversation(conversation_id, error, reason), with a new fail_conversation_due_to_hook; the shell-exit behavior is unchanged. The FinishedAction subscription returns early for a conversation that is already Error (see Decisions).
    • app/src/ai/blocklist/hooks/mod.rs: RunSurface, RunIdentity, and set_run (replacing set_run_metadata); set_delivered_sources and delivered_sources; command_execution_posture exposed; surface made pub(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.rs and envelope_tests.rs: surface takes the run surface; the hook_ended_run cancellation name.
    • app/src/ai/agent_sdk/driver/output.rs: the two record kinds. The contract named agent_sdk/output.rs and output/, but the run's records live here.
    • app/src/ai/agent_sdk/driver/error_classification.rs: the HookSourcesUnavailable arm.
    • app/src/ai/agent_sdk/mod_tests.rs: the new AgentDriverOptions field in the test helper.
  • Not changed, although the contract allowed it: AgentConfigSnapshot (see Decisions) and app/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-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

  • 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 the WARP_HOOK_DIRS parsing in driver/hooks_tests.rs.

  • 3 and 5: driver_tests.rs hook_setup_records_the_run_its_surface_and_its_policies, and envelope_tests.rs run_metadata_reaches_the_envelope_and_the_run_surface_decides_oz_or_cli. CLI-over-file precedence is in config_file_tests.rs command_line_hook_settings_win_over_the_file.

  • 4: driver/hooks_tests.rs.

  • 6: driver_tests.rs a_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 scripted ask through the runner, the denial, and the driver's failure path, and checks that no second request goes out; it failed with two exchanges before the FinishedAction change. The default deny-and-continue half is PR 6's pre_tool_tests.rs.

  • 8: driver_tests.rs explicit_sources_load_before_the_run_pins_and_later_ones_cannot_join, plus a_repository_found_after_the_run_pins_is_reported_in_the_run_output for the late-clone diagnostic.

  • 9: remote_child_tests.rs (no hook field in the payload, the strict refusal, and the warning).

  • 10: driver_tests.rs execute_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

13

This pull request is a draft

Merging is unavailable until it is marked ready for review.