diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index a91a961..f513463 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -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; diff --git a/ingestion/internal/capturehttp/handler.go b/ingestion/internal/capturehttp/handler.go new file mode 100644 index 0000000..8bc73f0 --- /dev/null +++ b/ingestion/internal/capturehttp/handler.go @@ -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) +} diff --git a/ingestion/internal/capturehttp/handler_test.go b/ingestion/internal/capturehttp/handler_test.go new file mode 100644 index 0000000..a2247cc --- /dev/null +++ b/ingestion/internal/capturehttp/handler_test.go @@ -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) +} diff --git a/ingestion/internal/capturehttp/origin.go b/ingestion/internal/capturehttp/origin.go new file mode 100644 index 0000000..b449371 --- /dev/null +++ b/ingestion/internal/capturehttp/origin.go @@ -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 +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index a88a21b..84d94d5 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -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 {