From d7a842f356d0ff4cf79c034689c61a793486fc64 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 23:42:18 +0200 Subject: [PATCH] feat(capture): I1 sovereignty gate + server-derived origin (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the trust-zone Origin to CaptureContext and the I1 gate to the use-case: a confidential effective classification through a us-nexus origin is refused before ANY write (ErrSovereigntyRefused), and the refusal is itself audited. A caller-asserted harness label that names a different zone than the server-derived origin is logged as a security event — context.Harness is descriptive-only, never a gate input. The gate triggers only on an explicit ZoneUSNexus, so the unset default (ZoneUnknown) can never make it fire on caller-controllable input; the REST adapter always sets a concrete zone. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/capture/entities.go | 38 ++++++++++- ingestion/internal/capture/service.go | 53 +++++++++++++++ ingestion/internal/capture/service_test.go | 79 ++++++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) 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) +}