feat(security): STEP_UP authorization decision (closes #210)#247
Conversation
|
Live-verified end-to-end. Built the binary from the branch tip. Config exercise: ```yaml Engine.Check() across 5 caller identities: RFC 9470 challenge shape via
Repro is in |
initializ-mk
left a comment
There was a problem hiding this comment.
Request changes — the stepup engine is clean, but there's a critical integration bug: the RFC 9470 401 challenge is never emitted on any transport, including the REST path the PR marks as working. The primary acceptance criterion ("HTTP 401 with RFC 9470 step-up challenge on StepUp") is unmet.
Blocker: the 401 step-up challenge is never sent
The hook correctly returns a typed *stepup.RequiredError, and loop.go propagates it (before tool exec hook: %w). But executeTask swallows it: on any executor.Execute error it sets task.Status = Failed, emits session_end, and returns (task, snap, nil). All five of executeTask's return statements pass nil as the error — none return the executor error.
So the REST handler never reaches the challenge writer:
task, snap, err := r.executeTask(ctx, ...) // err is ALWAYS nil on tool failure
if err != nil { // never true for a step-up denial
if WriteStepUpChallengeOnError(w, err) { return } // dead code for step-up
writeJSON(w, http.StatusInternalServerError, ...)
return
}
writeJSON(w, http.StatusOK, task) // returns 200 + a "failed" task bodyThe caller gets HTTP 200 with a failed-task body, no WWW-Authenticate header, no acr_values. RFC 9470's whole purpose — a machine-readable challenge the client acts on to re-authenticate and retry — is unfulfilled.
Why it slipped through: the 4 challenge-writer tests all call WriteStepUpChallengeOnError directly with a RequiredError, bypassing executeTask. Nothing drives the handler end to end, so the swallow is untested.
Severity nuance: the tool is still correctly blocked — the task fails and the tool never executes, so fail-closed is intact and this is not a security hole. But the feature is non-functional: there's no path to step up and retry.
Fix: have executeTask return the step-up error (detect via stepup.AsRequiredError and return it as the third value, keeping the failed-task store/audit side effects) so the handler's WriteStepUpChallengeOnError(w, err) branch is reachable. Then add an end-to-end handler test: httptest POST /tasks/send that triggers a step-up tool call and asserts 401 + the WWW-Authenticate: Bearer error="step_up_required", acr_values="..." header. Same gap exists on the JSON-RPC/SSE paths (documented as follow-up) — but REST is claimed to work and doesn't.
Correctness — verified good
- Engine is solid. Immutable after construction (no locking), fail-closed on nil identity / no acr, strict-equal and hierarchy comparison modes,
Validatecatches enabled-with-empty-tools and a tool acr missing from the hierarchy (typos). - Identity propagation works.
WithIdentity(auth middleware) ->req.Context()->executeTaskonly adds ctx values (never strips) ->executor.Execute->hooks.Fire->auth.IdentityFromContextresolves in the hook. - Audit fires correctly.
auth_step_up_requiredis emitted inside the hook before the error is swallowed, so the SIEM signal is intact — only the client-facing challenge is lost.
Minor
- Ordering claim. The hook is registered after the library guardrails but before the skill guardrails, so a skill-guardrail-denied call still produces a step-up challenge first — slightly narrower than the "fires AFTER guardrails" rationale in the comment.
- step_up +
--no-auth. Identity is always nil, so every step-up tool is permanently gated (and, per the bug above, returns 200-failed rather than a challenge). Worth a startup warning or doc note that step_up requires auth to be meaningful.
The engine design and the RFC 9470 challenge writer are both correct in isolation — the gap is purely that executeTask never lets the error reach the writer. Wire that through with an end-to-end test and this is close.
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.
Manoj's blocker on #247: the RFC 9470 step-up challenge was never sent. The hook correctly returned `*stepup.RequiredError` and `loop.go` propagated it via "before tool exec hook: %w," but `executeTask` swallowed it: on any `executor.Execute` error it set `task.Status = Failed`, emitted `session_end`, and returned `(task, snap, nil)`. Every one of executeTask's return statements passed `nil` as the error, so the REST handler's if err != nil { if WriteStepUpChallengeOnError(w, err) { return } writeJSON(w, http.StatusInternalServerError, ...) } writeJSON(w, http.StatusOK, task) branch was unreachable for step-up. The caller got **HTTP 200 with a failed-task body**, no `WWW-Authenticate`, no acr_values. The tool was correctly BLOCKED (fail-closed intact) but the feature was non-functional: no machine-readable challenge means no path to step up and retry. Fix: `executeTask` now detects `*stepup.RequiredError` via `stepup.AsRequiredError` (which uses `errors.As` so the loop-wrap doesn't hide it) and returns it as the third value. All the failed-task side effects (store.Put, session_end, invocation_complete) still run — the caller inspects the failure via GET /tasks/{id} AFTER re-authenticating and retrying with the stronger acr. Non-step-up executor errors keep the pre-#247 behavior (err returned as nil so the handler renders 200 with the failed task) — the fix is surgical. Tests (three new subtests in step_up_execute_task_test.go — the gap in coverage that let this slip past the existing writer-only unit tests): - `TestExecuteTask_ReturnsStepUpError` — the primary regression: wrap `*stepup.RequiredError` as `loop.go` does, drive executeTask, assert err is non-nil AND `errors.As`-unwraps to RequiredError AND the task-store + session_end side effects still fired. - `TestExecuteTask_NonStepUpErrorStaysAsNil` — surgical-fix guard: a generic executor error must still return nil so existing clients keep getting 200 + failed-task on tool failures. - `TestExecuteTask_StepUpEndToEnd_ChallengeEmitted` — the full seam Manoj asked to close: drive executeTask, feed the returned error into WriteStepUpChallengeOnError against an httptest.ResponseRecorder, assert 401 + the RFC 9470 WWW-Authenticate header + the JSON body with tool/required_acr. Mirrors the exact REST-handler code path. Same swallow exists on the JSON-RPC / SSE paths — documented as follow-up in the original PR body. This PR's contract was that REST works, and REST now works.
Governance R4b. Adds the third policy decision the framework
requires: on a high-assurance tool call, if the caller's
authentication assurance is insufficient, Forge returns an RFC 9470
step-up challenge instead of running the tool.
- `forge-core/security/stepup/` — new pkg. Engine reads per-tool
acr requirements from ForgeConfig, checks against the
auth.Identity's `acr` claim, returns typed *RequiredError on
mismatch.
- Optional acr_hierarchy for "stronger acr satisfies weaker
requirement" semantics (default strict-equal).
- Runner registers a BeforeToolExec hook that fires AFTER
guardrails (so a guardrail-denied call doesn't produce an
unnecessary challenge) and BEFORE tool execution (so the tool
never runs on a mismatch).
- WriteStepUpChallengeOnError translates the typed error into HTTP
401 with `WWW-Authenticate: Bearer error="step_up_required",
acr_values="<acr>"` per RFC 9470 §3.
- PolicyResult gains a RequiredAcr field + StepUp() constructor
for policy engines that want to return step-up as their
decision (populates the existing DecisionStepUp enum slot from
R4a).
security.step_up:
enabled: true
tools:
cli_execute: acr:mfa
acr_hierarchy: [acr:password, acr:mfa, acr:hardware] # optional
Fail-loud: enabled with empty tools list rejected at startup;
tool acr not in hierarchy rejected at startup.
auth_step_up_required — fields: tool, required_acr, presented_acr
(omitted when empty), reason. No token bytes, no full claim maps.
- POST /tasks/send REST → HTTP 401 with challenge header
- JSON-RPC + SSE surfaces documented as follow-ups (step-up
errors surface in body but not as HTTP 401 headers there yet;
requires plumbing the typed error through the dispatcher).
`forge-core/security/stepup/stepup_test.go` — 12 tests:
- config validation (enabled+no tools, hierarchy typo)
- tool without requirement passes through
- strict-equal match + mismatch
- hierarchy: stronger satisfies weaker, weaker fails,
unknown-acr fails-closed
- nil identity / missing acr claim / disabled engine
- RequiredError satisfies errors.As unwrapping
`forge-cli/runtime/step_up_test.go` — 4 tests for the challenge
writer: happy path (RFC 9470 header format), wrapped-error unwrap,
non-step-up errors passthrough, nil error defensive.
Docs at docs/security/step-up-auth.md with the RFC 9470 challenge
shape, threat-model section, and follow-up notes on JSON-RPC/SSE
transport coverage.
Manoj's blocker on #247: the RFC 9470 step-up challenge was never sent. The hook correctly returned `*stepup.RequiredError` and `loop.go` propagated it via "before tool exec hook: %w," but `executeTask` swallowed it: on any `executor.Execute` error it set `task.Status = Failed`, emitted `session_end`, and returned `(task, snap, nil)`. Every one of executeTask's return statements passed `nil` as the error, so the REST handler's if err != nil { if WriteStepUpChallengeOnError(w, err) { return } writeJSON(w, http.StatusInternalServerError, ...) } writeJSON(w, http.StatusOK, task) branch was unreachable for step-up. The caller got **HTTP 200 with a failed-task body**, no `WWW-Authenticate`, no acr_values. The tool was correctly BLOCKED (fail-closed intact) but the feature was non-functional: no machine-readable challenge means no path to step up and retry. Fix: `executeTask` now detects `*stepup.RequiredError` via `stepup.AsRequiredError` (which uses `errors.As` so the loop-wrap doesn't hide it) and returns it as the third value. All the failed-task side effects (store.Put, session_end, invocation_complete) still run — the caller inspects the failure via GET /tasks/{id} AFTER re-authenticating and retrying with the stronger acr. Non-step-up executor errors keep the pre-#247 behavior (err returned as nil so the handler renders 200 with the failed task) — the fix is surgical. Tests (three new subtests in step_up_execute_task_test.go — the gap in coverage that let this slip past the existing writer-only unit tests): - `TestExecuteTask_ReturnsStepUpError` — the primary regression: wrap `*stepup.RequiredError` as `loop.go` does, drive executeTask, assert err is non-nil AND `errors.As`-unwraps to RequiredError AND the task-store + session_end side effects still fired. - `TestExecuteTask_NonStepUpErrorStaysAsNil` — surgical-fix guard: a generic executor error must still return nil so existing clients keep getting 200 + failed-task on tool failures. - `TestExecuteTask_StepUpEndToEnd_ChallengeEmitted` — the full seam Manoj asked to close: drive executeTask, feed the returned error into WriteStepUpChallengeOnError against an httptest.ResponseRecorder, assert 401 + the RFC 9470 WWW-Authenticate header + the JSON body with tool/required_acr. Mirrors the exact REST-handler code path. Same swallow exists on the JSON-RPC / SSE paths — documented as follow-up in the original PR body. This PR's contract was that REST works, and REST now works.
7950cee to
1dcb07c
Compare
initializ-mk
left a comment
There was a problem hiding this comment.
Approving — commit 7950cee fixes the blocker exactly as recommended, and closes the coverage gap. Re-verified on the branch: build clean, full forge-cli/runtime suite green, all step-up tests pass.
Blocker — 401 challenge never fired — fixed
executeTask now surfaces the step-up error to the handler:
if _, isStepUp := stepup.AsRequiredError(err); isStepUp {
return task, acc.Snapshot(), err
}
return task, acc.Snapshot(), nilVerified:
- Correct placement — in the generic "execute failed" block (a step-up
RequiredErroris notcontext.Canceled, so it flows here, not the cancellation path), and it runs after the failed-task side effects (store.Put,session_end,invocation_complete), so audit + task state are preserved and the caller canGET /tasks/{id}after re-auth. - Surgical — non-step-up executor errors still return
nil, so ordinary tool failures keep rendering 200 + failed-task. Pinned byTestExecuteTask_NonStepUpErrorStaysAsNil. errors.Asunwrap works through loop.go's"before tool exec hook: %w"wrap.
Tests — gap closed
step_up_execute_task_test.go:
ReturnsStepUpError— err propagates, unwraps toRequiredError, side effects fire.NonStepUpErrorStaysAsNil— surgical guard.StepUpEndToEnd_ChallengeEmitted— drives executeTask -> WriteStepUpChallengeOnError and asserts 401 +WWW-Authenticate(error="step_up_required",acr_values) + JSON body. The exact REST-handler seam.
Follow-up (tracked, non-blocking)
- JSON-RPC / SSE paths still swallow the error (no 401 there) — reaffirmed as documented follow-up. The PR's contract was that REST works, and REST now works.
- Two minors from the first review remain fine to defer: the hook fires after library but before skill guardrails; and
step_up+--no-authpermanently gates (identity always nil) — worth a startup warning eventually.
Nice work — the fix is exactly the right shape (surface the typed error, keep the side effects, leave other errors alone) and the end-to-end test mirrors the handler path precisely.
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.
Closes #210. Governance R4b — adds the third policy decision from the R4 taxonomy: on a high-assurance tool call, if the caller's authentication assurance is insufficient, Forge returns an RFC 9470 step-up challenge instead of running the tool.
Design
forge-core/security/stepup/package: Engine reads per-toolacrrequirements from ForgeConfig, checks againstauth.Identity.Claims["acr"], returns typed*RequiredErroron mismatch.acr_hierarchyfor "stronger acr satisfies weaker requirement" semantics; default strict-equal comparison.BeforeToolExechook that fires AFTER guardrails (so a guardrail-denied call doesn't produce an unnecessary challenge) and BEFORE tool execution.WriteStepUpChallengeOnErrortranslates the typed error into HTTP 401 withWWW-Authenticate: Bearer error="step_up_required", acr_values="<acr>"per RFC 9470 §3.PolicyResult.RequiredAcrfield +StepUp()constructor populate the existingDecisionStepUpenum slot from R4a (feat(guardrails): generalize MODIFY policy decision beyond cli_execute #221).Config
```yaml
security:
step_up:
enabled: true
tools:
cli_execute: acr:mfa
http_request: acr:mfa
acr_hierarchy: # optional
- acr:password
- acr:mfa
- acr:hardware
```
Fail-loud on startup:
enabled: truewith empty tools list is rejected; a tool acr not in the hierarchy (typo) is rejected.Audit event
New
auth_step_up_requiredevent with fields{tool, required_acr, presented_acr, reason}. No token bytes, no full claim maps.Wire coverage
Acceptance criteria (from #210)
DecisionStepUpreturnable from any guardrail hook (viaruntime.StepUp(requiredAcr, reason))acrclaim on the retry (auth package already carries Claims map; Engine.Check readsacrfrom it)auth_step_up_requiredaudit event with tool, reason, required acrdocs/security/step-up-auth.mdTest plan
go test ./forge-core/security/stepup/...— 12 tests (validation, strict + hierarchy paths, fail-closed, RequiredError unwrap)go test ./forge-cli/runtime/...— 4 challenge-writer tests (RFC 9470 format, wrapped-error unwrap, non-step-up passthrough)go test ./forge-core/... ./forge-cli/...greengofmt -w+golangci-lint runclean