In-process verification gates

September 3, 2026 · View on GitHub

Verification normally happens after a worker finishes: the scheduler inspects results and re-dispatches on failure, paying a full round-trip for every miss. A gate-capable adapter instead wires blocking hooks into the worker's own runtime so the required checks run inside the session, before the worker's turn ends.

What it does

For a gate-capable adapter, Bernstein renders two hooks at spawn time:

HookTriggerBehaviour
Tool-permission matcherPreToolUse on Write / Edit / MultiEdit / NotebookEditRefuses a write whose target falls outside the task's path allowlist. Realpath containment refuses a .. traversal or an in-scope symlink that resolves outside the worktree.
Completion gateStopRuns the task's required evidence producers in-session; refuses to let the turn end while any of them fail.
Interactive approval gatePreToolUsePuts a tool call the classifier does not auto-decide to the operator, and blocks the agent until it is resolved or the TTL expires. Off unless approvals.interactive is set.

Both hooks shell out to:

bernstein hook-gate check --session <id> --event PreToolUse < event.json
bernstein hook-gate check --session <id> --event Stop < event.json

The command reads the hook event JSON on stdin, evaluates the policy persisted at .sdd/runtime/hook_gate/<session>.json (written at spawn time from the task's owned_files and required evidence_producers), and exits 2 to block or 0 to allow.

Which adapters support it

Only adapters with a genuine blocking hook surface are wired up. Currently: claude and claude_routine. Every other adapter renders no gate hooks and degrades to the scheduler-side gate with no policy weakening — there is no partial or best-effort in-process enforcement for an adapter that lacks the surface.

Trust model

The in-process gate is defence in depth and a cost optimisation, not a replacement for the authoritative check. The scheduler-side evidence gate (bernstein evidence show / verify) remains authoritative and runs regardless of what happened in-session.

Every gate outcome — a blocked completion or a refused out-of-scope write — is sealed as the same signed, chain-anchored evidence bundle the scheduler-side gate produces (via bernstein audit verify). A downstream verifier cannot tell from the receipt schema whether enforcement fired in-process or scheduler-side.

Failure handling

Enforcement is fail-open on unexpected errors: a bug while sealing a receipt must never wedge a worker, so sealing failures are logged and swallowed — but a computed block/allow decision is always emitted from the check itself. An unsafe or unrecognised session id degrades to allow-through rather than touching the filesystem with an unvalidated path.

A policy that enforces nothing (no path allowlist, no required producers) is a pass-through: both hooks always allow.

Source

src/bernstein/adapters/hook_gate_render.py (hook rendering per adapter), src/bernstein/core/security/hook_gate.py (policy model and gate evaluation), src/bernstein/cli/commands/hook_gate_cmd.py (bernstein hook-gate check).

Interactive tool-call approvals

Opt-in. With approvals.interactive: false — the default — nothing below happens and the PreToolUse hook behaves exactly as it did before.

# bernstein.yaml
approvals:
  interactive: true
  timeout_seconds: 600      # TTL; expiry denies, it does not hang
  smart_auto_approve: false # a classifier APPROVE verdict skips human review

With it on, a tool call reaching the PreToolUse hook is decided in this order, and only a call none of these settle reaches the operator:

StepOutcome
Per-tool permission policyA fail-closed profile rejects here regardless of whether approvals are on, so turning the queue off cannot bypass it.
Classifier deny-listDenies unconditionally, headless included.
Classifier APPROVESkips the queue only when smart_auto_approve is set.
Classifier ASKFalls through to human review — this is what enqueues.
Always-allow listA matching tool+args pattern proceeds without asking.
OtherwiseEnqueued as a pending approval; the hook blocks.

While a call is pending it is visible to all three resolution surfaces, which share one queue under .sdd/runtime/approvals:

GET  /approvals/queue              # HTTP
POST /approvals/{id}/resolve
bernstein approve --tool           # CLI

...plus the TUI ApprovalPanel, which polls the same queue. Resolving through any of them releases the same pending.

Who resolved it

Every resolution names a principal: an identifier, how it was authenticated, and the session or grant it acted under. There is no default, so a surface that cannot name the decider cannot resolve.

SurfaceIdentifierAuthentication method
HTTP with scoped tokens configuredthe token's principalscoped-token
HTTP with no tokens configured (loopback posture)dashboard-operatorloopback
CLI and TUIos-user:<name>local-shell
TTL expiry, sweeper, policy deny, classifier auto-decisionsystem:<component>server-internal

An HTTP request that presents no valid token while scoped tokens are configured is refused with 401 rather than resolved against an unnamed caller. The system: namespace is reserved: a human principal may not use it and a server-internal one must, so a decision the software made cannot be read as a person's. The principal is written to the resolution sentinel under .sdd/runtime/approvals and to the human_approval_decision audit-chain event, whose actor is the principal's identifier.

The agent observes the decision as the hook's exit code: an allow (or an always-allow promotion) lets the call proceed, a reject refuses it with the operator's reason on stderr, and TTL expiry is a denial, not a hang — the worker is never left waiting on an operator who never came back.

Coverage caveat

The PreToolUse matcher is registered for write tools only (Write|Edit|MultiEdit|NotebookEdit), so those are the calls the gate currently sees. Bash and other tools do not reach it, even though the classifier is largely written about shell commands. Widening the matcher is a separate change.

Failure posture

An infrastructure failure inside the gate degrades to allow-through, matching this command's existing behaviour for an unreadable policy: the authoritative scheduler-side gate stays the sole enforcement point. A reject decision is not an infrastructure failure and always blocks.