feat(harness): adopt the standalone approval-gate worker, retire the in-process gate#261
feat(harness): adopt the standalone approval-gate worker, retire the in-process gate#261ytallo wants to merge 3 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
skill-check — worker0 verified, 20 skipped (no docs/).
Four for four. Nicely done. |
1140eeb to
919e731
Compare
18e4770 to
62af9c1
Compare
62af9c1 to
7821b46
Compare
7821b46 to
d4a595c
Compare
d4a595c to
a38de7e
Compare
a38de7e to
23bd3c3
Compare
23bd3c3 to
962f15b
Compare
962f15b to
c9b4cb7
Compare
c9b4cb7 to
57444f9
Compare
3f69b87 to
d459837
Compare
747c877 to
6e75e9a
Compare
d459837 to
578fd6f
Compare
6e75e9a to
749b0ca
Compare
578fd6f to
7352982
Compare
…in-process gate
The harness no longer embeds approval policy. It now ships only the
mechanics the standalone approval-gate worker (approval-gate/ crate,
tech-specs/2026-06-agentic/approval-gate.md) composes with:
- harness::hook::pre_dispatch trigger type: dispatchWithHook consults
bound hooks (continue / deny / hold) per call, in priority order,
fail-closed on transport errors or unparseable replies. Zero bound
hooks = ungated dispatch (hooks narrow, never widen).
- harness::function::resolve: settle one held call — action "execute"
releases it through the normal execution path (no hook re-consult),
"deliver" answers it with the given content/is_error without
executing. Decisions persist to the new function_resolutions scope and
are DELETED after consumption (the old approvals scope leaked one row
per decision forever). Unknown/settled calls answer {resolved: false}.
- harness::turn_completed trigger type: emitted once per terminal
transition (completed / cancelled / failed) from the saveRecord
funnel, so the gate purges its pending records without polling.
Turn records gain a turn_id (backfilled as legacy-<session_id> for
in-flight records). Entering function_awaiting_approval now enqueues one
post-persist scan wake, closing a pre-existing race where a resolve
landing mid-batch parked the turn forever. The turn::on_approval state
trigger is gone — resolve wakes the queue directly.
Removed: src/approval-gate/ (resolve, settings RPCs, denial/redact —
all reimplemented in the Rust worker), turn-orchestrator/hook.ts
(consultBefore), the harness configuration entry's permissions block
(the gate owns its own approval-gate entry), and the approval-gate
process from the composite entry point.
Console: coerceSettings unwraps the new {settings, source} response
envelopes and preserves granted_by: "seed".
Breaking: a deployment without the gate worker runs ungated; held calls
created before this deploy are orphaned (run::abort is the escape
hatch); operators must drop the legacy approval-gate block from local
config.yaml and run the Rust worker as a separate process (start the
harness first — the gate's hook binding is best-effort at boot).
This branch retires the in-process gate, so the harness no longer bundles approval-gate. Remove it from the README Modules row and note that approval is now delegated to the standalone approval-gate worker via the pre_dispatch hook.
Mirror the approval-gate worker's id rename on the harness side so the wire contract stays aligned: the turn-orchestrator hook/turn-completed trigger ids (harness::hook::pre-dispatch, harness::turn-completed) and the console's approval::* calls (set-mode, add-always-allow, approve-always, clear-settings, get-settings, list-pending, on-turn-completed, remove-always-allow) now use kebab-case per the naming SOP. The point:"pre_dispatch" payload discriminant is data, not an id, and is left as-is on both sides.
7352982 to
48315ff
Compare
Summary
The harness no longer ships its own approval policy engine. It exposes a generic synchronous pre-dispatch hook point (
harness::hook::pre_dispatch) and a release surface (harness::function::resolve), and delegates every allow / deny / hold decision to the standalone approval-gate worker that binds to that hook. All of the in-harness gate code — permission modes, allow-lists, YAML-rule evaluation, redaction, and the per-session settings functions — is deleted. From the agent's and user's perspective the gating behavior is unchanged; only its owner moved out of process.Stacked on
Targets
feat/approval-gate-worker, notmain. The standalone worker crate lives on that base branch; this PR is the harness-side integration only and must merge after the base.What changes
src/turn-orchestrator/hooks/):harness::hook::pre_dispatchis registered as a trigger type. Any sibling worker binds to it (priority-ordered, glob-filterable by function id) and answers{decision: "continue" | "deny" | "hold"}synchronously per agent function call.hooks/types.ts— wire schema for hook input/output and the strict per-binding config (functions,priority,timeout_ms,on_error).hooks/registry.ts— subscriber registry; bindings rebuild on engine replay after a harness restart.hooks/chain.ts— consults bound hooks in order; firstdeny/holdshort-circuits, all-continue⇒ allow. Transport failure follows the binding'son_error(fail_closeddefault denies withgate_unavailable;fail_openskips). Zero bindings ⇒ allow (a deployment with no gate is ungated).hooks/denial.ts— denial-envelope shapes shared by the hook chain and the generic trigger-failure path;denied_byis nowpermissions | user | hook | gate_unavailable.agent-trigger.ts):dispatchWithHooknow takes aDispatchContext(session_id,turn_id,step,metadata) and consultsconsultPreDispatchinstead of the old in-processconsultBefore. Aholdreturns{kind: 'pending', held_by, pending_timeout_ms}so the batch can park the call.function-resolve.ts, new):harness::function::resolvelets the hook owner settle a held call —action: "execute"releases it back through the normal execution path (released calls skip the hook chain),action: "deliver"answers it with supplied content/is_errorwithout executing (user deny, sweep timeout). Decisions are persisted to afunction_resolutionsscope and the parked turn is woken on the turn-step queue; unknown/stale/duplicate calls return{resolved: false}(benign, never an error). A transient state-read outage surfaces as an error so the caller retries rather than losing the hold.function-awaiting-approval/): the wake now consumesfunction_resolutionsrows (read → apply → delete) rather than reacting to anapprovals-scope state-write trigger. It re-scans until a pass settles nothing new (a released call can settle a sibling), then routes the batch tofunction_executeorfinalizeBatch.turn-completed.ts, new):harness::turn_completedtrigger type fires on terminal turns (completed / cancelled / failed) so the gate can purge a turn's pending records instead of polling. Fired fromfinalizeBatchand on stale-turn takeover inrun-start.ts; at-least-once, unordered, idempotent.state.ts): records carry aturn_id(generated innewRecord, backfilled for pre-existing records) threaded through the hook input,function::resolve, andturn_completedso siblings can key their state to one turn.run-abort.ts): clears parkedawaiting_approvaland best-effort deletes their unconsumed resolution rows so a decision racing the abort doesn't linger.turn-orchestrator/register.ts): registers the two trigger types first (siblings can bind immediately), plusharness::function::resolve.harness/register.ts,index.ts,harness/iii.worker.yaml): theapproval-gateworker is removed from the compositeWORKERSarray and its dependency drop, and the harness config entry no longer carries the permissions block tied to the in-process gate.Removed
src/approval-gate/**— the entire in-process gate:resolve.ts,denial.ts,redact.ts,schemas.ts,main.ts,iii.worker.yaml, andsettings/**(mode, always-allow add/remove, default-mode, human-only, store, register, etc.).src/turn-orchestrator/hook.ts— the oldconsultBeforeapproval consult (human-only self-escalation defense → settings snapshot → mode/allow-list →policy::check_permissions), replaced byhooks/chain.ts.src/harness/permissions-config.tsand itsregisterHarnessConfigEntrywiring.tests/approval-gate/**,tests/turn-orchestrator/hook.test.ts,tests/turn-orchestrator/function-awaiting-approval-state-trigger.test.ts,tests/integration/mode-approval.e2e.test.ts.Behavior
always_allowlist, YAML policy rules, and redaction all ran in-process insideconsultBefore, and the awaiting-approval wake fired off anapprovals-scope state write.harness::hook::pre_dispatch. Releases come back viaharness::function::resolve; cleanup is driven byharness::turn_completed.deniedresult (still classified as an error and rendered as[PERMISSION_DENIED]), held calls park the turn until a human decides, and abort still unparks cleanly. A gate that crashes/times out fails closed by default. With the standalone gate bound, the agent-facing outcome is identical to before.Testing
pnpm run typecheck(tsc -b --noEmit) → pass, clean compile.pnpm run test(vitest run) → 74 files, 835 tests, all passing, includingfunction-resolve,function-awaiting-approval,hooks/chain,hooks/registry,turn-completed,hook-gate-flow.e2e,wire-parity, andparallel-approval.e2e.pnpm run lint(biome check) → clean for the files in this change (lint:fixapplied). The only remaining warnings are pre-existingnoTemplateCurlyInStringnotices in an unrelatedllm-routerconfig test, untouched here.Reviewer notes
hooks/chain.ts(the deny/hold/fail-open/fail-closed decision matrix) andfunction-resolve.ts(theexecutevsdeliversettlement and the strict-vs-tolerant read contract — a state outage must surface as an error, not{resolved: false}).function-awaiting-approval/run.ts: the re-scan-until-stable pass, theexecuted-guard against double execution, and the extra single-shot wake when a pass settles some-but-not-all siblings.hooks/denial.ts+agent-trigger.ts:gate_unavailableon hook failure, and thatstatus: 'denied'still flows throughisErrorResultand[PERMISSION_DENIED]rendering.turn_idbackfill instate.tskeeps in-flight turns valid across the deploy, and thatturn_completedfan-out (incl. the stale-takeover emit inrun-start.ts) is genuinely fire-and-forget / idempotent.register.tsordering (trigger types before FSM steps) so a sibling that boots early can bind without a gap.