feat: lifecycle hooks — operator-configured commands at agent lifecycle events - #380
feat: lifecycle hooks — operator-configured commands at agent lifecycle events#380jrideout wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: fb50dbe The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
8072bfe to
556a1d9
Compare
245727f to
8193d95
Compare
…le events Adds an off-by-default hooks feature: a hooks.json file (TRUEFORGE_HOOKS_PATH, default in the platform config dir) declares shell commands that run at user_prompt_submit, pre_tool_use, post_tool_use, and turn_done. Blocking events can veto a prompt (403 with reason) or a tool call (error tool result via the existing ApprovalDecision deny path); observational events run every entry. Core gains a toolSetDecorators capability seam applied to every toolset — including the deferred-tool proxy's underlying servers (hooks see real tool names) and the toolsets Code Mode dispatches against — plus the lifecycleHooks builtin, an IToolSet.unwrapped/unwrapToolSet decorator seam, and an extraCapabilities passthrough on TurnResourceResolver. The server owns spawning, timeouts (the hook's process group is killed and the decision settles per fail_mode even if descendants hold the stdio pipes), independent stdout/stderr caps, and fail-open/fail-closed policy.
8193d95 to
fb50dbe
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fb50dbe. Configure here.
| // even when the command itself exited in time — its stragglers are the | ||
| // ones pinning the hook's stdio. | ||
| killProcessTree(child); | ||
| }, hook.timeout_ms); |
There was a problem hiding this comment.
Timeout race can allow denies
Medium Severity
When a hook exits successfully near its timeout_ms budget, the timeout callback settles from exitResult immediately instead of waiting for the post-exit drain grace. Node can emit exit before all stdout data events, so a deny decision still sitting in the pipe can be read as empty stdout — which is treated as allow and is not covered by fail_mode. killProcessTree then runs right after, which can drop any remaining buffered output.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit fb50dbe. Configure here.


Implements #379 — operator-configured lifecycle hooks.
What
An off-by-default hooks feature: an operator-owned
hooks.jsondeclares shell commands that run at agent lifecycle events. Blocking events can veto a prompt or a tool call; observational events see raw tool results and turn completion.user_prompt_submitpre_tool_usepost_tool_useturn_doneDecision contract per entry: exit 0 + stdout
ApprovalDecision(empty stdout = allow); exit 2 = deny with stderr as reason; any other failure/timeout/unparseable stdout resolves perfail_mode(opendefault,closed). Blocking events run entries sequentially with first-deny-wins; observational events run every entry. Payloads are snake_case JSON on stdin (hook_event_name,session_id,turn_id, plus per-event fields).Why
TrueForge has a human checkpoint (tool approval) but no programmatic one. External policy/security/audit systems need to observe and sometimes veto agent actions without shipping code inside the harness. See #379 for the full motivation.
How
child_process):AgentCapability.toolSetDecorators— a new capability seam applied to every toolset ininitTools()(system meta tools, sandbox, deferred-tool proxy, user MCP servers), so nothing bypasses the hook.lifecycleHooks(runner, events)builtin — anIToolSetdecorator that consults an injectedLifecycleHookRunnerbefore/aftercallTool. A deny returns the same synthesized error tool result a user denial produces (reusingApprovalDecisionend to end — no new decision concepts).TurnResourceResolvergains an optionalextraCapabilitiespassthrough (the seamResolvedAgentDefinitionalready declared).ApprovalDecisionSchemais now exported (the server parses hook stdout with it).src/schemas/hooks.ts— zod schema forhooks.json(strict entries; unknown event keys tolerated with a load-time warning, so a file written for a newer server does not fail startup on an older one).src/hooks/hooksFile.ts— read once at startup; absent = disabled, invalid = fail startup (same posture as the YAML catalogs).src/hooks/CommandHookRunner.ts— spawning, stdin payload, stdout/exit-code decision parsing, timeouts, output caps, fail modes. Constructed per turn so payloads carry session/turn identity.src/apis/turns.ts—user_prompt_submitgate beforesession.createTurn(turn id minting hoisted so hook payloads carry it), hooks capability wired through the resolver,turn_donedispatched fromdrainTurnEventsafter the terminal dual-write.TRUEFORGE_HOOKS_PATH(default: env-paths config dir +hooks.json); a missing file at the default location disables hooks, while a missing file at an explicitly set path fails startup — a typo cannot silently turn policy off. The prefix deviates from the bare-name convention deliberately: external integrators that write the file resolve the same variable from outside this process, where a bareHOOKS_PATHwould be collision-prone.Trust posture
Hook commands are operator-trusted code executing on the harness host — the same trust level as the server process. Nothing an agent, session, or API caller does can add or change hooks. Documented in
docs/key-features/hooks.mdxwith an explicit warning, including hosted-mode semantics (hooks run on the replica that owns the turn).Testing
trueforge-core: unit tests for the decorator (identity preservation, deny-blocks-inner, post sees raw results, approval-pause sentinel skips post, post-only config never consults pre).trueforge: schema tests; loader tests;CommandHookRunnertests running real commands through the spawn path (decision parsing, exit 2, fail modes open/closed for non-zero exit / garbage stdout / timeout, stdin payload round-trip, first-deny-wins short circuit); turns-API tests for the 403 deny, allow pass-through, approval-only-input skip, andturn_donepayload.pnpm format:check && pnpm typecheck && pnpm lint:ci && pnpm build,pnpm test:trueforge,pnpm test:trueforge-core,pnpm test:store:sqlite,pnpm test:frontendall green. (test:trueforge-uifails identically on unmodifiedmainunder Node 26 — environmental; CI's Node 24 is unaffected.)Docs & release
docs/key-features/hooks.mdx+ nav registration + a row in the Harness Capabilities overview;.env.exampleentry; changeset bumping@truefoundry/trueforge-coreand@truefoundry/trueforge(minor).Note
High Risk
Hooks can veto user prompts and every tool path (including Code Mode) and spawn operator-trusted shell commands on the harness host. That is policy/security-critical and changes core toolset wiring.
Overview
Adds operator-owned lifecycle hooks (off by default) so external policy/audit tools can observe or block agent actions without per-agent config. Commands are declared in
hooks.json(TRUEFORGE_HOOKS_PATH); agents and API callers cannot enable them.Blocking events:
user_prompt_submitrejects a turn with 403 before create;pre_tool_usewraps everycallTool(MCP, sandbox, deferred meta + resolved identity, Code Mode) and returns a deny as an error tool result. Observational:post_tool_use(raw result) andturn_done(after the terminal event is durable, unawaited so it never holds the stream).trueforge-core adds
AgentCapability.toolSetDecorators,lifecycleHooks,IToolSet.unwrapped/unwrapToolSet, andTurnResourceResolver.extraCapabilities. The server loads hooks at startup, runs them viaCommandHookRunner(exit/stdout decision contract, timeouts, fail-open/closed), and documents trust: hook commands run at server privilege on the replica that owns the turn.Reviewed by Cursor Bugbot for commit fb50dbe. Bugbot is set up for automated code reviews on this repo. Configure here.