feat: POST /capture REST adapter + OAuth2 + I1 sovereignty gate (#53, capture 49d) #59

Merged
mathias merged 3 commits from feat/capture-rest-i1-gate into main 2026-06-22 21:44:14 +00:00
Owner

Implements #53 (capture 49d) — the HTTP door + the load-bearing I1 gate. "Capture works" end-to-end for direct-REST harnesses after this. Branches from main (deps #50/#51/#52 already merged).

What landed

I1 sovereignty gate (in the use-case, capture/):

  • CaptureContext.Origin (a server-derived Zone). The service refuses before any write when effective classification is confidential AND origin is us-nexus (ErrSovereigntyRefused), and audits the refusal (refusals must be reconstructable too).
  • A caller-asserted harness label naming a different zone than the derived origin → security event (context.Harness is descriptive-only, never a gate input — a control keyed on attacker-suppliable input is not a control).
  • Gate fires only on explicit ZoneUSNexus, so the unset default can't make it misfire; the adapter always sets a concrete zone.

REST adapter (capturehttp/):

  • POST /capture: authenticate → derive origin → decode → capture.Service → map receipt to status. Thin; no business logic.
  • Auth mirrors the chassis Bearer precedence (static token wins, then Dex JWT) but returns the principal + auth path — the chassis middleware hides both, and capture needs the principal to derive origin. Depends on a small Validator interface (the chassis *JWTValidator satisfies it) so the JWT/origin path is testable without a live JWKS.
  • OriginResolver: static-token caller + allowlisted JWT subjects → sovereign; every other principal → us-nexus (fail safe), so confidential is refused by default for unknown identities — same fail-safe-to-strict philosophy as #50. Principal/origin are server-set on the input, overwriting anything the caller's body asserted (TestCallerCannotForgeOrigin).
  • HTTP status: 200 all-ok/dry-run · 207 partial · 502 all-failed · 403 I1 refusal · 400 fail-closed validation.

audit/ — minimal SlogSink: emits the I5 request-level record to structured logs (alloy/loki scrapes them), security events at warn. Placeholder behind AuditSink; #54 swaps in the loki+buffer+reconcile sink (confidential fails closed, internal degrades) without touching callers.

Wiring (main): /capture mounts behind the same static+JWT creds as /mcp, reuses the MCP server's graph-wired brain store (one implementation, via a new BrainStore() accessor) and the classification tags. Mounts only when a Gitea tracker is configured. Sovereign JWT principals via BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS (comma-sep).

Origin → trust-zone mapping (the spec §4.2 decision, made concrete)

The spec left the exact mechanism to implementation. Chosen, server-side only:

Auth path Principal Zone
static token local-cli (homelab/Tailscale) sovereign
JWT, subject in allowlist e.g. koala-cli sovereign
JWT, any other subject (e.g. claude.ai) token subject us-nexus (fail safe)

Acceptance criteria

  • POST /capture JWT-authed, thin adapter (decode → use-case → encode)
  • Harness origin server-derived from authenticated principal; context.harness not a gate input
  • Asserted-vs-derived origin mismatch logged as a security event
  • I1 gate: confidential + us-nexus → refuse before any write, names the invariant
  • Effective classification = stricter(declared, #50-derived); mismatch logged
  • HTTP status semantics (200 / 207 / 403 / 400 / 502); dry_run writes nothing
  • Tests: happy, confidential-via-us-nexus-refused, confidential-via-sovereign-allowed, static-is-sovereign, caller-cannot-forge-origin, stricter-wins (service), partial→207, dry-run, 401, validation→400
  • task check green; secret-handling respected (token only in auth header, never logged/argv)

Deferred (correctly, per the epic)

  • Real audit sink (loki + buffer + classification-aware degradation) → #54. The slog sink never fails, so the I5 "refuse if unauditable" floor isn't exercised yet.
  • ai-sessions SummaryWriter impl → nil for now; a request with a summary fails that one item (partial receipt), everything else lands.
  • k3s manifest (I3) → infra repo (#55).

Not auto-merged — review + merge from claude.ai.

Closes #53

🤖 Generated with Claude Code

Implements #53 (capture 49d) — the HTTP door + the load-bearing I1 gate. **"Capture works" end-to-end for direct-REST harnesses after this.** Branches from `main` (deps #50/#51/#52 already merged). ## What landed **I1 sovereignty gate (in the use-case, `capture/`):** - `CaptureContext.Origin` (a server-derived `Zone`). The service refuses **before any write** when effective classification is `confidential` AND origin is `us-nexus` (`ErrSovereigntyRefused`), and **audits the refusal** (refusals must be reconstructable too). - A caller-asserted harness label naming a different zone than the derived origin → **security event** (`context.Harness` is descriptive-only, never a gate input — a control keyed on attacker-suppliable input is not a control). - Gate fires only on explicit `ZoneUSNexus`, so the unset default can't make it misfire; the adapter always sets a concrete zone. **REST adapter (`capturehttp/`):** - `POST /capture`: authenticate → derive origin → decode → `capture.Service` → map receipt to status. Thin; no business logic. - Auth mirrors the chassis Bearer precedence (static token wins, then Dex JWT) but returns the **principal + auth path** — the chassis middleware hides both, and capture needs the principal to derive origin. Depends on a small `Validator` interface (the chassis `*JWTValidator` satisfies it) so the JWT/origin path is testable without a live JWKS. - **`OriginResolver`:** static-token caller + allowlisted JWT subjects → `sovereign`; **every other principal → `us-nexus` (fail safe)**, so confidential is refused by default for unknown identities — same fail-safe-to-strict philosophy as #50. Principal/origin are server-set on the input, overwriting anything the caller's body asserted (`TestCallerCannotForgeOrigin`). - HTTP status: **200** all-ok/dry-run · **207** partial · **502** all-failed · **403** I1 refusal · **400** fail-closed validation. **`audit/` — minimal `SlogSink`:** emits the I5 request-level record to structured logs (alloy/loki scrapes them), security events at warn. Placeholder behind `AuditSink`; #54 swaps in the loki+buffer+reconcile sink (confidential fails closed, internal degrades) without touching callers. **Wiring (`main`):** `/capture` mounts behind the same static+JWT creds as `/mcp`, reuses the MCP server's **graph-wired brain store** (one implementation, via a new `BrainStore()` accessor) and the classification tags. Mounts only when a Gitea tracker is configured. Sovereign JWT principals via `BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS` (comma-sep). ## Origin → trust-zone mapping (the spec §4.2 decision, made concrete) The spec left the exact mechanism to implementation. Chosen, server-side only: | Auth path | Principal | Zone | |---|---|---| | static token | `local-cli` (homelab/Tailscale) | sovereign | | JWT, subject in allowlist | e.g. `koala-cli` | sovereign | | JWT, any other subject (e.g. claude.ai) | token subject | **us-nexus (fail safe)** | ## Acceptance criteria - [x] `POST /capture` JWT-authed, thin adapter (decode → use-case → encode) - [x] Harness origin **server-derived** from authenticated principal; `context.harness` not a gate input - [x] Asserted-vs-derived origin mismatch logged as a security event - [x] I1 gate: confidential + us-nexus → refuse before any write, names the invariant - [x] Effective classification = stricter(declared, #50-derived); mismatch logged - [x] HTTP status semantics (200 / 207 / 403 / 400 / 502); `dry_run` writes nothing - [x] Tests: happy, confidential-via-us-nexus-refused, confidential-via-sovereign-allowed, static-is-sovereign, caller-cannot-forge-origin, stricter-wins (service), partial→207, dry-run, 401, validation→400 - [x] `task check` green; secret-handling respected (token only in auth header, never logged/argv) ## Deferred (correctly, per the epic) - **Real audit sink (loki + buffer + classification-aware degradation) → #54.** The slog sink never fails, so the I5 "refuse if unauditable" floor isn't exercised yet. - **ai-sessions `SummaryWriter` impl** → nil for now; a request with a summary fails that one item (partial receipt), everything else lands. - **k3s manifest (I3)** → infra repo (#55). Not auto-merged — review + merge from claude.ai. Closes #53 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mathias added 3 commits 2026-06-22 21:42:42 +00:00
Adds the trust-zone Origin to CaptureContext and the I1 gate to the
use-case: a confidential effective classification through a us-nexus
origin is refused before ANY write (ErrSovereigntyRefused), and the
refusal is itself audited. A caller-asserted harness label that names a
different zone than the server-derived origin is logged as a security
event — context.Harness is descriptive-only, never a gate input.

The gate triggers only on an explicit ZoneUSNexus, so the unset default
(ZoneUnknown) can never make it fire on caller-controllable input; the
REST adapter always sets a concrete zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emits the request-level audit record to structured logs (scraped by the
existing alloy/loki substrate) and surfaces security events at warn
level. Placeholder behind the AuditSink interface — the classification-
aware loki+buffer+reconcile sink (confidential fails closed, internal
degrades) lands in #54 and replaces this without touching callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(capturehttp): POST /capture REST adapter + OAuth2 + origin resolver (#53)
CI / Lint / Test / Vet (pull_request) Successful in 12s
CI / Mirror to GitHub (pull_request) Has been skipped
a1997838b0
The HTTP door for the capture capability. Thin: authenticate → derive
trust-zone origin → decode → capture.Service → map receipt to status.

- Auth mirrors the chassis Bearer precedence (static token wins, then Dex
  JWT) but returns the resolved principal + auth path, which the chassis
  middleware hides — capture needs the principal to derive the origin.
  Depends on a small Validator interface (the chassis *JWTValidator
  satisfies it) so the JWT/origin path is testable without a live JWKS.
- OriginResolver maps principal → trust zone: static-token caller and
  allowlisted JWT subjects → sovereign; every other principal → us-nexus
  (fail safe, so the I1 gate refuses confidential by default). Principal
  and origin are server-set on the input, overwriting any body the caller
  sent.
- HTTP status: 200 all-ok / dry-run, 207 partial, 502 all-failed, 403 on
  the I1 refusal, 400 on fail-closed validation.
- Wired in main behind the same static+JWT credentials as /mcp, reusing
  the MCP server's graph-wired brain store (one implementation) and the
  classification tags (#50). Mounts only when a Gitea tracker is
  configured. Sovereign JWT principals via BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mathias merged commit b7938d4636 into main 2026-06-22 21:44:14 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#59