feat(security): DEFER authorization decision (closes #211)#248
Conversation
initializ-mk
left a comment
There was a problem hiding this comment.
Request changes — this is the strongest PR in the governance batch: the pause/resume concurrency (the part most likely to harbor bugs) is genuinely well-engineered and passes -race. The changes requested are small — an inaccurate status-code comment and validation-consistency with the sibling R4 PRs — plus one design limitation worth documenting before merge.
Concurrency — verified correct
I traced every ordering of the Resolve-vs-timeout race:
Registerarms atime.AfterFunc; bothResolveandautoTimeoutdelete frompendingundere.mu, so whichever takes the lock first wins and the other finds!okand no-ops.Handle.resolved sync.Onceis a second guard sowaitis sent to exactly once.waitis buffered(1), so a resolver never blocks even if the waiter already left (ctx-cancel) or hasn't arrived yet — no goroutine leak, no send-on-closed panic.- Timers are cleaned on all three paths (Resolve, autoTimeout, ctx-cancel) — no timer leak.
- ctx-cancel is cleaned up by the hook itself: on
WaitCtxreturningctx.Err(), it restores task status and callsengine.Resolve(...Timeout)to deregister, rather than leaking the handle until the timer fires.
Both security/deferpolicy and the runtime defer/decision tests pass under -race.
Reject/timeout -> failed task is correct here (unlike #247)
On reject/timeout the hook returns an error -> the loop aborts -> executeTask converts it to a failed task. That's the same swallow I flagged as a blocker on #247, but here it is the right behavior: DEFER has no machine-readable challenge to emit, so a rejected/timed-out approval legitimately ends the task as failed, and the a2a async model conveys that via task status (the caller reads the returned task or polls GET /tasks/{id}). Approve -> restore working -> tool proceeds. All three audit events fire.
Changes requested
-
Status-code comment is wrong.
registerDecisionsEndpoint/makeDecisionsHandlercomments say "409 for tasks not in a deferred state," but the code returns 404 for that case — aPeekmiss can't distinguish unknown-task from not-deferred, so both are 404. 409 fires only on the narrowPeek-hit-then-Resolverace. The user-facingdocs/security/defer-decisions.mdis correct; just fix the two inline comments so an integrator building the approve/reject webhook isn't misled about the contract. -
Validation consistency with the R4 siblings. Step-up (#247) and intent (#245) fail loud when
enabled: truebut no tools are declared; defer silently no-ops (len(cfg.Tools) == 0 -> return) while still logging "defer engine wired," so an operator who enables defer but forgetstools:gets silent inaction. Add the same startup rejection (and a smallDeferConfig.Validate— todayRegisterclampstimeout <= 0to 10m, which is safe but happens deep at first use rather than at startup).
Design limitation (document before merge)
The hook blocks the executor/request goroutine synchronously for up to timeout (default 10m). The caller must hold the connection open — or use tasks/sendSubscribe (SSE) — for the whole approval window; a shorter client or server write-timeout cancels the ctx (cleaned up correctly) and abandons the approval. Worth calling out explicitly in the docs that synchronous tasks/send isn't the right transport for long approval windows. Cross-restart persistence is absent, but the Engine seam is called out — fine as a follow-up.
Nice work — the double-guard on the resolve/timeout race and the buffered-channel-never-blocks-a-resolver design are exactly right. Fix the comment + the validation-consistency nit and I'm happy to approve.
Three follow-ups from Manoj's review on #248: 1. Inline comment mismatch. `registerDecisionsEndpoint` and `makeDecisionsHandler` said "409 for tasks not in a deferred state," but the code returns 404 for that case — Peek can't tell "unknown task" from "not currently deferred" apart, so both collapse to 404. 409 fires only on the narrow Peek-hit- then-Resolve race. Comments now match. The user-facing docs/security/defer-decisions.md already had it right. 2. DeferConfig.Validate for consistency with the R4 siblings. Step-up (#247) and intent (#245) fail startup when `enabled: true` but no tools are declared. Defer used to silently no-op while logging "defer engine wired," so an operator who typoed `defer.tools:` got zero enforcement plus a green log line. Now fails startup with a message that names the block. Config test pins the matrix. 3. Design-limitation callout in the docs. The pause blocks the executor goroutine for up to `defer.timeout`, so the caller's transport (client read-timeout, server write-timeout, reverse-proxy idle) must exceed that window. Added an explicit "Choose your transport for the approval window" paragraph with the sync vs. SSE guidance and the timeout rule of thumb. The engine + hook + audit-event behaviors are unchanged — these are surface fixes.
initializ-mk
left a comment
There was a problem hiding this comment.
Approving — commit 05f311c addresses all three requested changes. Re-verified on the branch: build clean, forge-core/security/deferpolicy green under -race, runtime defer/decision tests green, types config test green.
-
Status-code comment fixed.
registerDecisionsEndpoint/makeDecisionsHandlernow document it accurately: 404 for no-pending-deferral (unknown task and not-currently-deferred collapse into one case —Peekcan't distinguish them), 409 only for the narrow Peek-hit-then-Resolve race, 400 for malformed bodies/decisions, 200 on resolve. The user-facing docs were already right. -
DeferConfig.Validateadded and called at runner construction, soenabled: truewith nodefer.tools:fails startup (with a message that names the block) instead of logging "defer engine wired" and enforcing nothing — consistent with the fail-loud posture of the R4 siblings (#245 / #247).TestDeferConfig_Validatepins the matrix. -
Design-limitation documented. The new "Choose your transport for the approval window" callout in
docs/security/defer-decisions.mdexplains that the pause blocks the executor goroutine for up todefer.timeout, so the client read-timeout / server write-timeout / proxy idle must exceed that window, and steers long approvals totasks/sendSubscribe(SSE) or the async poll pattern.
This was already the strongest PR in the governance batch — the resolve/timeout double-guard (pending-delete-under-lock + resolved sync.Once), the buffered-channel-never-blocks-a-resolver design, and the correct ctx-cancel cleanup all verified under -race in the first pass. The three surface items are now closed cleanly. Nice work.
…#211) Governance R4c. Adds the fifth PolicyDecision from the R4 taxonomy: the executor can pause a task mid-run on a high-risk tool call, wait for an external approver to POST a decision, and resume where it left off. - `forge-core/security/deferpolicy/` package: Engine with Register/Resolve/Peek/WaitCtx, per-task pending map, timeout via time.AfterFunc, sync.Once-guarded resolution. In-process only — the pause is a goroutine block on a decision channel; the goroutine's stack IS the persisted state (process restart abandons pending deferrals). - `forge-cli/runtime/defer.go`: BeforeToolExec hook that fires on a matching tool call, flips the a2a task status to `deferred`, emits `task_deferred` audit, blocks on Handle.WaitCtx, restores status on approve / errors out on reject or timeout with distinct audit events. - `POST /tasks/{id}/decisions` endpoint accepts `{decision, approver, note}`; 200 on resolve, 404 on unknown task, 400 on invalid decision. - Handler function extracted (`makeDecisionsHandler`) so tests exercise all status-code paths without the full server. - Config surface: `security.defer.enabled` + per-tool `{to, timeout, context_template}` map + optional `default_timeout` / `default_to`. Fail-loud on enabled-but-empty. - `runtime.PolicyResult` gains `Defer *DeferSpec` field + `Defer()` constructor for hooks that want to signal deferral via the common result type. - `a2a.TaskStateDeferred = "deferred"` new state — parallel `GET /tasks/{id}` observers see the pause. - Three audit constants: `task_deferred`, `task_deferred_decision`, `task_deferred_timeout`. `deferpolicy/defer_test.go` — 12 engine tests: Register uniqueness, concurrent tasks, Resolve approve/reject unblocks waiter, timeout unblocks with DecisionTimeout, resolve-before-timeout cancels timer with no spurious value, timeout-before-resolve leaves nothing to resolve, unknown-task 404 shape, ctx-cancel behaviour, Peek, default timeout of 10m. `runtime/defer_test.go` — 8 hook + endpoint tests: approve / reject / timeout paths at hook level, unconfigured tool fast-path, ctx-cancel cleanup, context-template rendering, decisions endpoint happy path / 404 / 400 for bad decision. Built the binary, ran `forge run` on the demo yaml → startup log `defer engine wired tools=1`. A driver exercised all four scenarios (approve, reject, timeout auto-deny, unknown-task 404) against a live HTTP-served decisions endpoint. All pass. Docs at docs/security/defer-decisions.md with the operator guide, audit-event shapes, and follow-up notes on the notify integration (Slack Block Kit → POST decisions).
Three follow-ups from Manoj's review on #248: 1. Inline comment mismatch. `registerDecisionsEndpoint` and `makeDecisionsHandler` said "409 for tasks not in a deferred state," but the code returns 404 for that case — Peek can't tell "unknown task" from "not currently deferred" apart, so both collapse to 404. 409 fires only on the narrow Peek-hit- then-Resolve race. Comments now match. The user-facing docs/security/defer-decisions.md already had it right. 2. DeferConfig.Validate for consistency with the R4 siblings. Step-up (#247) and intent (#245) fail startup when `enabled: true` but no tools are declared. Defer used to silently no-op while logging "defer engine wired," so an operator who typoed `defer.tools:` got zero enforcement plus a green log line. Now fails startup with a message that names the block. Config test pins the matrix. 3. Design-limitation callout in the docs. The pause blocks the executor goroutine for up to `defer.timeout`, so the caller's transport (client read-timeout, server write-timeout, reverse-proxy idle) must exceed that window. Added an explicit "Choose your transport for the approval window" paragraph with the sync vs. SSE guidance and the timeout rule of thumb. The engine + hook + audit-event behaviors are unchanged — these are surface fixes.
05f311c to
68d1866
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Re-approving — my earlier approval was auto-dismissed by the rebase, not by any code change. The new head 68d1866 is a pure rebase of the previously-approved 05f311c onto the current main (now carrying the merged #245/#246/#247): I diffed the two — only blob hashes and line-number positions moved, the added content is byte-identical (the Defer.Validate() call at Runner startup, DeferConfig.Validate, the 404/409 comment fix, and the transport/timeout doc callout).
Re-verified on the rebased base: both modules build clean, security/deferpolicy green under -race, runtime defer/decision tests green, and the types defer-config test green.
All three requested changes remain in place and correct. No new concerns. Approving.
… docs /sync-docs pass triggered by R4c defer landing on this branch. The governance events / config blocks / hook layer had accumulated a doc debt across four PRs — the R3/R7/R4b/R9 features already merged but never made it into audit-logging.md, policy-decisions.md, the forge.yaml schema, or the .claude skill. Bringing everything current in one pass so the docs match #248's post-merge shape. - docs/security/policy-decisions.md: `step_up` and `defer` rows said "Reserved — not yet emitted". Updated to name the config block that triggers them and cross-link to step-up-auth.md and defer-decisions.md. - docs/security/audit-logging.md: added rows for the nine governance audit event types — intent_alignment, intent_drift, auth_step_up_required, task_deferred, task_deferred_decision, task_deferred_timeout, credential_issued, credential_revoked, credential_failed. Each row lists the fields wire shape and cross-links to the feature doc. Explicitly notes what payloads are NOT allowed to carry (LLM prompts, tool args, credential material). - docs/reference/forge-yaml-schema.md: `security:` section only documented `policy_path`. Extended to cover intent_alignment, intent_drift, step_up, defer with an example yaml block per feature and a field-notes table. Called out the *float64 pointer semantics for thresholds (explicit 0 preserved) and the monotone_n ≤ window rejection. - docs/core-concepts/hooks.md: new "Governance hooks" subsection in the hook reference — one table row per governance hook (R3 / R7 / R4b / R4c / R9) with hook point, config block, and fail behavior. Documents the registration order (guardrails first, then governance, R3 → R4b → R4c → R9). - .claude/skills/forge.md: added rows in §17 Audit event reference for all nine governance events, and a new §12.11 "Governance framework R1–R9" subsection under Security model with the compliance matrix, the where-it-lives pointer, and a cross-link to the compliance-suite live-verification harness. ToC stays H2-only so no ToC edit needed. The .claude/skills/forge-skill-builder.md constant tie-in is untouched — no changes to skill_builder_context.go on this branch.
Closes #211. Governance R4c — the fifth and final `PolicyDecision` from the R4 taxonomy. On a high-risk tool call, the executor pauses, an external approver POSTs a decision, and the executor resumes where it left off. Distinct from DENY (refuses) and STEP_UP (re-authenticates) — the tool call is not terminated, only paused.
Design
security.defer.tools.<name>: {to, timeout, context_template}. Fast-path for unconfigured tools.Config
```yaml
security:
defer:
enabled: true
default_timeout: 10m
tools:
cli_execute:
to: channel:slack:#oncall
timeout: 10m
context_template: "Agent wants to run {tool}: {args}"
```
Endpoint
```
POST /tasks/{id}/decisions
Content-Type: application/json
{"decision":"approve","approver":"alice@example.com","note":"one-off"}
```
200 on resolve, 404 on unknown task, 400 on invalid decision string, 409 on race with timeout.
Acceptance criteria (from #211)
Test coverage
Live-verified end-to-end
Built the binary from the branch tip. `forge run` startup log:
```
{"level":"info","msg":"defer engine wired","time":"...","tools":1}
```
Driver in `/tmp/r4c-demo/verify/` exercised all four scenarios against a live decisions endpoint:
Full `go test ./forge-core/... ./forge-cli/...` sweep green; `gofmt -w` + `golangci-lint run` clean.