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) <noreply@anthropic.com>
411 lines
13 KiB
Go
411 lines
13 KiB
Go
package capture
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// --- fakes ---
|
|
|
|
type fakeBrain struct {
|
|
writes []Note
|
|
updates []Note
|
|
gets []string
|
|
failOn func(Note) error // nil = always succeed
|
|
hashSeq int
|
|
}
|
|
|
|
func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref {
|
|
f.hashSeq++
|
|
path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md"
|
|
return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded}
|
|
}
|
|
|
|
func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) {
|
|
if f.failOn != nil {
|
|
if err := f.failOn(n); err != nil {
|
|
return Ref{}, err
|
|
}
|
|
}
|
|
f.writes = append(f.writes, n)
|
|
return f.ref("w", n, false), nil
|
|
}
|
|
|
|
func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) {
|
|
if f.failOn != nil {
|
|
if err := f.failOn(n); err != nil {
|
|
return Ref{}, err
|
|
}
|
|
}
|
|
n.Filename = slug
|
|
f.updates = append(f.updates, n)
|
|
return f.ref("u", n, true), nil
|
|
}
|
|
|
|
func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) {
|
|
f.gets = append(f.gets, id)
|
|
return StoredNote{ID: id, Path: id}, nil
|
|
}
|
|
|
|
type fakeTracker struct {
|
|
created []string
|
|
closed []int
|
|
comments []int
|
|
err error
|
|
}
|
|
|
|
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
|
if f.err != nil {
|
|
return IssueRef{}, f.err
|
|
}
|
|
f.created = append(f.created, repo+":"+title)
|
|
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, _ string) (IssueRef, error) {
|
|
if f.err != nil {
|
|
return IssueRef{}, f.err
|
|
}
|
|
f.closed = append(f.closed, number)
|
|
return IssueRef{Repo: repo, Number: number}, nil
|
|
}
|
|
|
|
func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
|
if f.err != nil {
|
|
return IssueRef{}, f.err
|
|
}
|
|
f.comments = append(f.comments, number)
|
|
return IssueRef{Repo: repo, Number: number}, nil
|
|
}
|
|
|
|
type fakeSummary struct {
|
|
paths []string
|
|
content []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.paths = append(f.paths, path)
|
|
f.content = append(f.content, content)
|
|
return nil
|
|
}
|
|
|
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
|
// behaviour without depending on the real defaulting.
|
|
type fakePolicy struct{ tags map[string]classification.Level }
|
|
|
|
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
|
if lvl, ok := p.tags[t.Name]; ok {
|
|
return lvl
|
|
}
|
|
return classification.Internal
|
|
}
|
|
|
|
type fakeAudit struct {
|
|
entries []AuditEntry
|
|
err error
|
|
}
|
|
|
|
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.entries = append(f.entries, e)
|
|
return nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
|
s := NewService(b, tr, sw, p, a)
|
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
|
return s
|
|
}
|
|
|
|
func baseCtx() CaptureContext {
|
|
return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"}
|
|
}
|
|
|
|
// --- scenarios ---
|
|
|
|
func TestCaptureHappyPath(t *testing.T) {
|
|
b := &fakeBrain{}
|
|
tr := &fakeTracker{}
|
|
au := &fakeAudit{}
|
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
|
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Insights: []Insight{
|
|
{Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""},
|
|
{Text: "b", Wing: "hyperguild", Hall: "facts"},
|
|
},
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, rec.Insights, 2)
|
|
for _, r := range rec.Insights {
|
|
assert.True(t, r.OK)
|
|
assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned")
|
|
}
|
|
require.Len(t, rec.Tickets, 1)
|
|
assert.True(t, rec.Tickets[0].OK)
|
|
assert.Equal(t, 2, len(b.writes))
|
|
assert.Empty(t, rec.Errors)
|
|
// Audit emitted naming principal/harness + items that landed.
|
|
require.Len(t, au.entries, 1)
|
|
assert.Equal(t, "mathias", au.entries[0].Principal)
|
|
assert.Equal(t, "claude-code", au.entries[0].Harness)
|
|
assert.Len(t, au.entries[0].Items, 3)
|
|
}
|
|
|
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
|
b := &fakeBrain{}
|
|
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
|
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, b.writes, "supersede must not create")
|
|
require.Len(t, b.updates, 1)
|
|
assert.Equal(t, "prior-note", b.updates[0].Filename)
|
|
assert.True(t, rec.Insights[0].Superseded)
|
|
}
|
|
|
|
func TestCaptureValidationFailClosed(t *testing.T) {
|
|
b := &fakeBrain{}
|
|
tr := &fakeTracker{}
|
|
au := &fakeAudit{}
|
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
|
|
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Insights: []Insight{
|
|
{Text: "ok", Wing: "hyperguild", Hall: "facts"},
|
|
{Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall
|
|
},
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
|
})
|
|
require.Error(t, err)
|
|
// Nothing written anywhere.
|
|
assert.Empty(t, b.writes)
|
|
assert.Empty(t, b.updates)
|
|
assert.Empty(t, tr.created)
|
|
assert.Empty(t, au.entries)
|
|
}
|
|
|
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
|
})
|
|
require.Error(t, err)
|
|
|
|
_, err = svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number
|
|
})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestCapturePartialFailureBestEffort(t *testing.T) {
|
|
b := &fakeBrain{failOn: func(n Note) error {
|
|
if strings.Contains(n.Content, "FAIL") {
|
|
return errors.New("disk full")
|
|
}
|
|
return nil
|
|
}}
|
|
tr := &fakeTracker{}
|
|
au := &fakeAudit{}
|
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
|
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
Insights: []Insight{
|
|
{Text: "good one", Wing: "hyperguild", Hall: "facts"},
|
|
{Text: "FAIL here", Wing: "hyperguild", Hall: "facts"},
|
|
},
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
|
})
|
|
require.NoError(t, err, "partial failure is not a request-level error")
|
|
assert.True(t, rec.Insights[0].OK)
|
|
assert.False(t, rec.Insights[1].OK)
|
|
assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback")
|
|
require.Len(t, rec.Errors, 1)
|
|
assert.Equal(t, "insight[1]", rec.Errors[0].Item)
|
|
// Audit reflects exactly what landed: 1 insight + 1 ticket.
|
|
require.Len(t, au.entries, 1)
|
|
assert.Len(t, au.entries[0].Items, 2)
|
|
}
|
|
|
|
func TestCaptureDryRunWritesNothing(t *testing.T) {
|
|
b := &fakeBrain{}
|
|
tr := &fakeTracker{}
|
|
au := &fakeAudit{}
|
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
|
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: baseCtx(),
|
|
DryRun: true,
|
|
Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}},
|
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, rec.DryRun)
|
|
assert.Len(t, rec.Insights, 1)
|
|
assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok")
|
|
// Nothing written anywhere, including audit.
|
|
assert.Empty(t, b.writes)
|
|
assert.Empty(t, tr.created)
|
|
assert.Empty(t, au.entries)
|
|
}
|
|
|
|
func TestCaptureStricterClassificationWins(t *testing.T) {
|
|
// Caller declares internal; target wing tagged confidential → effective confidential + security event.
|
|
b := &fakeBrain{}
|
|
au := &fakeAudit{}
|
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
|
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
|
|
|
ctx := baseCtx()
|
|
ctx.Classification = "internal"
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: ctx,
|
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
|
require.Len(t, au.entries, 1)
|
|
assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event")
|
|
assert.Equal(t, "confidential", au.entries[0].EffectiveClassification)
|
|
}
|
|
|
|
func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
|
au := &fakeAudit{}
|
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
|
|
|
ctx := baseCtx()
|
|
ctx.Classification = "confidential"
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: ctx,
|
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
|
}
|
|
|
|
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
|
sw := &fakeSummary{}
|
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
|
|
|
ctx := baseCtx()
|
|
ctx.Fidelity = "transcript-parse"
|
|
ctx.SessionRef = "abc123def456"
|
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
Context: ctx,
|
|
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, rec.Summary)
|
|
assert.True(t, rec.Summary.OK)
|
|
require.Len(t, sw.paths, 1)
|
|
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
|
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)
|
|
}
|