Skip to content

feat: lifecycle hooks — operator-configured commands at agent lifecycle events - #380

Open
jrideout wants to merge 1 commit into
truefoundry:mainfrom
jrideout:feat/lifecycle-hooks
Open

feat: lifecycle hooks — operator-configured commands at agent lifecycle events#380
jrideout wants to merge 1 commit into
truefoundry:mainfrom
jrideout:feat/lifecycle-hooks

Conversation

@jrideout

@jrideout jrideout commented Aug 20, 2026

Copy link
Copy Markdown

Implements #379 — operator-configured lifecycle hooks.

What

An off-by-default hooks feature: an operator-owned hooks.json declares 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.

Event Fires Can block?
user_prompt_submit before a turn is created from user text — user messages and answers to client-side tool questions yes — turn rejected (403 + reason)
pre_tool_use before every tool call (MCP, sandbox, system tools, subagent spawns, deferred tools — meta-invocation and resolved call — and Code Mode bridged calls) yes — error tool result via the existing deny path
post_tool_use after each executed call, raw result (pre-offload) no
turn_done after the terminal event is durable (best-effort, off the response path) no

Decision contract per entry: exit 0 + stdout ApprovalDecision (empty stdout = allow); exit 2 = deny with stderr as reason; any other failure/timeout/unparseable stdout resolves per fail_mode (open default, 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

  • trueforge-core (kept free of child_process):
    • AgentCapability.toolSetDecorators — a new capability seam applied to every toolset in initTools() (system meta tools, sandbox, deferred-tool proxy, user MCP servers), so nothing bypasses the hook.
    • lifecycleHooks(runner, events) builtin — an IToolSet decorator that consults an injected LifecycleHookRunner before/after callTool. A deny returns the same synthesized error tool result a user denial produces (reusing ApprovalDecision end to end — no new decision concepts).
    • TurnResourceResolver gains an optional extraCapabilities passthrough (the seam ResolvedAgentDefinition already declared).
    • ApprovalDecisionSchema is now exported (the server parses hook stdout with it).
  • trueforge (server):
    • src/schemas/hooks.ts — zod schema for hooks.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.tsuser_prompt_submit gate before session.createTurn (turn id minting hoisted so hook payloads carry it), hooks capability wired through the resolver, turn_done dispatched from drainTurnEvents after the terminal dual-write.
    • Config: 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 bare HOOKS_PATH would 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.mdx with 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; CommandHookRunner tests 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, and turn_done payload.
  • Full local gate: pnpm format:check && pnpm typecheck && pnpm lint:ci && pnpm build, pnpm test:trueforge, pnpm test:trueforge-core, pnpm test:store:sqlite, pnpm test:frontend all green. (test:trueforge-ui fails identically on unmodified main under 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.example entry; changeset bumping @truefoundry/trueforge-core and @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_submit rejects a turn with 403 before create; pre_tool_use wraps every callTool (MCP, sandbox, deferred meta + resolved identity, Code Mode) and returns a deny as an error tool result. Observational: post_tool_use (raw result) and turn_done (after the terminal event is durable, unawaited so it never holds the stream).

trueforge-core adds AgentCapability.toolSetDecorators, lifecycleHooks, IToolSet.unwrapped / unwrapToolSet, and TurnResourceResolver.extraCapabilities. The server loads hooks at startup, runs them via CommandHookRunner (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.

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fb50dbe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@truefoundry/trueforge-core Minor
@truefoundry/trueforge Minor

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

@jrideout
jrideout force-pushed the feat/lifecycle-hooks branch from 8072bfe to 556a1d9 Compare August 20, 2026 18:27
Comment thread packages/trueforge/src/hooks/CommandHookRunner.ts
@jrideout
jrideout force-pushed the feat/lifecycle-hooks branch 3 times, most recently from 245727f to 8193d95 Compare August 20, 2026 19:46
…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.
@jrideout
jrideout force-pushed the feat/lifecycle-hooks branch from 8193d95 to fb50dbe Compare August 22, 2026 15:25

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fb50dbe. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants