feat(trust): Phase 3 — approval workflow, audit log, dynamic policies, scope wildcards

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 04:02:47 +00:00
parent fc21d01a71
commit 9b65defdd8
9 changed files with 1513 additions and 6 deletions

View file

@ -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.
---

174
trust/approval.go Normal file
View file

@ -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 &copy
}
// 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)
}

296
trust/approval_test.go Normal file
View file

@ -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)
}

125
trust/audit.go Normal file
View file

@ -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
}

281
trust/audit_test.go Normal file
View file

@ -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)
}

137
trust/config.go Normal file
View file

@ -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
}

256
trust/config_test.go Normal file
View file

@ -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{}))
}

View file

@ -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
}

197
trust/scope_test.go Normal file
View file

@ -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)
}