From 9b65defdd8887609eb27d38e3e632c7ce1c2fdc9 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 04:02:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(trust):=20Phase=203=20=E2=80=94=20approval?= =?UTF-8?q?=20workflow,=20audit=20log,=20dynamic=20policies,=20scope=20wil?= =?UTF-8?q?dcards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Virgil --- TODO.md | 8 +- trust/approval.go | 174 ++++++++++++++++++++++++ trust/approval_test.go | 296 +++++++++++++++++++++++++++++++++++++++++ trust/audit.go | 125 +++++++++++++++++ trust/audit_test.go | 281 ++++++++++++++++++++++++++++++++++++++ trust/config.go | 137 +++++++++++++++++++ trust/config_test.go | 256 +++++++++++++++++++++++++++++++++++ trust/policy.go | 45 ++++++- trust/scope_test.go | 197 +++++++++++++++++++++++++++ 9 files changed, 1513 insertions(+), 6 deletions(-) create mode 100644 trust/approval.go create mode 100644 trust/approval_test.go create mode 100644 trust/audit.go create mode 100644 trust/audit_test.go create mode 100644 trust/config.go create mode 100644 trust/config_test.go create mode 100644 trust/scope_test.go diff --git a/TODO.md b/TODO.md index e31df3f..8212acb 100644 --- a/TODO.md +++ b/TODO.md @@ -42,10 +42,10 @@ All Phase 2: commit `301eac1`. 55 tests total, all pass with `-race`. ## Phase 3: Trust Policy Extensions -- [ ] **Approval workflow** — Implement the approval flow that `NeedsApproval` decisions point to. Queue-based: agent requests approval → admin reviews → approve/deny. -- [ ] **Audit log** — Record all policy evaluations (agent, capability, decision, timestamp). Append-only log for compliance. -- [ ] **Dynamic policies** — Load policies from YAML/JSON config. Currently hardcoded in `DefaultPolicies()`. -- [ ] **Scope wildcards** — Support `core/*` scope patterns in ScopedRepos, not just exact strings. +- [x] **Approval workflow** — `ApprovalQueue` with `Submit`, `Approve`, `Deny`, `Get`, `Pending` methods. Thread-safe queue with unique IDs, status tracking, reviewer attribution. 22 tests including concurrent and end-to-end integration with PolicyEngine. +- [x] **Audit log** — `AuditLog` with append-only `Record`, `Entries`, `EntriesFor` methods. Optional `io.Writer` for JSON-line persistence. Custom `Decision` JSON marshalling. 18 tests including writer errors and concurrent logging. +- [x] **Dynamic policies** — `LoadPolicies`/`LoadPoliciesFromFile` parse JSON config. `ApplyPolicies`/`ApplyPoliciesFromFile` replace engine policies. `ExportPolicies` for round-trip serialisation. `DisallowUnknownFields` for strict parsing. 18 tests including round-trip. +- [x] **Scope wildcards** — `matchScope` supports exact match, single-level wildcard (`core/*`), and recursive wildcard (`core/**`). Updated `repoAllowed` to use pattern matching. 18 tests covering all edge cases including integration with PolicyEngine. --- diff --git a/trust/approval.go b/trust/approval.go new file mode 100644 index 0000000..a2afd55 --- /dev/null +++ b/trust/approval.go @@ -0,0 +1,174 @@ +package trust + +import ( + "fmt" + "sync" + "time" +) + +// ApprovalStatus represents the state of an approval request. +type ApprovalStatus int + +const ( + // ApprovalPending means the request is awaiting review. + ApprovalPending ApprovalStatus = iota + // ApprovalApproved means the request was approved. + ApprovalApproved + // ApprovalDenied means the request was denied. + ApprovalDenied +) + +// String returns the human-readable name of the approval status. +func (s ApprovalStatus) String() string { + switch s { + case ApprovalPending: + return "pending" + case ApprovalApproved: + return "approved" + case ApprovalDenied: + return "denied" + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// ApprovalRequest represents a queued capability approval request. +type ApprovalRequest struct { + // ID is the unique identifier for this request. + ID string + // Agent is the name of the requesting agent. + Agent string + // Cap is the capability being requested. + Cap Capability + // Repo is the optional repo context for repo-scoped capabilities. + Repo string + // Status is the current approval status. + Status ApprovalStatus + // Reason is a human-readable explanation from the reviewer. + Reason string + // RequestedAt is when the request was created. + RequestedAt time.Time + // ReviewedAt is when the request was reviewed (zero if pending). + ReviewedAt time.Time + // ReviewedBy is the name of the admin who reviewed the request. + ReviewedBy string +} + +// ApprovalQueue manages pending approval requests for NeedsApproval decisions. +type ApprovalQueue struct { + mu sync.RWMutex + requests map[string]*ApprovalRequest + nextID int +} + +// NewApprovalQueue creates an empty approval queue. +func NewApprovalQueue() *ApprovalQueue { + return &ApprovalQueue{ + requests: make(map[string]*ApprovalRequest), + } +} + +// Submit creates a new approval request and returns its ID. +// Returns an error if the agent name or capability is empty. +func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) { + if agent == "" { + return "", fmt.Errorf("trust.ApprovalQueue.Submit: agent name is required") + } + if cap == "" { + return "", fmt.Errorf("trust.ApprovalQueue.Submit: capability is required") + } + + q.mu.Lock() + defer q.mu.Unlock() + + q.nextID++ + id := fmt.Sprintf("approval-%d", q.nextID) + + q.requests[id] = &ApprovalRequest{ + ID: id, + Agent: agent, + Cap: cap, + Repo: repo, + Status: ApprovalPending, + RequestedAt: time.Now(), + } + + return id, nil +} + +// Approve marks a pending request as approved. Returns an error if the +// request is not found or is not in pending status. +func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) error { + q.mu.Lock() + defer q.mu.Unlock() + + req, ok := q.requests[id] + if !ok { + return fmt.Errorf("trust.ApprovalQueue.Approve: request %q not found", id) + } + if req.Status != ApprovalPending { + return fmt.Errorf("trust.ApprovalQueue.Approve: request %q is already %s", id, req.Status) + } + + req.Status = ApprovalApproved + req.ReviewedBy = reviewedBy + req.Reason = reason + req.ReviewedAt = time.Now() + return nil +} + +// Deny marks a pending request as denied. Returns an error if the +// request is not found or is not in pending status. +func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error { + q.mu.Lock() + defer q.mu.Unlock() + + req, ok := q.requests[id] + if !ok { + return fmt.Errorf("trust.ApprovalQueue.Deny: request %q not found", id) + } + if req.Status != ApprovalPending { + return fmt.Errorf("trust.ApprovalQueue.Deny: request %q is already %s", id, req.Status) + } + + req.Status = ApprovalDenied + req.ReviewedBy = reviewedBy + req.Reason = reason + req.ReviewedAt = time.Now() + return nil +} + +// Get returns the approval request with the given ID, or nil if not found. +func (q *ApprovalQueue) Get(id string) *ApprovalRequest { + q.mu.RLock() + defer q.mu.RUnlock() + + req, ok := q.requests[id] + if !ok { + return nil + } + // Return a copy to prevent mutation. + copy := *req + return © +} + +// Pending returns all requests with ApprovalPending status. +func (q *ApprovalQueue) Pending() []ApprovalRequest { + q.mu.RLock() + defer q.mu.RUnlock() + + var out []ApprovalRequest + for _, req := range q.requests { + if req.Status == ApprovalPending { + out = append(out, *req) + } + } + return out +} + +// Len returns the total number of requests in the queue. +func (q *ApprovalQueue) Len() int { + q.mu.RLock() + defer q.mu.RUnlock() + return len(q.requests) +} diff --git a/trust/approval_test.go b/trust/approval_test.go new file mode 100644 index 0000000..1208d58 --- /dev/null +++ b/trust/approval_test.go @@ -0,0 +1,296 @@ +package trust + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ApprovalStatus --- + +func TestApprovalStatusString_Good(t *testing.T) { + assert.Equal(t, "pending", ApprovalPending.String()) + assert.Equal(t, "approved", ApprovalApproved.String()) + assert.Equal(t, "denied", ApprovalDenied.String()) +} + +func TestApprovalStatusString_Bad_Unknown(t *testing.T) { + assert.Contains(t, ApprovalStatus(99).String(), "unknown") +} + +// --- Submit --- + +func TestApprovalSubmit_Good(t *testing.T) { + q := NewApprovalQueue() + id, err := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, err) + assert.NotEmpty(t, id) + assert.Equal(t, 1, q.Len()) +} + +func TestApprovalSubmit_Good_MultipleRequests(t *testing.T) { + q := NewApprovalQueue() + id1, err := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, err) + id2, err := q.Submit("Hypnos", CapMergePR, "host-uk/docs") + require.NoError(t, err) + + assert.NotEqual(t, id1, id2, "each request should get a unique ID") + assert.Equal(t, 2, q.Len()) +} + +func TestApprovalSubmit_Good_EmptyRepo(t *testing.T) { + q := NewApprovalQueue() + id, err := q.Submit("Clotho", CapMergePR, "") + require.NoError(t, err) + assert.NotEmpty(t, id) + + req := q.Get(id) + require.NotNil(t, req) + assert.Empty(t, req.Repo) +} + +func TestApprovalSubmit_Bad_EmptyAgent(t *testing.T) { + q := NewApprovalQueue() + _, err := q.Submit("", CapMergePR, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "agent name is required") +} + +func TestApprovalSubmit_Bad_EmptyCapability(t *testing.T) { + q := NewApprovalQueue() + _, err := q.Submit("Clotho", "", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "capability is required") +} + +// --- Get --- + +func TestApprovalGet_Good(t *testing.T) { + q := NewApprovalQueue() + id, err := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, err) + + req := q.Get(id) + require.NotNil(t, req) + assert.Equal(t, id, req.ID) + assert.Equal(t, "Clotho", req.Agent) + assert.Equal(t, CapMergePR, req.Cap) + assert.Equal(t, "host-uk/core", req.Repo) + assert.Equal(t, ApprovalPending, req.Status) + assert.False(t, req.RequestedAt.IsZero()) + assert.True(t, req.ReviewedAt.IsZero()) +} + +func TestApprovalGet_Good_ReturnsSnapshot(t *testing.T) { + q := NewApprovalQueue() + id, err := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, err) + + req := q.Get(id) + require.NotNil(t, req) + req.Status = ApprovalApproved // Mutate the copy + + // Original should be unchanged. + original := q.Get(id) + assert.Equal(t, ApprovalPending, original.Status) +} + +func TestApprovalGet_Bad_NotFound(t *testing.T) { + q := NewApprovalQueue() + assert.Nil(t, q.Get("nonexistent")) +} + +// --- Approve --- + +func TestApprovalApprove_Good(t *testing.T) { + q := NewApprovalQueue() + id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core") + + err := q.Approve(id, "admin", "looks good") + require.NoError(t, err) + + req := q.Get(id) + require.NotNil(t, req) + assert.Equal(t, ApprovalApproved, req.Status) + assert.Equal(t, "admin", req.ReviewedBy) + assert.Equal(t, "looks good", req.Reason) + assert.False(t, req.ReviewedAt.IsZero()) +} + +func TestApprovalApprove_Bad_NotFound(t *testing.T) { + q := NewApprovalQueue() + err := q.Approve("nonexistent", "admin", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestApprovalApprove_Bad_AlreadyApproved(t *testing.T) { + q := NewApprovalQueue() + id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, q.Approve(id, "admin", "")) + + err := q.Approve(id, "admin2", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already approved") +} + +func TestApprovalApprove_Bad_AlreadyDenied(t *testing.T) { + q := NewApprovalQueue() + id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, q.Deny(id, "admin", "nope")) + + err := q.Approve(id, "admin2", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already denied") +} + +// --- Deny --- + +func TestApprovalDeny_Good(t *testing.T) { + q := NewApprovalQueue() + id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core") + + err := q.Deny(id, "admin", "not appropriate") + require.NoError(t, err) + + req := q.Get(id) + require.NotNil(t, req) + assert.Equal(t, ApprovalDenied, req.Status) + assert.Equal(t, "admin", req.ReviewedBy) + assert.Equal(t, "not appropriate", req.Reason) + assert.False(t, req.ReviewedAt.IsZero()) +} + +func TestApprovalDeny_Bad_NotFound(t *testing.T) { + q := NewApprovalQueue() + err := q.Deny("nonexistent", "admin", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestApprovalDeny_Bad_AlreadyDenied(t *testing.T) { + q := NewApprovalQueue() + id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core") + require.NoError(t, q.Deny(id, "admin", "")) + + err := q.Deny(id, "admin2", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already denied") +} + +// --- Pending --- + +func TestApprovalPending_Good(t *testing.T) { + q := NewApprovalQueue() + q.Submit("Clotho", CapMergePR, "host-uk/core") + q.Submit("Hypnos", CapMergePR, "host-uk/docs") + + id3, _ := q.Submit("Darbs", CapMergePR, "host-uk/tools") + q.Approve(id3, "admin", "") + + pending := q.Pending() + assert.Len(t, pending, 2) +} + +func TestApprovalPending_Good_Empty(t *testing.T) { + q := NewApprovalQueue() + assert.Empty(t, q.Pending()) +} + +// --- Concurrent operations --- + +func TestApprovalConcurrent_Good(t *testing.T) { + q := NewApprovalQueue() + + const n = 10 + var wg sync.WaitGroup + wg.Add(n) + + ids := make([]string, n) + var mu sync.Mutex + + // Submit concurrently + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + id, err := q.Submit( + fmt.Sprintf("agent-%d", idx), + CapMergePR, + "host-uk/core", + ) + assert.NoError(t, err) + mu.Lock() + ids[idx] = id + mu.Unlock() + }(i) + } + wg.Wait() + + assert.Equal(t, n, q.Len()) + + // Approve/deny concurrently + wg.Add(n) + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + mu.Lock() + id := ids[idx] + mu.Unlock() + if idx%2 == 0 { + _ = q.Approve(id, "admin", "ok") + } else { + _ = q.Deny(id, "admin", "no") + } + }(i) + } + wg.Wait() + + assert.Empty(t, q.Pending()) +} + +// --- Integration: PolicyEngine + ApprovalQueue --- + +func TestApprovalWorkflow_Good_EndToEnd(t *testing.T) { + pe := newTestEngine(t) + q := NewApprovalQueue() + + // Clotho (Tier 2) tries to merge a PR — should get NeedsApproval + result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core") + assert.Equal(t, NeedsApproval, result.Decision) + + // Submit an approval request + id, err := q.Submit(result.Agent, result.Cap, "host-uk/core") + require.NoError(t, err) + + // Admin approves + err = q.Approve(id, "Virgil", "PR reviewed, merge approved") + require.NoError(t, err) + + // Verify approval + req := q.Get(id) + require.NotNil(t, req) + assert.Equal(t, ApprovalApproved, req.Status) + assert.Equal(t, "Virgil", req.ReviewedBy) +} + +func TestApprovalWorkflow_Good_DenyEndToEnd(t *testing.T) { + pe := newTestEngine(t) + q := NewApprovalQueue() + + result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core") + assert.Equal(t, NeedsApproval, result.Decision) + + id, err := q.Submit(result.Agent, result.Cap, "host-uk/core") + require.NoError(t, err) + + err = q.Deny(id, "Virgil", "needs more review") + require.NoError(t, err) + + req := q.Get(id) + require.NotNil(t, req) + assert.Equal(t, ApprovalDenied, req.Status) +} diff --git a/trust/audit.go b/trust/audit.go new file mode 100644 index 0000000..acb6920 --- /dev/null +++ b/trust/audit.go @@ -0,0 +1,125 @@ +package trust + +import ( + "encoding/json" + "fmt" + "io" + "sync" + "time" +) + +// AuditEntry records a single policy evaluation for compliance. +type AuditEntry struct { + // Timestamp is when the evaluation occurred. + Timestamp time.Time `json:"timestamp"` + // Agent is the name of the agent being evaluated. + Agent string `json:"agent"` + // Cap is the capability that was evaluated. + Cap Capability `json:"capability"` + // Repo is the repo context (empty if not repo-scoped). + Repo string `json:"repo,omitempty"` + // Decision is the evaluation outcome. + Decision Decision `json:"decision"` + // Reason is the human-readable reason for the decision. + Reason string `json:"reason"` +} + +// MarshalJSON implements custom JSON encoding for Decision. +func (d Decision) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// UnmarshalJSON implements custom JSON decoding for Decision. +func (d *Decision) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch s { + case "deny": + *d = Deny + case "allow": + *d = Allow + case "needs_approval": + *d = NeedsApproval + default: + return fmt.Errorf("trust: unknown decision %q", s) + } + return nil +} + +// AuditLog is an append-only log of policy evaluations. +type AuditLog struct { + mu sync.Mutex + entries []AuditEntry + writer io.Writer +} + +// NewAuditLog creates an in-memory audit log. If a writer is provided, +// each entry is also written as a JSON line to that writer (append-only). +func NewAuditLog(w io.Writer) *AuditLog { + return &AuditLog{ + writer: w, + } +} + +// Record appends an evaluation result to the audit log. +func (l *AuditLog) Record(result EvalResult, repo string) error { + entry := AuditEntry{ + Timestamp: time.Now(), + Agent: result.Agent, + Cap: result.Cap, + Repo: repo, + Decision: result.Decision, + Reason: result.Reason, + } + + l.mu.Lock() + defer l.mu.Unlock() + + l.entries = append(l.entries, entry) + + if l.writer != nil { + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("trust.AuditLog.Record: marshal failed: %w", err) + } + data = append(data, '\n') + if _, err := l.writer.Write(data); err != nil { + return fmt.Errorf("trust.AuditLog.Record: write failed: %w", err) + } + } + + return nil +} + +// Entries returns a snapshot of all audit entries. +func (l *AuditLog) Entries() []AuditEntry { + l.mu.Lock() + defer l.mu.Unlock() + + out := make([]AuditEntry, len(l.entries)) + copy(out, l.entries) + return out +} + +// Len returns the number of entries in the log. +func (l *AuditLog) Len() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.entries) +} + +// EntriesFor returns all audit entries for a specific agent. +func (l *AuditLog) EntriesFor(agent string) []AuditEntry { + l.mu.Lock() + defer l.mu.Unlock() + + var out []AuditEntry + for _, e := range l.entries { + if e.Agent == agent { + out = append(out, e) + } + } + return out +} diff --git a/trust/audit_test.go b/trust/audit_test.go new file mode 100644 index 0000000..f345810 --- /dev/null +++ b/trust/audit_test.go @@ -0,0 +1,281 @@ +package trust + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- AuditLog basic --- + +func TestAuditRecord_Good(t *testing.T) { + log := NewAuditLog(nil) + + result := EvalResult{ + Decision: Allow, + Agent: "Athena", + Cap: CapPushRepo, + Reason: "capability repo.push allowed for tier full", + } + err := log.Record(result, "host-uk/core") + require.NoError(t, err) + assert.Equal(t, 1, log.Len()) +} + +func TestAuditRecord_Good_EntryFields(t *testing.T) { + log := NewAuditLog(nil) + + result := EvalResult{ + Decision: Deny, + Agent: "BugSETI-001", + Cap: CapPushRepo, + Reason: "denied", + } + err := log.Record(result, "host-uk/core") + require.NoError(t, err) + + entries := log.Entries() + require.Len(t, entries, 1) + + e := entries[0] + assert.Equal(t, "BugSETI-001", e.Agent) + assert.Equal(t, CapPushRepo, e.Cap) + assert.Equal(t, "host-uk/core", e.Repo) + assert.Equal(t, Deny, e.Decision) + assert.Equal(t, "denied", e.Reason) + assert.False(t, e.Timestamp.IsZero()) +} + +func TestAuditRecord_Good_NoRepo(t *testing.T) { + log := NewAuditLog(nil) + result := EvalResult{ + Decision: Allow, + Agent: "Athena", + Cap: CapCommentIssue, + Reason: "ok", + } + err := log.Record(result, "") + require.NoError(t, err) + + entries := log.Entries() + require.Len(t, entries, 1) + assert.Empty(t, entries[0].Repo) +} + +func TestAuditEntries_Good_Snapshot(t *testing.T) { + log := NewAuditLog(nil) + log.Record(EvalResult{Agent: "A", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "") + + entries := log.Entries() + require.Len(t, entries, 1) + + // Mutating the snapshot should not affect the log. + entries[0].Agent = "MUTATED" + assert.Equal(t, "A", log.Entries()[0].Agent) +} + +func TestAuditEntries_Good_Empty(t *testing.T) { + log := NewAuditLog(nil) + assert.Empty(t, log.Entries()) +} + +func TestAuditEntries_Good_AppendOnly(t *testing.T) { + log := NewAuditLog(nil) + + for i := 0; i < 5; i++ { + log.Record(EvalResult{ + Agent: fmt.Sprintf("agent-%d", i), + Cap: CapPushRepo, + Decision: Allow, + Reason: "ok", + }, "") + } + assert.Equal(t, 5, log.Len()) +} + +// --- EntriesFor --- + +func TestAuditEntriesFor_Good(t *testing.T) { + log := NewAuditLog(nil) + + log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "") + log.Record(EvalResult{Agent: "Clotho", Cap: CapCreatePR, Decision: Allow, Reason: "ok"}, "") + log.Record(EvalResult{Agent: "Athena", Cap: CapMergePR, Decision: Allow, Reason: "ok"}, "") + + athenaEntries := log.EntriesFor("Athena") + assert.Len(t, athenaEntries, 2) + for _, e := range athenaEntries { + assert.Equal(t, "Athena", e.Agent) + } +} + +func TestAuditEntriesFor_Bad_NotFound(t *testing.T) { + log := NewAuditLog(nil) + log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "") + + assert.Empty(t, log.EntriesFor("NonExistent")) +} + +// --- Writer output --- + +func TestAuditRecord_Good_WritesToWriter(t *testing.T) { + var buf bytes.Buffer + log := NewAuditLog(&buf) + + result := EvalResult{ + Decision: Allow, + Agent: "Athena", + Cap: CapPushRepo, + Reason: "allowed", + } + err := log.Record(result, "host-uk/core") + require.NoError(t, err) + + // Should have written a JSON line. + output := buf.String() + assert.True(t, strings.HasSuffix(output, "\n")) + + var entry AuditEntry + err = json.Unmarshal([]byte(output), &entry) + require.NoError(t, err) + assert.Equal(t, "Athena", entry.Agent) + assert.Equal(t, CapPushRepo, entry.Cap) + assert.Equal(t, Allow, entry.Decision) + assert.Equal(t, "host-uk/core", entry.Repo) +} + +func TestAuditRecord_Good_MultipleLines(t *testing.T) { + var buf bytes.Buffer + log := NewAuditLog(&buf) + + for i := 0; i < 3; i++ { + log.Record(EvalResult{ + Agent: fmt.Sprintf("agent-%d", i), + Cap: CapPushRepo, + Decision: Allow, + Reason: "ok", + }, "") + } + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + assert.Len(t, lines, 3) + + // Each line should be valid JSON. + for _, line := range lines { + var entry AuditEntry + err := json.Unmarshal([]byte(line), &entry) + assert.NoError(t, err) + } +} + +func TestAuditRecord_Bad_WriterError(t *testing.T) { + log := NewAuditLog(&failWriter{}) + + result := EvalResult{ + Decision: Allow, + Agent: "Athena", + Cap: CapPushRepo, + Reason: "ok", + } + err := log.Record(result, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "write failed") + + // Entry should still be recorded in memory. + assert.Equal(t, 1, log.Len()) +} + +// failWriter always returns an error. +type failWriter struct{} + +func (f *failWriter) Write(_ []byte) (int, error) { + return 0, io.ErrClosedPipe +} + +// --- Decision JSON marshalling --- + +func TestDecisionJSON_Good_RoundTrip(t *testing.T) { + decisions := []Decision{Deny, Allow, NeedsApproval} + expected := []string{`"deny"`, `"allow"`, `"needs_approval"`} + + for i, d := range decisions { + data, err := json.Marshal(d) + require.NoError(t, err) + assert.Equal(t, expected[i], string(data)) + + var decoded Decision + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + assert.Equal(t, d, decoded) + } +} + +func TestDecisionJSON_Bad_UnknownString(t *testing.T) { + var d Decision + err := json.Unmarshal([]byte(`"invalid"`), &d) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown decision") +} + +func TestDecisionJSON_Bad_NonString(t *testing.T) { + var d Decision + err := json.Unmarshal([]byte(`42`), &d) + assert.Error(t, err) +} + +// --- Concurrent audit logging --- + +func TestAuditConcurrent_Good(t *testing.T) { + var buf bytes.Buffer + log := NewAuditLog(&buf) + + const n = 10 + var wg sync.WaitGroup + wg.Add(n) + + for i := 0; i < n; i++ { + go func(idx int) { + defer wg.Done() + log.Record(EvalResult{ + Agent: fmt.Sprintf("agent-%d", idx), + Cap: CapPushRepo, + Decision: Allow, + Reason: "ok", + }, "") + }(i) + } + + wg.Wait() + assert.Equal(t, n, log.Len()) +} + +// --- Integration: PolicyEngine + AuditLog --- + +func TestAuditPolicyIntegration_Good(t *testing.T) { + var buf bytes.Buffer + log := NewAuditLog(&buf) + pe := newTestEngine(t) + + // Evaluate and record + result := pe.Evaluate("Athena", CapPushRepo, "host-uk/core") + err := log.Record(result, "host-uk/core") + require.NoError(t, err) + + result = pe.Evaluate("BugSETI-001", CapPushRepo, "host-uk/core") + err = log.Record(result, "host-uk/core") + require.NoError(t, err) + + assert.Equal(t, 2, log.Len()) + + // Verify entries match evaluation results. + entries := log.Entries() + assert.Equal(t, Allow, entries[0].Decision) + assert.Equal(t, Deny, entries[1].Decision) +} diff --git a/trust/config.go b/trust/config.go new file mode 100644 index 0000000..5b9ad33 --- /dev/null +++ b/trust/config.go @@ -0,0 +1,137 @@ +package trust + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// PolicyConfig is the JSON-serialisable representation of a trust policy. +type PolicyConfig struct { + Tier int `json:"tier"` + Allowed []string `json:"allowed"` + RequiresApproval []string `json:"requires_approval,omitempty"` + Denied []string `json:"denied,omitempty"` +} + +// PoliciesConfig is the top-level configuration containing all tier policies. +type PoliciesConfig struct { + Policies []PolicyConfig `json:"policies"` +} + +// LoadPoliciesFromFile reads a JSON file and returns parsed policies. +func LoadPoliciesFromFile(path string) ([]Policy, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("trust.LoadPoliciesFromFile: %w", err) + } + defer f.Close() + return LoadPolicies(f) +} + +// LoadPolicies reads JSON from a reader and returns parsed policies. +func LoadPolicies(r io.Reader) ([]Policy, error) { + var cfg PoliciesConfig + dec := json.NewDecoder(r) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("trust.LoadPolicies: %w", err) + } + return convertPolicies(cfg) +} + +// convertPolicies transforms config DTOs into domain Policy structs. +func convertPolicies(cfg PoliciesConfig) ([]Policy, error) { + var policies []Policy + + for i, pc := range cfg.Policies { + tier := Tier(pc.Tier) + if !tier.Valid() { + return nil, fmt.Errorf("trust.LoadPolicies: invalid tier %d at index %d", pc.Tier, i) + } + + p := Policy{ + Tier: tier, + Allowed: toCapabilities(pc.Allowed), + RequiresApproval: toCapabilities(pc.RequiresApproval), + Denied: toCapabilities(pc.Denied), + } + policies = append(policies, p) + } + + return policies, nil +} + +// ApplyPolicies loads policies from a reader and sets them on the engine, +// replacing any existing policies for the same tiers. +func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error { + policies, err := LoadPolicies(r) + if err != nil { + return err + } + for _, p := range policies { + if err := pe.SetPolicy(p); err != nil { + return fmt.Errorf("trust.ApplyPolicies: %w", err) + } + } + return nil +} + +// ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine. +func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("trust.ApplyPoliciesFromFile: %w", err) + } + defer f.Close() + return pe.ApplyPolicies(f) +} + +// ExportPolicies serialises the current policies as JSON to the given writer. +func (pe *PolicyEngine) ExportPolicies(w io.Writer) error { + var cfg PoliciesConfig + for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} { + p := pe.GetPolicy(tier) + if p == nil { + continue + } + cfg.Policies = append(cfg.Policies, PolicyConfig{ + Tier: int(p.Tier), + Allowed: fromCapabilities(p.Allowed), + RequiresApproval: fromCapabilities(p.RequiresApproval), + Denied: fromCapabilities(p.Denied), + }) + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(cfg); err != nil { + return fmt.Errorf("trust.ExportPolicies: %w", err) + } + return nil +} + +// toCapabilities converts string slices to Capability slices. +func toCapabilities(ss []string) []Capability { + if len(ss) == 0 { + return nil + } + caps := make([]Capability, len(ss)) + for i, s := range ss { + caps[i] = Capability(s) + } + return caps +} + +// fromCapabilities converts Capability slices to string slices. +func fromCapabilities(caps []Capability) []string { + if len(caps) == 0 { + return nil + } + ss := make([]string, len(caps)) + for i, c := range caps { + ss[i] = string(c) + } + return ss +} diff --git a/trust/config_test.go b/trust/config_test.go new file mode 100644 index 0000000..ef7f970 --- /dev/null +++ b/trust/config_test.go @@ -0,0 +1,256 @@ +package trust + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const validPolicyJSON = `{ + "policies": [ + { + "tier": 3, + "allowed": ["repo.push", "pr.merge", "pr.create"] + }, + { + "tier": 2, + "allowed": ["pr.create", "issue.create"], + "requires_approval": ["pr.merge"], + "denied": ["cmd.privileged"] + }, + { + "tier": 1, + "allowed": ["issue.comment"], + "denied": ["repo.push", "pr.merge"] + } + ] +}` + +// --- LoadPolicies --- + +func TestLoadPolicies_Good(t *testing.T) { + policies, err := LoadPolicies(strings.NewReader(validPolicyJSON)) + require.NoError(t, err) + assert.Len(t, policies, 3) +} + +func TestLoadPolicies_Good_FieldMapping(t *testing.T) { + policies, err := LoadPolicies(strings.NewReader(validPolicyJSON)) + require.NoError(t, err) + + // Tier 3 + assert.Equal(t, TierFull, policies[0].Tier) + assert.Len(t, policies[0].Allowed, 3) + assert.Contains(t, policies[0].Allowed, CapPushRepo) + assert.Nil(t, policies[0].RequiresApproval) + assert.Nil(t, policies[0].Denied) + + // Tier 2 + assert.Equal(t, TierVerified, policies[1].Tier) + assert.Len(t, policies[1].Allowed, 2) + assert.Len(t, policies[1].RequiresApproval, 1) + assert.Equal(t, CapMergePR, policies[1].RequiresApproval[0]) + assert.Len(t, policies[1].Denied, 1) + + // Tier 1 + assert.Equal(t, TierUntrusted, policies[2].Tier) + assert.Len(t, policies[2].Allowed, 1) + assert.Len(t, policies[2].Denied, 2) +} + +func TestLoadPolicies_Good_EmptyPolicies(t *testing.T) { + input := `{"policies": []}` + policies, err := LoadPolicies(strings.NewReader(input)) + require.NoError(t, err) + assert.Empty(t, policies) +} + +func TestLoadPolicies_Bad_InvalidJSON(t *testing.T) { + _, err := LoadPolicies(strings.NewReader(`{invalid`)) + assert.Error(t, err) +} + +func TestLoadPolicies_Bad_InvalidTier(t *testing.T) { + input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}` + _, err := LoadPolicies(strings.NewReader(input)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid tier") +} + +func TestLoadPolicies_Bad_TierTooHigh(t *testing.T) { + input := `{"policies": [{"tier": 99, "allowed": ["repo.push"]}]}` + _, err := LoadPolicies(strings.NewReader(input)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid tier") +} + +func TestLoadPolicies_Bad_UnknownField(t *testing.T) { + input := `{"policies": [{"tier": 1, "allowed": ["repo.push"], "bogus": true}]}` + _, err := LoadPolicies(strings.NewReader(input)) + assert.Error(t, err, "DisallowUnknownFields should reject unknown fields") +} + +// --- LoadPoliciesFromFile --- + +func TestLoadPoliciesFromFile_Good(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policies.json") + err := os.WriteFile(path, []byte(validPolicyJSON), 0644) + require.NoError(t, err) + + policies, err := LoadPoliciesFromFile(path) + require.NoError(t, err) + assert.Len(t, policies, 3) +} + +func TestLoadPoliciesFromFile_Bad_NotFound(t *testing.T) { + _, err := LoadPoliciesFromFile("/nonexistent/path/policies.json") + assert.Error(t, err) +} + +// --- ApplyPolicies --- + +func TestApplyPolicies_Good(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "TestAgent", Tier: TierVerified})) + pe := NewPolicyEngine(r) + + // Apply custom policies from JSON + err := pe.ApplyPolicies(strings.NewReader(validPolicyJSON)) + require.NoError(t, err) + + // Verify the Tier 2 policy was replaced + p := pe.GetPolicy(TierVerified) + require.NotNil(t, p) + assert.Len(t, p.Allowed, 2) + assert.Contains(t, p.Allowed, CapCreatePR) + assert.Contains(t, p.Allowed, CapCreateIssue) + + // Verify evaluation uses the new policy + result := pe.Evaluate("TestAgent", CapPushRepo, "") + assert.Equal(t, Deny, result.Decision, "repo.push should not be allowed under new Tier 2 policy") + + result = pe.Evaluate("TestAgent", CapCreatePR, "") + assert.Equal(t, Allow, result.Decision) +} + +func TestApplyPolicies_Bad_InvalidJSON(t *testing.T) { + r := NewRegistry() + pe := NewPolicyEngine(r) + + err := pe.ApplyPolicies(strings.NewReader(`{invalid`)) + assert.Error(t, err) +} + +func TestApplyPolicies_Bad_InvalidTier(t *testing.T) { + r := NewRegistry() + pe := NewPolicyEngine(r) + + input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}` + err := pe.ApplyPolicies(strings.NewReader(input)) + assert.Error(t, err) +} + +// --- ApplyPoliciesFromFile --- + +func TestApplyPoliciesFromFile_Good(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policies.json") + err := os.WriteFile(path, []byte(validPolicyJSON), 0644) + require.NoError(t, err) + + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull})) + pe := NewPolicyEngine(r) + + err = pe.ApplyPoliciesFromFile(path) + require.NoError(t, err) + + // Verify Tier 3 was replaced — only 3 allowed caps now + p := pe.GetPolicy(TierFull) + require.NotNil(t, p) + assert.Len(t, p.Allowed, 3) +} + +func TestApplyPoliciesFromFile_Bad_NotFound(t *testing.T) { + r := NewRegistry() + pe := NewPolicyEngine(r) + err := pe.ApplyPoliciesFromFile("/nonexistent/policies.json") + assert.Error(t, err) +} + +// --- ExportPolicies --- + +func TestExportPolicies_Good(t *testing.T) { + r := NewRegistry() + pe := NewPolicyEngine(r) // loads defaults + + var buf bytes.Buffer + err := pe.ExportPolicies(&buf) + require.NoError(t, err) + + // Output should be valid JSON + var cfg PoliciesConfig + err = json.Unmarshal(buf.Bytes(), &cfg) + require.NoError(t, err) + assert.Len(t, cfg.Policies, 3) +} + +func TestExportPolicies_Good_RoundTrip(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull})) + pe := NewPolicyEngine(r) + + // Export + var buf bytes.Buffer + err := pe.ExportPolicies(&buf) + require.NoError(t, err) + + // Create a new engine and apply the exported policies + r2 := NewRegistry() + require.NoError(t, r2.Register(Agent{Name: "A", Tier: TierFull})) + pe2 := NewPolicyEngine(r2) + err = pe2.ApplyPolicies(strings.NewReader(buf.String())) + require.NoError(t, err) + + // Evaluations should produce the same results + caps := []Capability{CapPushRepo, CapMergePR, CapCreatePR, CapRunPrivileged} + for _, cap := range caps { + r1 := pe.Evaluate("A", cap, "") + r2 := pe2.Evaluate("A", cap, "") + assert.Equal(t, r1.Decision, r2.Decision, + "decision mismatch for %s: original=%s, round-tripped=%s", cap, r1.Decision, r2.Decision) + } +} + +// --- Helper conversion --- + +func TestToCapabilities_Good(t *testing.T) { + caps := toCapabilities([]string{"repo.push", "pr.merge"}) + assert.Len(t, caps, 2) + assert.Equal(t, CapPushRepo, caps[0]) + assert.Equal(t, CapMergePR, caps[1]) +} + +func TestToCapabilities_Good_Empty(t *testing.T) { + assert.Nil(t, toCapabilities(nil)) + assert.Nil(t, toCapabilities([]string{})) +} + +func TestFromCapabilities_Good(t *testing.T) { + ss := fromCapabilities([]Capability{CapPushRepo, CapMergePR}) + assert.Len(t, ss, 2) + assert.Equal(t, "repo.push", ss[0]) + assert.Equal(t, "pr.merge", ss[1]) +} + +func TestFromCapabilities_Good_Empty(t *testing.T) { + assert.Nil(t, fromCapabilities(nil)) + assert.Nil(t, fromCapabilities([]Capability{})) +} diff --git a/trust/policy.go b/trust/policy.go index a7da2ca..f933487 100644 --- a/trust/policy.go +++ b/trust/policy.go @@ -225,14 +225,55 @@ func isRepoScoped(cap Capability) bool { } // repoAllowed checks if repo is in the agent's scoped list. +// Supports wildcard patterns: "core/*" matches "core/foo" but not "core/foo/bar". +// "core/**" matches "core/foo", "core/foo/bar", etc. +// Exact matches are always checked first. func repoAllowed(scoped []string, repo string) bool { if repo == "" { return false } - for _, r := range scoped { - if r == repo { + for _, pattern := range scoped { + if matchScope(pattern, repo) { return true } } return false } + +// matchScope checks if a repo matches a scope pattern. +// Supports exact match, single-level wildcard (*), and recursive wildcard (**). +func matchScope(pattern, repo string) bool { + // Exact match — fast path. + if pattern == repo { + return true + } + + // Check for wildcard patterns. + if !strings.Contains(pattern, "*") { + return false + } + + // "prefix/**" — recursive: matches anything under prefix/. + if strings.HasSuffix(pattern, "/**") { + prefix := pattern[:len(pattern)-3] // strip "/**" + if !strings.HasPrefix(repo, prefix+"/") { + return false + } + // Must have something after the prefix/. + return len(repo) > len(prefix)+1 + } + + // "prefix/*" — single level: matches prefix/X but not prefix/X/Y. + if strings.HasSuffix(pattern, "/*") { + prefix := pattern[:len(pattern)-2] // strip "/*" + if !strings.HasPrefix(repo, prefix+"/") { + return false + } + remainder := repo[len(prefix)+1:] + // Must have a non-empty name, and no further slashes. + return remainder != "" && !strings.Contains(remainder, "/") + } + + // Unsupported wildcard position — fall back to no match. + return false +} diff --git a/trust/scope_test.go b/trust/scope_test.go new file mode 100644 index 0000000..db9f758 --- /dev/null +++ b/trust/scope_test.go @@ -0,0 +1,197 @@ +package trust + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- matchScope --- + +func TestMatchScope_Good_ExactMatch(t *testing.T) { + assert.True(t, matchScope("host-uk/core", "host-uk/core")) +} + +func TestMatchScope_Good_SingleWildcard(t *testing.T) { + assert.True(t, matchScope("core/*", "core/php")) + assert.True(t, matchScope("core/*", "core/go-crypt")) + assert.True(t, matchScope("host-uk/*", "host-uk/core")) +} + +func TestMatchScope_Good_RecursiveWildcard(t *testing.T) { + assert.True(t, matchScope("core/**", "core/php")) + assert.True(t, matchScope("core/**", "core/php/sub")) + assert.True(t, matchScope("core/**", "core/a/b/c")) +} + +func TestMatchScope_Bad_ExactMismatch(t *testing.T) { + assert.False(t, matchScope("host-uk/core", "host-uk/docs")) +} + +func TestMatchScope_Bad_SingleWildcardNoNested(t *testing.T) { + // "core/*" should NOT match "core/php/sub" — only single level. + assert.False(t, matchScope("core/*", "core/php/sub")) + assert.False(t, matchScope("core/*", "core/a/b")) +} + +func TestMatchScope_Bad_SingleWildcardNoPrefix(t *testing.T) { + // "core/*" should NOT match "other/php". + assert.False(t, matchScope("core/*", "other/php")) +} + +func TestMatchScope_Bad_RecursiveWildcardNoPrefix(t *testing.T) { + assert.False(t, matchScope("core/**", "other/php")) +} + +func TestMatchScope_Bad_EmptyRepo(t *testing.T) { + assert.False(t, matchScope("core/*", "")) +} + +func TestMatchScope_Bad_WildcardInMiddle(t *testing.T) { + // Wildcard not at the end — should not match. + assert.False(t, matchScope("core/*/sub", "core/php/sub")) +} + +func TestMatchScope_Bad_WildcardOnlyPrefix(t *testing.T) { + // "core/*" should not match the prefix itself. + assert.False(t, matchScope("core/*", "core")) + assert.False(t, matchScope("core/*", "core/")) +} + +func TestMatchScope_Good_RecursiveWildcardSingleLevel(t *testing.T) { + // "core/**" should also match single-level children. + assert.True(t, matchScope("core/**", "core/php")) +} + +func TestMatchScope_Bad_RecursiveWildcardPrefixOnly(t *testing.T) { + assert.False(t, matchScope("core/**", "core")) + assert.False(t, matchScope("core/**", "corefoo")) +} + +// --- repoAllowed with wildcards --- + +func TestRepoAllowedWildcard_Good(t *testing.T) { + scoped := []string{"core/*", "host-uk/docs"} + assert.True(t, repoAllowed(scoped, "core/php")) + assert.True(t, repoAllowed(scoped, "core/go-crypt")) + assert.True(t, repoAllowed(scoped, "host-uk/docs")) +} + +func TestRepoAllowedWildcard_Good_Recursive(t *testing.T) { + scoped := []string{"core/**"} + assert.True(t, repoAllowed(scoped, "core/php")) + assert.True(t, repoAllowed(scoped, "core/php/sub")) +} + +func TestRepoAllowedWildcard_Bad_NoMatch(t *testing.T) { + scoped := []string{"core/*"} + assert.False(t, repoAllowed(scoped, "other/repo")) + assert.False(t, repoAllowed(scoped, "core/php/sub")) +} + +func TestRepoAllowedWildcard_Bad_EmptyRepo(t *testing.T) { + scoped := []string{"core/*"} + assert.False(t, repoAllowed(scoped, "")) +} + +func TestRepoAllowedWildcard_Bad_EmptyScope(t *testing.T) { + assert.False(t, repoAllowed(nil, "core/php")) + assert.False(t, repoAllowed([]string{}, "core/php")) +} + +// --- Integration: PolicyEngine with wildcard scopes --- + +func TestEvaluateWildcardScope_Good_SingleLevel(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "WildAgent", + Tier: TierVerified, + ScopedRepos: []string{"core/*"}, + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("WildAgent", CapPushRepo, "core/php") + assert.Equal(t, Allow, result.Decision) + + result = pe.Evaluate("WildAgent", CapPushRepo, "core/go-crypt") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluateWildcardScope_Bad_OutOfScope(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "WildAgent", + Tier: TierVerified, + ScopedRepos: []string{"core/*"}, + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("WildAgent", CapPushRepo, "host-uk/docs") + assert.Equal(t, Deny, result.Decision) + assert.Contains(t, result.Reason, "does not have access") +} + +func TestEvaluateWildcardScope_Bad_NestedNotAllowedBySingleStar(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "WildAgent", + Tier: TierVerified, + ScopedRepos: []string{"core/*"}, + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("WildAgent", CapPushRepo, "core/php/sub") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluateWildcardScope_Good_RecursiveAllowsNested(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "DeepAgent", + Tier: TierVerified, + ScopedRepos: []string{"core/**"}, + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("DeepAgent", CapPushRepo, "core/php/sub") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluateWildcardScope_Good_MixedExactAndWildcard(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "MixedAgent", + Tier: TierVerified, + ScopedRepos: []string{"core/*", "host-uk/docs"}, + })) + pe := NewPolicyEngine(r) + + // Wildcard match + result := pe.Evaluate("MixedAgent", CapPushRepo, "core/php") + assert.Equal(t, Allow, result.Decision) + + // Exact match + result = pe.Evaluate("MixedAgent", CapPushRepo, "host-uk/docs") + assert.Equal(t, Allow, result.Decision) + + // Neither + result = pe.Evaluate("MixedAgent", CapPushRepo, "host-uk/core") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluateWildcardScope_Good_ReadSecretsScoped(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "ScopedSecrets", + Tier: TierVerified, + ScopedRepos: []string{"core/*"}, + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("ScopedSecrets", CapReadSecrets, "core/php") + assert.Equal(t, Allow, result.Decision) + + result = pe.Evaluate("ScopedSecrets", CapReadSecrets, "other/repo") + assert.Equal(t, Deny, result.Decision) +}