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/audit/slog.go b/ingestion/internal/audit/slog.go new file mode 100644 index 0000000..b7d57d2 --- /dev/null +++ b/ingestion/internal/audit/slog.go @@ -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 +} diff --git a/ingestion/internal/audit/slog_test.go b/ingestion/internal/audit/slog_test.go new file mode 100644 index 0000000..687528d --- /dev/null +++ b/ingestion/internal/audit/slog_test.go @@ -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{}) + }) +} diff --git a/ingestion/internal/capture/entities.go b/ingestion/internal/capture/entities.go index 05f295d..c9aa227 100644 --- a/ingestion/internal/capture/entities.go +++ b/ingestion/internal/capture/entities.go @@ -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 diff --git a/ingestion/internal/capture/service.go b/ingestion/internal/capture/service.go index af893ce..98a83c9 100644 --- a/ingestion/internal/capture/service.go +++ b/ingestion/internal/capture/service.go @@ -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(), diff --git a/ingestion/internal/capture/service_test.go b/ingestion/internal/capture/service_test.go index b57df0a..2201513 100644 --- a/ingestion/internal/capture/service_test.go +++ b/ingestion/internal/capture/service_test.go @@ -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) +} 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 {