Description
Summary
Fourth PR of the agent hooks Phase 1 stack. It turns PR 2's minimal runner into the production CommandHookRunner: the invocation deadline and the cancel flag end the hook's whole process tree with a stop request, a grace period, and then a kill, on Unix through the process group and on Windows through a per-child Job Object added to the command crate; output is bounded; at most four hooks run at once; the environment a hook receives follows a written policy; and every invocation writes one log line. It also closes PR 2's deferred review finding: a descendant that outlived the hook and still held its pipes is now stopped.
crates/command:Command::new_in_jobandspawn_in_job, which return aJobGuardnext to the child. On Unix the guard addresses the child's process group; on Windows the child is created withoutCREATE_BREAKAWAY_FROM_JOBand assigned to a fresh kill-on-close Job Object (windows::ProcessJob). The guard can ask the tree to stop (SIGTERMto the group; on Windows the job is ended), end it outright, and report whether anything in it still exists; dropping it ends the tree. The existingJobObjectbuilder is untouched.crates/ai/src/hooks/runner/command.rs: the runner. A hook waits for one ofHOOK_MAX_CONCURRENT(4) slots, with the wait counted against its deadline; it is spawned with/bin/sh -corcmd.exe /S /Cin a job; stdin is written and closed; stdout is kept up toHOOK_STDOUT_LIMIT(1 MiB) and the rest read and dropped withstdout_truncatedset; stderr keeps its lastHOOK_STDERR_TAIL(4 KiB) with control characters escaped. When the hook exits, times out, or is cancelled, whatever is left of its tree is asked to stop, givenHOOK_KILL_GRACE(2 s), then killed. Onelog::info!line per invocation.crates/ai/src/hooks/runner/env.rs: the environment policy in two layers. The runner supplies the host layer (its own process environment under the scrub list below,HOME, the captured login shellPATH,WARP_HOOK_PROTOCOL_VERSION); the dispatcher supplies the per-invocation layer throughinvocation_environment(&HookIdentity, &session_env)(the hook-session variables, then the protected identity variables and the format's aliases, written last).build_environmentis the whole policy in one call for callers and tests that have every input.crates/ai/src/hooks/runner/mod.rs:working_directory_for(source, project_root).- Amendment to PR 1:
HookProcessOutcome::stdout_truncatedandHOOK_TIMEOUT_CEILING(600 s); PR 3's Cursor adapter treats a cut-off stdout as malformed output.
Environment policy
Everything from here to the end of "What is not scrubbed" can be copied into architecture.md §9.1. It is what a hook process sees, in the order it is built.
- Warp's own process environment, minus the scrub list and the protected names (below).
HOMEset to the user's home directory (dirs::home_dir();USERPROFILE-based on Windows). When that is unknown an inheritedHOMEis left alone.PATHset to the captured login shellPATHwhen the application has one; otherwise the inheritedPATHstays.- The hook-session environment (from
session.starthooks). An entry with a protected name is dropped with a warning; an entry whose name is empty or contains=or NUL, or whose value contains NUL, is dropped with a warning. Session entries may change anything else, includingPATHandHOME. - The protected identity variables, always written last:
WARP_PROJECT_DIR,WARP_VERSION,WARP_HOOK_PROTOCOL_VERSION(1),WARP_RUN_ID(when the run has an id),WARP_USER_EMAIL(when known),WARP_CODE_REMOTE("true"when the run is remote; absent otherwise). - For a Cursor-format source, the aliases, chosen by format and not by where the file lives:
CURSOR_PROJECT_DIR,CURSOR_VERSION(Warp's version),CURSOR_USER_EMAIL(when known),CURSOR_CODE_REMOTE(when remote),CLAUDE_PROJECT_DIR.CURSOR_TRANSCRIPT_PATHis never set.
Scrub list (removed from the host environment, exact names): WARP_API_KEY, WARP_WORKLOAD_TOKEN, WARP_CLOUD_AGENT_OTLP_TOKEN, WARP_CLOUD_AGENT_OTLP_TOKEN_EXPIRES_AT, WARP_IS_LOCAL_SHELL_SESSION, WARP_HONOR_PS1, WARP_USE_SSH_WRAPPER, WARP_SSH_REUSE_CONTROL_MASTER, WARP_SHELL_DEBUG_MODE, WARP_PROMPT_NODE_VERSION_ENABLED, WARP_PATH_APPEND, WARP_INITIAL_WORKING_DIR, WARP_INITIAL_HISTFILESIZE, WARP_INITIAL_HISTSIZE, WARP_TERMINAL_SESSION_UUID, WARP_FOCUS_URL, WARP_CLIENT_VERSION, WARP_CLI_AGENT_PROTOCOL_VERSION, SSH_SOCKET_DIR; and every name starting with WARP_METADATA_.
Protected names (removed from the host environment, refused from the session environment, written only by Warp): WARP_PROJECT_DIR, WARP_VERSION, WARP_HOOK_PROTOCOL_VERSION, WARP_RUN_ID, WARP_USER_EMAIL, WARP_CODE_REMOTE, CURSOR_PROJECT_DIR, CURSOR_VERSION, CURSOR_USER_EMAIL, CURSOR_CODE_REMOTE, CURSOR_TRANSCRIPT_PATH, CLAUDE_PROJECT_DIR.
What is not scrubbed: run identity a worker set on Warp's process (OZ_RUN_ID, OZ_CLI, WARP_SERVER_ROOT_URL), NSC_TOKEN_FILE (a path to a file the hook could read regardless), and any third-party credential a worker put in Warp's process environment (for example ANTHROPIC_API_KEY and OPENAI_API_KEY, which Warp also reads from its own environment to detect a worker-injected key; they are passed through on purpose). The run's managed secrets and cloud-provider variables go to the agent's terminal session, not to Warp's process, so they do not reach hooks through this path.
How the scrub list was chosen
The four credential names are every Warp-owned credential Warp reads from its own environment, from a search of env::var calls and *_ENV constants across app/src and crates: the CLI API key (crates/warp_cli/src/lib.rs), the workload token a cloud run sends to fetch its managed secrets and git-host credentials (crates/isolation_platform/src/lib.rs, app/src/ai/agent_sdk/driver/git_credentials.rs), the tracing token and its expiry (app/src/tracing/cloud_agent_auth.rs, which removes the token only when tracing starts), and the WARP_METADATA_* names a worker injects. A Warp-owned credential Warp learns to read later belongs on the list, and env_tests.rs names each one so a missing entry fails a test.
Decisions
Process tree and the kill sequence
- Why the whole tree ends after a normal exit too (§9.1, PR 2 review finding S3): PR 2 left a descendant that outlived the hook running and gave the pipes one second to drain. Now
end_treeruns on every path. After an exit it checks whether anything in the tree is left; when nothing is, it returns at once (the common case adds no delay). When something is, it sends the stop request, keeps reading the pipes while polling the tree every 50 ms, and kills after the grace.sleep 60 &followed byexit 2is therefore reported as exit 2 with its output within a few tens of milliseconds, and thesleepis gone. A hook gets no background work on either platform; on Unixsetsidis the escape the architecture accepts. Also recorded inquestions.md. - Signalling the group after the leader was collected (attack list item 1): PR 2 only signalled the group while the hook itself was alive, to avoid a recycled pid. POSIX keeps a process group id from being reused while any member of the group exists, so
killpgon the recorded id is safe while descendants remain: it reaches them or fails withESRCH. The moment the last member is gone the id is reusable, soJobGuardremembers that answer: onceis_alive()(or a signal) finds the group gone,request_stop,terminate, and the guard's drop send nothing more, and the drop checks liveness before terminating. What remains is the check-then-signal window PR 2 already accepted on the timeout path (review round 1, S1). OnlyESRCHmeans the group is gone;EPERM(the group holds a process this user cannot signal) still counts as a live tree, so such a tree is stopped, waited for, and killed rather than skipped (style review). - The stop request is
SIGTERMto the process group on Unix. On Windows there is no catchable signal for a console child; "request stop" ends the job outright and the grace period only waits for the processes to be collected. The Unix tests cover both a tree that stops when asked (no grace spent) and one that ignoresTERM(trap '' TERM; sleep 60 &), which is killed after the grace. - A dropped dispatch future ends the tree, not only the direct child:
JobGuard's drop kills the process group (Unix) or terminates and closes the job (Windows), on top ofkill_on_drop(true). - A hook found already exited when the deadline or cancellation arrives is reported by its exit status (PR 2's rule); its leftover tree is still ended.
- Collection: after a kill the runner waits up to 2 s (
COLLECT_WAIT) for the child to be collected so a pid check afterwards sees it gone, then up to 1 s (PIPE_CLOSE_WAIT) for the pipes to close, in case a writer that left the tree still holds them. Neither is a grace period; onlyHOOK_KILL_GRACEgives the tree a chance before a harsher step. - Output after a stop request is discarded (contract: late output after cancellation is discarded): on
TimedOutandCancelledthe outcome'sstdoutis empty andstdout_truncatedis false, whatever the hook printed from aTERMtrap; the stderr tail is kept for the log line. The pipes are still read while the tree stops, so a stopping child never blocks on a full pipe (review round 1, S4). - Zombies and the grace period (review round 1, N4):
is_alive()counts a finished-but-unreaped process, becausekillpg(pgid, 0)does. On a desktop or an image with a reaping init, an orphan a hook leaves is reaped at once and the tree reads as ended as soon as it stops. If Warp itself is PID 1 in a container without a reaping init, an orphan that received the stop request stays a zombie, the tree never reads as ended, and such an invocation pays the full 2 s grace before the kill (which changes nothing) and returns; the hook's outcome is unaffected. This is accepted here: the fix is aPR_SET_CHILD_SUBREAPERcall or a reaping init in the Oz runtime's startup, which is outside this PR's files, and the Oz agent image's PID 1 was not checked from this machine. - Windows nested jobs (§9.1): the child is not created with
CREATE_BREAKAWAY_FROM_JOB, so it stays in Warp's own job and is assigned to the fresh one as a nested job (Windows 8 and later). A child that cannot be assigned is killed and reported asSpawnFailed, so no hook runs outside a job. The assignment happens right after spawn; a grandchild started in the microseconds before it would not be in the job, which is accepted (there is no suspended-spawn inasync-process). Also recorded inquestions.md. - The
commandcrate's Windows code was written against thewindows0.62 signatures in the registry and was not compiled:rustup target list --installedshows onlyaarch64-apple-darwinandwasm32-unknown-unknown, and no target was installed. The crate's manifest enables thewindowsfeatures its own calls need (Win32_Foundation,Win32_SecurityforCreateJobObjectW,Win32_System_JobObjects,Win32_System_Threading), socargo check -p commandon a Windows machine does not depend on feature unification withapp(review round 1, S2).cargo check -p commandand the Unix job tests pass on the host; the Windows tests (async_tests.rswindowsmodule: termination ends a PowerShell pluspingtree and the grandchild is gone; closing the job handle alone, without an explicit terminate, ends the grandchild;command_tests.rswindowsmodule:.cmdscripts,&and|in a command string, a script in a directory with a space, byte-exact Unicode stdin through PowerShell, a timeout and a finished hook both leaving no grandchild, checked withtasklist) run first in CI. cmd.exekeeps PR 2's shell choice, now invoked ascmd.exe /S /C "<command>". The command text is appended withraw_arginstead of as an encoded argument, becausecmd.exedoes not follow the quoting rulesCreateProcessarguments are encoded with and an author's"would otherwise reach it as\";/Swith one outer pair of quotes makescmd.exestrip exactly that pair, so a command that starts with a quoted path and has more quotes after it ("C:\Program Files\hooks\check.cmd" "arg") runs as written instead of losing its first and last quote (review round 1, N2). Windows tests cover&and|, and a script in a directory with a space called with a quoted argument.
Output
- Stdout over the limit keeps being read and dropped (attack list item 3), so the hook can exit on its own instead of blocking on a full pipe; the outcome carries the first 1 MiB and
stdout_truncated. The adapter reads a cut-off response as malformed output even when the kept prefix happens to parse (PR 3 follow-through, tested for every Phase 1 event). - Stderr escaping: every control character other than newline and tab, plus DEL, is written as
\xNNinHookProcessOutcome::stderr_tail, so the tail is safe to log or show; escaping can lengthen the tail, so it is cut from the front to 4 KiB afterwards. Stdout is never escaped. The caret form was not used because\x1bis unambiguous next to ordinary text. - The log line is
hook invocation: event=<vendor event> source=<source> binding=<index> exit=<ProcessExit> duration_ms=<n> stderr_tail=<Debug of the escaped tail, at most 500 bytes>. The Debug form turns newlines into\n, so the record is one line (attack list item 9). The payload and stdout are never logged; a test installs a capturing logger and checks the fields, the single line, and the absence of the payload. - Where the one line lives (§3 Observability): PR 1 put it in the dispatcher and PR 6's contract moves it to the runner and gives the dispatcher a per-dispatch summary instead. This PR writes it in the runner. Until PR 6 lands, both lines appear for each invocation; the runner's names the vendor event (the invocation does not carry the boundary) and the dispatcher's names the boundary.
Concurrency
- Slots are a token channel (
async-channel, already a dependency) rather than a new semaphore dependency: a permit is a token taken from a bounded channel and returned on drop. A waiter whoserecvis dropped holds nothing (attack list item 4). - Waiting for a slot counts against the deadline: the deadline is fixed when the invocation starts, and a hook that gets a slot late runs with what is left. A waiter whose deadline passes is reported
TimedOutwithout having spawned, and a cancelled waiterCancelled; both are logged as "did not start … while waiting for one of the 4 hook slots" so they can be told apart from a hook that ran slowly (PR 6 attack list item 7). The cancel flag is checked again once the slot is held, before anything is spawned (review round 1, N5). Tests cover both, that six hooks never have more than four running, and that a hook which waited about a second with a 1.5 s deadline is stopped before 2.2 s (it would run to about 2.5 s if the deadline were taken after the wait; review round 1, S3).
Environment
- Two layers instead of one call (§9.1): the runner knows its process environment, the captured
PATH, and the home directory; the dispatcher knows the source's format, the project directory, the run, the user, and the session environment.HookInvocation.envis the dispatcher's layer, whichinvocation_environmentbuilds; the runner spawns withenv_clear()followed byhost_environmentand then that layer. PR 6 fillsHookInvocation.envfrominvocation_environment(&HookIdentity { .. }, &session_env), where the session variables are a separate input rather than a field of the identity (style review); until then hooks get the host layer and the protocol version, which is what PR 2's tests expect.build_environment(EnvironmentInput { runner, session_env, hook }), whose fields are in the order they are applied, composes both and is what the pure tests exercise; a test checks the composition equals the layers applied in order. EnvironmentInputcarries no source kind: nothing in the policy depends on it, since aliases follow the format.- The scrub list was read from what Warp exports to the shells it starts (
crates/warp_terminal/src/local_tty/unix.rsbuild_host_shell_command,windows/environment.rs,focus_env.rs), what the CLI reads as a credential (crates/warp_cli/src/lib.rsWARP_API_KEY), and what a worker injects (WARP_METADATA_*inapp/src/ai/agent_sdk/driver.rsbuild_secret_env_vars).TerminalDriverandLocalCommandExecutorset variables on the agent's terminal session (task_env_vars, managed secrets, cloud-provider variables), not on Warp's process, so they are not in the hook's inherited environment and are not in the list (attack list item 5). Also recorded inquestions.md. - Protected names are never inherited (attack list item 5, §9.1 "hook-session variables cannot replace these"): a
WARP_RUN_IDfrom an enclosing run, or aCURSOR_TRANSCRIPT_PATHfrom a real Cursor, is removed; Warp writes its own value or nothing.WARP_HOOK_PROTOCOL_VERSIONis written in both layers (the host layer so a caller with an emptyHookInvocation.envstill gets it, and the invocation layer with the other protected names), and the runner writes it once more after the invocation's variables, so not even a hand-builtHookInvocation.envcan replace it (review round 1, N1; style review 2). HOMEandPATHon Windows (attack list item 6):HOMEcomes fromdirs::home_dir(), which readsUSERPROFILEthere; thePATHcapture isNoneon Windows and the inheritedPATHstays, as in PR 2. Variable names compare case-insensitively on Windows soPathis replaced rather than shadowed.- Session variables that the operating system would reject (empty name,
=or NUL in the name, NUL in the value) are dropped with a warning instead of failing the spawn.
Working directory
working_directory_forreturnsOption<PathBuf>rather thanPathBuf(§7.3, §9.1): a named source with no file and no project root has no directory, and the runner must never fall back to Warp's own working directory (PR 2 attack list item 7), so the registry (PR 5) refuses to activate such a binding instead of guessing. A project file with no root given runs in the parent of its.cursordirectory.- The runner does not rewrite a relative command; the shell resolves it against the working directory (tested with
./hook.sh).
Shared types and constants
HOOK_TIMEOUT_CEILING(600 s) lives inmodel.rsnext to the default timeouts, since everything inmodel.rsis shared and the registry (PR 5) applies it. Only the constant is defined here.- The runner cannot refuse a remote execution host, which the contract lists as out of scope:
HookInvocationcarries no host, andHookExecutionHosthas onlyLocal. The registry already filters bindings by host, so no invocation for another host can be built today; when a remote variant exists the dispatcher must not build one.
Departures from architecture.md
- Amendment to PR 1 (
17c9e32b1):HookProcessOutcomeincrates/ai/src/hooks/runner/mod.rsgainedstdout_truncated: bool, andcrates/ai/src/hooks/model.rsgainedHOOK_TIMEOUT_CEILING. PR 2's branch got the matching field in its own runner and decode tests (1eb3e0126); PR 3's branch reads a cut-off stdout as malformed output inadapters/cursor/decode.rsand sets the field in its test helpers (32d7f2b21). - §3 Observability: the one line per invocation is written by the runner (per PR 6's contract) while PR 1's dispatcher line still exists, so two lines appear per invocation until PR 6 replaces the dispatcher's with a per-dispatch summary.
- §9.1 says timeout kills the whole tree; this PR also ends the tree after a normal exit, so a hook cannot leave background work. See Decisions and
questions.md. - §9.1 "
cmd.exeversus PowerShell command-string execution and Unicode stdin still need tests": the tests exist behindcfg(windows)and were not run here (no Windows target installed). - Files outside this PR's ownership that changed:
crates/command/Cargo.toml(thewindowsdependency gains theWin32_Foundation,Win32_System_JobObjects, andWin32_System_Threadingfeatures the job code needs;Cargo.lockis unchanged). The PR 1 amendment and the PR 2 and PR 3 follow-throughs are on their own branches.
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. The warp commands 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 --tests— passedcargo nextest run -p ai --features local_fs,test-util -E 'test(runner)' --no-fail-fast— passed (47 tests; also passed in a full parallel run at load average 7 to 8 after the process-tree tests were made load-proof, see below)cargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)' --no-fail-fast— passed (178 tests, including PR 2's slice tests and the app-independent e2e tests against the new runner)cargo nextest run -p command— passed (17 tests)cargo clippy -p ai --features local_fs,test-util --all-targets --tests -- -D warnings— passedcargo clippy -p ai --all-targets --tests -- -D warnings— passedcargo clippy -p command --all-targets --tests -- -D warnings— passedcargo check -p warp— passedcargo nextest run -p warp -E 'test(hooks)' --no-fail-fast— passed (51 tests, including PR 2's real-runner deny throughtry_to_execute_action)./script/format— ran last;./script/format --checkis clean ondavid/hooks/01-scaffold,02-vertical-slice, and03-cursor-adapterafter the restack- On PR 1's branch after the amendment:
cargo check -p ai --features local_fs,test-utilandcargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)'— passed (36 tests). On PR 2's branch:cargo check -p ai --features local_fs,test-util --tests— passed. On PR 3's branch:cargo nextest run -p ai --features local_fs,test-util -E 'test(cursor::decode)'(24 tests) and-E 'test(hooks)'(152 tests) — passed. - Windows: not compiled or run here;
rustup target list --installedhas no Windows target and none was installed.
New tests, by module:
-
command(async_tests.rs): on Unix,terminateends the child and a sleeping grandchild;request_stopis a catchableSIGTERM; dropping the guard ends the tree; a tree that has ended reports not alive and later stop and terminate calls send nothing; a group found gone by a signal is remembered as ended; onlyESRCHmeans a group is gone (EPERMand other errors do not). On Windows (not run here): termination empties the job of PowerShell andpingand the grandchild is gone; closing the job handle without an explicit terminate ends the grandchild. -
runner/command: stdin delivery and stdout capture; exit code and stderr; the stderr tail of a 64 KiB output; 2 MiB of stdout cut to 1 MiB withstdout_truncatedand no hang; exactly 1 MiB kept whole; a signalled process; working directory, capturedPATH,HOME, protocol version, and an invocation variable; inheritedPATHandHOMEwhen the runner has none; the dispatcher's identity layer with Cursor aliases; the command string as written; a timeout stops the tree without spending the grace; a tree that ignoresTERMis killed after the grace; cancellation stops the tree; a pre-set flag spawns nothing; missing shell and missing working directory; a stderr flood before reading stdin; a descendant holding the pipes is stopped and the exit still decides; a hook that never reads stdin; control characters escaped in the outcome; the escaping function; six hooks never run more than four at once and all complete; a waiter whose deadline passes never ran; a hook that waited runs with what is left of its deadline (and would fail if the wait were added on top); a cancelled hook's late stdout is discarded while its stderr tail is kept; a raw invocation entry cannot replaceWARP_HOOK_PROTOCOL_VERSION; one log line with the named fields and no payload. Windows (not run here):.cmdstdout and exit codes,&and|as written, a script in a directory with a space with a quoted argument, byte-exact Unicode stdin, a timeout and a finished hook both leaving nopinggrandchild behind. -
runner/env: identity variables and aliases; optional identity only when known; the scrub list and prefix with other variables kept, with each credential named one by one so a missing one fails; inherited protected names replaced or removed;PATHandHOMEfrom the runner or inherited; session variables applied but refused for protected names; unusable session names dropped; every name once; the two layers compose to the whole; every name the policy sets is protected; and one subprocess test that setsWARP_API_KEY,WARP_WORKLOAD_TOKEN,WARP_CLOUD_AGENT_OTLP_TOKEN,WARP_METADATA_SECRET_TEST,WARP_IS_LOCAL_SHELL_SESSION, andWARP_RUN_IDon the test process and checks a hook'senvdump. -
runner(mod_test.rs):working_directory_forfor project files with and without a root, explicit sources inside a project, and named sources. -
Load robustness of the process-tree tests (style review 2): under heavy load a shell has been seen to take more than ten seconds to start, so the tests that need the script to be running (cancellation, the grace period, the pipe-holding descendant) trigger the stop once the script has written its pids and measure from that moment; the one deadline-driven test uses an 8 s deadline and checks descendants only when the script had started by then (the shared stop, grace, kill sequence is covered by the readiness-gated tests); every wall-clock bound carries a 5 s margin and the slot tests use longer sleeps so their bounds still tell the two behaviors apart. nextest runs each test in its own process, so an in-binary mutex could not serialize them; margins and readiness do the work instead.
-
adapters/cursor/decode(PR 3's branch): a cut-off stdout whose prefix parses is still malformed on every permission event and follows the failure policy on observers. -
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 activates in production yet.
Agent Mode
- Warp Agent Mode - This PR was created via Warp's AI Agent Mode
CHANGELOG-NONE
Activity
9This 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.