Merge pull request 'feat: POST /capture REST adapter + OAuth2 + I1 sovereignty gate (#53, capture 49d)' (#59) from feat/capture-rest-i1-gate into main
This commit was merged in pull request #59.
This commit is contained in:
@@ -16,6 +16,10 @@ import (
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
@@ -119,6 +123,18 @@ func envInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// splitList parses a comma-separated env value into a trimmed,
|
||||
// empty-free slice. Used for the capture sovereign-principal allowlist.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||
// caller never has to handle the rare error path.
|
||||
func systemHostname() string {
|
||||
@@ -349,6 +365,30 @@ func main() {
|
||||
|
||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||
|
||||
// POST /capture (#53): the uniform capture REST door. Needs a ticket
|
||||
// tracker to file action items, so it only mounts when Gitea is
|
||||
// configured. It reuses the MCP server's graph-wired brain store (one
|
||||
// implementation), the classification tags for the I1 gate, and a slog
|
||||
// audit sink (the loki+buffer sink lands in #54). The handler does its
|
||||
// own auth (static + JWT) because it needs the principal to derive the
|
||||
// trust-zone origin — the chassis middleware hides it.
|
||||
if tracker := mcpSrv.IssueTracker(); tracker != nil {
|
||||
classCfg, cerr := classification.Load(brainDir)
|
||||
if cerr != nil {
|
||||
logger.Error("load classification config", "err", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
captureSvc := capture.NewService(
|
||||
mcpSrv.BrainStore(), tracker, nil, classCfg, audit.NewSlogSink(logger))
|
||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli",
|
||||
capturehttp.NewOriginResolver(sovereign))
|
||||
mux.Handle("POST /capture", captureH)
|
||||
logger.Info("capture endpoint enabled", "sovereign_principals", len(sovereign))
|
||||
} else {
|
||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||
}
|
||||
|
||||
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||
// integration UI, which has no static-Bearer field. Setting both
|
||||
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package audit provides AuditSink implementations for the capture
|
||||
// capability (I5). This file ships the minimal slog-backed sink used in
|
||||
// #53: it emits the request-level audit record to structured logs, which
|
||||
// the alloy/loki substrate already scrapes. The classification-aware
|
||||
// degradation/refusal sink (confidential fails closed, internal buffers +
|
||||
// reconciles) lands in #54 and replaces this behind the same interface.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// SlogSink records audit entries to an slog.Logger. It never fails, so it
|
||||
// does not exercise the I5 floor (refuse-if-unauditable) — that is #54's
|
||||
// loki+buffer sink. A nil logger falls back to slog.Default().
|
||||
type SlogSink struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewSlogSink constructs a SlogSink. nil logger ⇒ slog.Default().
|
||||
func NewSlogSink(logger *slog.Logger) *SlogSink {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &SlogSink{logger: logger}
|
||||
}
|
||||
|
||||
// Record emits the audit entry at info level. Security events, when
|
||||
// present, are logged at warn level so they surface independently of the
|
||||
// routine audit stream.
|
||||
func (s *SlogSink) Record(_ context.Context, e capture.AuditEntry) error {
|
||||
s.logger.Info("capture audit",
|
||||
"principal", e.Principal,
|
||||
"actor", e.Actor,
|
||||
"harness", e.Harness,
|
||||
"session_ref", e.SessionRef,
|
||||
"classification", e.EffectiveClassification,
|
||||
"items", e.Items,
|
||||
"ts", e.Timestamp,
|
||||
)
|
||||
for _, ev := range e.SecurityEvents {
|
||||
s.logger.Warn("capture security event", "principal", e.Principal, "event", ev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSlogSinkRecordsEntryAndSecurityEvents(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sink := audit.NewSlogSink(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
|
||||
err := sink.Record(context.Background(), capture.AuditEntry{
|
||||
Principal: "koala-cli",
|
||||
Harness: "claude-code",
|
||||
EffectiveClassification: "confidential",
|
||||
Items: []string{"insight:wiki/a/facts/x.md"},
|
||||
SecurityEvents: []string{"asserted-vs-derived origin mismatch"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "capture audit")
|
||||
assert.Contains(t, out, "koala-cli")
|
||||
assert.Contains(t, out, "confidential")
|
||||
assert.Contains(t, out, "capture security event")
|
||||
assert.Contains(t, out, "asserted-vs-derived origin mismatch")
|
||||
}
|
||||
|
||||
func TestSlogSinkNilLoggerDefaults(t *testing.T) {
|
||||
// nil logger must not panic.
|
||||
require.NotPanics(t, func() {
|
||||
_ = audit.NewSlogSink(nil).Record(context.Background(), capture.AuditEntry{})
|
||||
})
|
||||
}
|
||||
@@ -15,13 +15,44 @@
|
||||
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||
package capture
|
||||
|
||||
// Zone is the trust zone a capture originates from, server-derived from
|
||||
// the authenticated principal (spec §4.2 / I1). It is NEVER taken from
|
||||
// caller input — context.Harness is descriptive telemetry only.
|
||||
type Zone int
|
||||
|
||||
const (
|
||||
// ZoneUnknown means the origin was not set. The REST adapter always
|
||||
// sets a concrete zone; the service treats Unknown as "not gated" (only
|
||||
// an explicit ZoneUSNexus triggers the I1 refusal) so the gate can
|
||||
// never fire on a caller-controllable default.
|
||||
ZoneUnknown Zone = iota
|
||||
// ZoneSovereign is sovereign soil (homelab / Tailscale CLI callers).
|
||||
ZoneSovereign
|
||||
// ZoneUSNexus is a non-sovereign US-jurisdiction surface (e.g.
|
||||
// claude.ai). Confidential captures through it are refused (I1).
|
||||
ZoneUSNexus
|
||||
)
|
||||
|
||||
// String renders the zone for audit/refusal messages.
|
||||
func (z Zone) String() string {
|
||||
switch z {
|
||||
case ZoneSovereign:
|
||||
return "sovereign-soil"
|
||||
case ZoneUSNexus:
|
||||
return "us-nexus"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureContext is the per-session metadata accompanying a capture.
|
||||
//
|
||||
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||
// the server independently derives the target's classification and gates
|
||||
// on the stricter of the two. Principal is server-derived from the
|
||||
// authenticated identity (#53 populates it); it is never caller-asserted.
|
||||
// Harness is descriptive telemetry only — never a gate input.
|
||||
// on the stricter of the two. Principal and Origin are server-derived from
|
||||
// the authenticated identity (the REST adapter populates them); they are
|
||||
// never caller-asserted. Harness is descriptive telemetry only — never a
|
||||
// gate input.
|
||||
type CaptureContext struct {
|
||||
Harness string
|
||||
SessionRef string
|
||||
@@ -29,6 +60,7 @@ type CaptureContext struct {
|
||||
Actor string
|
||||
Classification string // caller-declared level token ("" = unspecified)
|
||||
Principal string // server-derived (auth); audit identity
|
||||
Origin Zone // server-derived trust zone; the I1 gate input
|
||||
}
|
||||
|
||||
// Insight is one piece of session knowledge bound for the brain. A
|
||||
|
||||
@@ -34,6 +34,33 @@ func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p Classificatio
|
||||
|
||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||
|
||||
// ErrSovereigntyRefused is returned when the I1 gate refuses a capture
|
||||
// (confidential effective classification through a us-nexus origin). The
|
||||
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
||||
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
||||
|
||||
// assertedZoneMismatch returns a security-event string when the caller's
|
||||
// harness label asserts a trust zone that contradicts the server-derived
|
||||
// origin. A harness label that names no zone (the normal case, e.g.
|
||||
// "claude-code") returns "". The label is never used as a gate input —
|
||||
// this only flags the discrepancy for the audit trail.
|
||||
func assertedZoneMismatch(harness string, derived Zone) string {
|
||||
var asserted Zone
|
||||
switch strings.ToLower(strings.TrimSpace(harness)) {
|
||||
case "sovereign-soil", "sovereign":
|
||||
asserted = ZoneSovereign
|
||||
case "us-nexus", "usnexus":
|
||||
asserted = ZoneUSNexus
|
||||
default:
|
||||
return "" // no zone claim
|
||||
}
|
||||
if asserted != derived {
|
||||
return fmt.Sprintf("asserted-vs-derived origin mismatch: harness asserted %s, principal resolves to %s",
|
||||
asserted, derived)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||
// classification (stricter of declared vs target-derived), then persist
|
||||
// insights → tickets → summary best-effort, emit an audit record, and
|
||||
@@ -57,6 +84,32 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
||||
|
||||
effective, securityEvents := s.resolveClassification(declared, in)
|
||||
|
||||
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||
// label that names a different zone is descriptive-only and logged as a
|
||||
// security event (spec §4.2: a control keyed on attacker-suppliable
|
||||
// input is not a control).
|
||||
if ev := assertedZoneMismatch(in.Context.Harness, in.Context.Origin); ev != "" {
|
||||
securityEvents = append(securityEvents, ev)
|
||||
}
|
||||
|
||||
// I1 sovereignty gate: a confidential capture through a us-nexus origin
|
||||
// is refused before ANY write. The refusal itself is audited (best
|
||||
// effort) — refusals must be reconstructable too.
|
||||
if effective == classification.Confidential && in.Context.Origin == ZoneUSNexus {
|
||||
_ = s.audit.Record(ctx, AuditEntry{
|
||||
Timestamp: s.now().UTC(),
|
||||
Principal: in.Context.Principal,
|
||||
Actor: in.Context.Actor,
|
||||
Harness: in.Context.Harness,
|
||||
SessionRef: in.Context.SessionRef,
|
||||
EffectiveClassification: effective.String(),
|
||||
Items: nil, // refused before any write
|
||||
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||
})
|
||||
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
||||
ErrSovereigntyRefused, in.Context.Origin)
|
||||
}
|
||||
|
||||
receipt := CaptureReceipt{
|
||||
Errors: []ItemError{},
|
||||
EffectiveClassification: effective.String(),
|
||||
|
||||
@@ -329,3 +329,82 @@ func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
||||
}
|
||||
|
||||
// --- I1 sovereignty gate (#53) ---
|
||||
|
||||
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, tr, nil, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
ctx.Origin = ZoneUSNexus
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||
// Refused before any write.
|
||||
assert.Empty(t, b.writes)
|
||||
assert.Empty(t, tr.created)
|
||||
// Refusal is audited.
|
||||
require.Len(t, au.entries, 1)
|
||||
assert.Empty(t, au.entries[0].Items, "no items landed on refusal")
|
||||
}
|
||||
|
||||
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||
b := &fakeBrain{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Classification = "confidential"
|
||||
ctx.Origin = ZoneSovereign
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.Len(t, b.writes, 1)
|
||||
}
|
||||
|
||||
func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
||||
// Caller asserts harness "sovereign-soil" but principal resolves to
|
||||
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||
au := &fakeAudit{}
|
||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Harness = "sovereign-soil" // asserted
|
||||
ctx.Origin = ZoneUSNexus // server-derived
|
||||
ctx.Classification = "confidential"
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||
require.Len(t, au.entries, 1)
|
||||
joined := strings.Join(au.entries[0].SecurityEvents, " | ")
|
||||
assert.Contains(t, joined, "asserted-vs-derived origin mismatch")
|
||||
assert.Contains(t, joined, "I1 refusal")
|
||||
}
|
||||
|
||||
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||
// us-nexus origin is fine for non-confidential data.
|
||||
b := &fakeBrain{}
|
||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||
ctx := baseCtx()
|
||||
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package capturehttp is the REST adapter for the capture use-case: the
|
||||
// POST /capture door (#53). It is deliberately thin — authenticate, derive
|
||||
// the trust-zone origin from the authenticated principal, decode the
|
||||
// request, call capture.Service, map the receipt to an HTTP status. No
|
||||
// business logic lives here; the I1 gate, validation, and orchestration
|
||||
// are all in the use-case.
|
||||
package capturehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
)
|
||||
|
||||
// Validator validates a Bearer JWT and returns its subject. The chassis
|
||||
// *auth.JWTValidator satisfies it (including its nil-receiver "disabled"
|
||||
// behaviour), and tests can substitute a fake without a live JWKS.
|
||||
type Validator interface {
|
||||
Validate(ctx context.Context, rawToken string) (string, error)
|
||||
}
|
||||
|
||||
// Handler serves POST /capture.
|
||||
type Handler struct {
|
||||
svc *capture.Service
|
||||
validator Validator // nil ⇒ JWT auth disabled
|
||||
staticToken string // "" ⇒ static auth disabled
|
||||
staticPrincipal string // principal name attributed to static-token callers
|
||||
resolver OriginResolver
|
||||
}
|
||||
|
||||
// New constructs a capture HTTP handler. staticToken callers are
|
||||
// attributed to staticPrincipal (a sovereign homelab identity); JWT
|
||||
// callers are attributed to their token subject.
|
||||
func New(svc *capture.Service, validator Validator, staticToken, staticPrincipal string, resolver OriginResolver) *Handler {
|
||||
if staticPrincipal == "" {
|
||||
staticPrincipal = "local-cli"
|
||||
}
|
||||
return &Handler{
|
||||
svc: svc,
|
||||
validator: validator,
|
||||
staticToken: staticToken,
|
||||
staticPrincipal: staticPrincipal,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// wire types — the POST /capture request body.
|
||||
type request struct {
|
||||
Context contextBody `json:"context"`
|
||||
Insights []insightBody `json:"insights"`
|
||||
Tickets []ticketBody `json:"tickets"`
|
||||
Summary *summaryBody `json:"summary,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type contextBody struct {
|
||||
Harness string `json:"harness"`
|
||||
SessionRef string `json:"session_ref"`
|
||||
Fidelity string `json:"fidelity"`
|
||||
Actor string `json:"actor"`
|
||||
Classification string `json:"classification"`
|
||||
}
|
||||
|
||||
type insightBody struct {
|
||||
Text string `json:"text"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
SupersedeSlug string `json:"supersede_slug,omitempty"`
|
||||
}
|
||||
|
||||
type ticketBody struct {
|
||||
Repo string `json:"repo"`
|
||||
Action string `json:"action"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type summaryBody struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ReposTouched []string `json:"repos_touched,omitempty"`
|
||||
}
|
||||
|
||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||
// result to an HTTP status.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, viaStatic, ok := h.authenticate(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
in := req.toInput()
|
||||
// Principal and origin are server-derived — overwrite anything the
|
||||
// caller may have tried to put in the body.
|
||||
in.Context.Principal = principal
|
||||
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
||||
|
||||
rec, err := h.svc.Capture(r.Context(), in)
|
||||
switch {
|
||||
case errors.Is(err, capture.ErrSovereigntyRefused):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
// Pre-write validation failure (fail-closed).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, statusFor(rec), rec)
|
||||
}
|
||||
|
||||
// authenticate mirrors the chassis Bearer precedence (static wins, then
|
||||
// JWT) but returns the resolved principal and whether the static path was
|
||||
// taken — the chassis middleware hides both, and capture needs them to
|
||||
// derive the origin.
|
||||
func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) {
|
||||
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !found || raw == "" {
|
||||
return "", false, false
|
||||
}
|
||||
if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
|
||||
return h.staticPrincipal, true, true
|
||||
}
|
||||
if h.validator != nil {
|
||||
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||
return sub, false, true
|
||||
}
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
func (b request) toInput() capture.CaptureInput {
|
||||
in := capture.CaptureInput{
|
||||
Context: capture.CaptureContext{
|
||||
Harness: b.Context.Harness,
|
||||
SessionRef: b.Context.SessionRef,
|
||||
Fidelity: b.Context.Fidelity,
|
||||
Actor: b.Context.Actor,
|
||||
Classification: b.Context.Classification,
|
||||
},
|
||||
DryRun: b.DryRun,
|
||||
}
|
||||
for _, i := range b.Insights {
|
||||
in.Insights = append(in.Insights, capture.Insight{
|
||||
Text: i.Text, Wing: i.Wing, Hall: i.Hall, SupersedeSlug: i.SupersedeSlug,
|
||||
})
|
||||
}
|
||||
for _, t := range b.Tickets {
|
||||
in.Tickets = append(in.Tickets, capture.Ticket{
|
||||
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||
})
|
||||
}
|
||||
if b.Summary != nil {
|
||||
in.Summary = &capture.Summary{
|
||||
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// statusFor maps a receipt to an HTTP status: 200 all-ok (or dry-run),
|
||||
// 207 partial, 502 everything-failed.
|
||||
func statusFor(rec capture.CaptureReceipt) int {
|
||||
if rec.DryRun {
|
||||
return http.StatusOK
|
||||
}
|
||||
var ok, fail int
|
||||
for _, i := range rec.Insights {
|
||||
count(&ok, &fail, i.OK)
|
||||
}
|
||||
for _, t := range rec.Tickets {
|
||||
count(&ok, &fail, t.OK)
|
||||
}
|
||||
if rec.Summary != nil {
|
||||
count(&ok, &fail, rec.Summary.OK)
|
||||
}
|
||||
switch {
|
||||
case fail == 0:
|
||||
return http.StatusOK
|
||||
case ok == 0:
|
||||
return http.StatusBadGateway // every persistence attempt failed
|
||||
default:
|
||||
return http.StatusMultiStatus // 207: partial success
|
||||
}
|
||||
}
|
||||
|
||||
func count(ok, fail *int, isOK bool) {
|
||||
if isOK {
|
||||
*ok++
|
||||
} else {
|
||||
*fail++
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package capturehttp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const staticTok = "static-secret"
|
||||
|
||||
// fakeValidator stands in for the chassis JWT validator.
|
||||
type fakeValidator struct {
|
||||
subject string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
||||
return f.subject, f.err
|
||||
}
|
||||
|
||||
type fakeTracker struct{ failCreate bool }
|
||||
|
||||
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
if f.failCreate {
|
||||
return capture.IssueRef{}, errors.New("gitea down")
|
||||
}
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
||||
t.Helper()
|
||||
cfg, err := classification.Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||
}
|
||||
|
||||
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func internalReq() map[string]any {
|
||||
return map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedWithoutToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedBadToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer wrong", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestHappyPathStaticToken(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
assert.Empty(t, rec.Errors)
|
||||
}
|
||||
|
||||
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
||||
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "sovereignty")
|
||||
}
|
||||
|
||||
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
||||
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
||||
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestPartialFailureIs207(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.False(t, rec.Tickets[0].OK)
|
||||
assert.Len(t, rec.Errors, 1)
|
||||
}
|
||||
|
||||
func TestDryRunWritesNothing(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||
"dry_run": true,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.DryRun)
|
||||
}
|
||||
|
||||
func TestCallerCannotForgeOrigin(t *testing.T) {
|
||||
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
||||
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt", map[string]any{
|
||||
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package capturehttp
|
||||
|
||||
import "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
|
||||
// OriginResolver maps an authenticated principal to its trust zone
|
||||
// (spec §4.2). The mapping is server-side and never reads caller input.
|
||||
//
|
||||
// Rules:
|
||||
// - The static-token path is a homelab CLI caller on sovereign soil →
|
||||
// ZoneSovereign.
|
||||
// - A JWT principal in the sovereign allowlist → ZoneSovereign.
|
||||
// - Any other JWT principal (e.g. claude.ai's OAuth identity, or any
|
||||
// unrecognised subject) → ZoneUSNexus.
|
||||
//
|
||||
// The default is the strict one: an unknown principal is treated as
|
||||
// us-nexus so the I1 gate fails safe (refuses confidential), exactly as
|
||||
// an untagged classification target fails safe to confidential (#50).
|
||||
type OriginResolver struct {
|
||||
sovereign map[string]bool
|
||||
}
|
||||
|
||||
// NewOriginResolver builds a resolver whose JWT sovereign principals are
|
||||
// the given subjects. The static-token caller is always sovereign and
|
||||
// need not be listed.
|
||||
func NewOriginResolver(sovereignPrincipals []string) OriginResolver {
|
||||
m := make(map[string]bool, len(sovereignPrincipals))
|
||||
for _, p := range sovereignPrincipals {
|
||||
if p != "" {
|
||||
m[p] = true
|
||||
}
|
||||
}
|
||||
return OriginResolver{sovereign: m}
|
||||
}
|
||||
|
||||
// Resolve returns the trust zone for a principal. viaStatic is true when
|
||||
// the static-token auth path was taken.
|
||||
func (r OriginResolver) Resolve(principal string, viaStatic bool) capture.Zone {
|
||||
if viaStatic || r.sovereign[principal] {
|
||||
return capture.ZoneSovereign
|
||||
}
|
||||
return capture.ZoneUSNexus
|
||||
}
|
||||
@@ -116,6 +116,13 @@ func (s *Server) IssueTracker() capture.IssueTracker {
|
||||
return s.tracker
|
||||
}
|
||||
|
||||
// BrainStore returns the shared brain store (graph-wired once WithGraph
|
||||
// has run), so the capture use-case writes through the exact same
|
||||
// implementation as the MCP handlers.
|
||||
func (s *Server) BrainStore() *brainstore.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||
if r.Method == http.MethodGet {
|
||||
|
||||
Reference in New Issue
Block a user