Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1997838b0 | ||
|
|
77680c7445 | ||
|
|
d7a842f356 | ||
|
|
aad90f2dfe | ||
|
|
07fca9ee73 | ||
|
|
f6bf9b5f57 | ||
|
|
6606b38a76 | ||
|
|
4cfc98de56 |
@@ -16,7 +16,12 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
"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/embed"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||||
@@ -118,6 +123,18 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
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
|
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||||
// caller never has to handle the rare error path.
|
// caller never has to handle the rare error path.
|
||||||
func systemHostname() string {
|
func systemHostname() string {
|
||||||
@@ -175,6 +192,15 @@ func main() {
|
|||||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea ticket tracker for the capture capability (#52). Token via env
|
||||||
|
// only — never logged or in argv. Both vars must be set to enable it;
|
||||||
|
// gitea.New returns nil otherwise, leaving ticket integration off.
|
||||||
|
giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be")
|
||||||
|
if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil {
|
||||||
|
mcpSrv = mcpSrv.WithIssueTracker(tracker)
|
||||||
|
logger.Info("brain gitea tracker configured", "url", giteaURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
// be set together for the path to wire on; otherwise BM25-only.
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
var vectorStore *vectorstore.PGStore
|
var vectorStore *vectorstore.PGStore
|
||||||
@@ -339,6 +365,30 @@ func main() {
|
|||||||
|
|
||||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
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
|
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||||
// integration UI, which has no static-Bearer field. Setting both
|
// integration UI, which has no static-Bearer field. Setting both
|
||||||
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
// 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.
|
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||||
package capture
|
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.
|
// CaptureContext is the per-session metadata accompanying a capture.
|
||||||
//
|
//
|
||||||
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||||
// the server independently derives the target's classification and gates
|
// the server independently derives the target's classification and gates
|
||||||
// on the stricter of the two. Principal is server-derived from the
|
// on the stricter of the two. Principal and Origin are server-derived from
|
||||||
// authenticated identity (#53 populates it); it is never caller-asserted.
|
// the authenticated identity (the REST adapter populates them); they are
|
||||||
// Harness is descriptive telemetry only — never a gate input.
|
// never caller-asserted. Harness is descriptive telemetry only — never a
|
||||||
|
// gate input.
|
||||||
type CaptureContext struct {
|
type CaptureContext struct {
|
||||||
Harness string
|
Harness string
|
||||||
SessionRef string
|
SessionRef string
|
||||||
@@ -29,6 +60,7 @@ type CaptureContext struct {
|
|||||||
Actor string
|
Actor string
|
||||||
Classification string // caller-declared level token ("" = unspecified)
|
Classification string // caller-declared level token ("" = unspecified)
|
||||||
Principal string // server-derived (auth); audit identity
|
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
|
// Insight is one piece of session knowledge bound for the brain. A
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ type IssueRef struct {
|
|||||||
// scopes to owner "mathias"; the port deliberately omits owner.
|
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||||
type IssueTracker interface {
|
type IssueTracker interface {
|
||||||
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||||
CloseIssue(ctx context.Context, repo string, number int) (IssueRef, error)
|
// CloseIssue closes an issue, optionally posting a closing comment
|
||||||
|
// first (empty comment ⇒ close only).
|
||||||
|
CloseIssue(ctx context.Context, repo string, number int, comment string) (IssueRef, error)
|
||||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
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
|
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||||
// classification (stricter of declared vs target-derived), then persist
|
// classification (stricter of declared vs target-derived), then persist
|
||||||
// insights → tickets → summary best-effort, emit an audit record, and
|
// 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)
|
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{
|
receipt := CaptureReceipt{
|
||||||
Errors: []ItemError{},
|
Errors: []ItemError{},
|
||||||
EffectiveClassification: effective.String(),
|
EffectiveClassification: effective.String(),
|
||||||
@@ -222,7 +275,7 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
|
|||||||
case "create":
|
case "create":
|
||||||
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||||
case "close":
|
case "close":
|
||||||
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number)
|
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
case "comment":
|
case "comment":
|
||||||
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (Iss
|
|||||||
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int) (IssueRef, error) {
|
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return IssueRef{}, f.err
|
return IssueRef{}, f.err
|
||||||
}
|
}
|
||||||
@@ -329,3 +329,82 @@ func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
|||||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package gitea implements capture.IssueTracker against a Gitea instance
|
||||||
|
// over its REST API. It is the new outbound dependency the brain server
|
||||||
|
// gains for the capture capability (#49c/#52): the server otherwise does
|
||||||
|
// brain-local file ops only.
|
||||||
|
//
|
||||||
|
// Owner is hard-coded to the operator and never taken from caller input.
|
||||||
|
// The API token is read once at construction, held in the struct, and
|
||||||
|
// never logged or placed in argv — it travels only in the Authorization
|
||||||
|
// header of outbound requests (AGENTS.md secret-handling).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// owner is the fixed repository owner for every ticket operation. It is a
|
||||||
|
// constant, not a parameter, so a caller can never redirect a write to
|
||||||
|
// another owner's repo.
|
||||||
|
const owner = "mathias"
|
||||||
|
|
||||||
|
// Client is a Gitea REST API IssueTracker.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. It returns nil when either baseURL or token is
|
||||||
|
// empty, so callers can treat missing config as "tracker disabled" with a
|
||||||
|
// single nil check (mirrors embed.New).
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
if baseURL == "" || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueResponse is the subset of a Gitea issue/comment payload we read.
|
||||||
|
type issueResponse struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssue opens a new issue under the fixed owner.
|
||||||
|
func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo),
|
||||||
|
map[string]any{"title": title, "body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentIssue posts a comment on an existing issue.
|
||||||
|
func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number),
|
||||||
|
map[string]any{"body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIssue closes an issue, first posting a closing comment when one is
|
||||||
|
// given (empty comment ⇒ close only).
|
||||||
|
func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) {
|
||||||
|
if strings.TrimSpace(comment) != "" {
|
||||||
|
if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPatch,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number),
|
||||||
|
map[string]any{"state": "closed"}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request against the Gitea API and decodes the
|
||||||
|
// response into out. Errors carry the status and a truncated body for
|
||||||
|
// diagnosis but never the token.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||||
|
reqBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
// Gitea's token scheme. Held here only; never logged.
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
if out != nil && len(respBody) > 0 {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "super-secret-token-value"
|
||||||
|
|
||||||
|
func TestNewNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, gitea.New("", testToken))
|
||||||
|
assert.Nil(t, gitea.New("https://git.example", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIssueForcesOwnerAndAuth(t *testing.T) {
|
||||||
|
var gotPath, gotAuth, gotBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.New(srv.URL, testToken)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias")
|
||||||
|
assert.Equal(t, "token "+testToken, gotAuth)
|
||||||
|
assert.Contains(t, gotBody, "Do the thing")
|
||||||
|
assert.Equal(t, "hyperguild", ref.Repo)
|
||||||
|
assert.Equal(t, 42, ref.Number)
|
||||||
|
assert.Contains(t, ref.URL, "/issues/42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentIssue(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath)
|
||||||
|
assert.Equal(t, 7, ref.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueWithComment(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var states []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var body map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &body)
|
||||||
|
states = append(states, body["state"].(string))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 9, ref.Number)
|
||||||
|
// Comment posted first, then state PATCHed to closed.
|
||||||
|
assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments")
|
||||||
|
assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9")
|
||||||
|
assert.Equal(t, []string{"closed"}, states)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueNoComment(t *testing.T) {
|
||||||
|
var commented bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/comments") {
|
||||||
|
commented = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, commented, "empty comment ⇒ no comment POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -48,6 +49,7 @@ type Server struct {
|
|||||||
embedder search.Embedder // nil = BM25-only retrieval
|
embedder search.Embedder // nil = BM25-only retrieval
|
||||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||||
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||||
|
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
@@ -100,6 +102,27 @@ func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithIssueTracker injects the Gitea ticket tracker behind the
|
||||||
|
// capture.IssueTracker interface. nil leaves ticket integration off. The
|
||||||
|
// use-case (capture) consumes this in #53; it is wired here so the
|
||||||
|
// dependency is constructed once and stays swappable/testable.
|
||||||
|
func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server {
|
||||||
|
s.tracker = t
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTracker returns the injected ticket tracker (nil when unconfigured).
|
||||||
|
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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package mcp_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -93,3 +95,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) {
|
|||||||
assert.Equal(t, float64(-32601), errObj["code"])
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubTracker struct{}
|
||||||
|
|
||||||
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithIssueTrackerInjects(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
assert.Nil(t, srv.IssueTracker(), "tracker is off by default")
|
||||||
|
srv = srv.WithIssueTracker(stubTracker{})
|
||||||
|
assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user