diff --git a/pkg/trust/policy.go b/pkg/trust/policy.go new file mode 100644 index 0000000..a7da2ca --- /dev/null +++ b/pkg/trust/policy.go @@ -0,0 +1,238 @@ +package trust + +import ( + "fmt" + "strings" +) + +// Policy defines the access rules for a given trust tier. +type Policy struct { + // Tier is the trust level this policy applies to. + Tier Tier + // Allowed lists the capabilities granted at this tier. + Allowed []Capability + // RequiresApproval lists capabilities that need human/higher-tier approval. + RequiresApproval []Capability + // Denied lists explicitly denied capabilities. + Denied []Capability +} + +// PolicyEngine evaluates capability requests against registered policies. +type PolicyEngine struct { + registry *Registry + policies map[Tier]*Policy +} + +// Decision is the result of a policy evaluation. +type Decision int + +const ( + // Deny means the action is not permitted. + Deny Decision = iota + // Allow means the action is permitted. + Allow + // NeedsApproval means the action requires human or higher-tier approval. + NeedsApproval +) + +// String returns the human-readable name of the decision. +func (d Decision) String() string { + switch d { + case Deny: + return "deny" + case Allow: + return "allow" + case NeedsApproval: + return "needs_approval" + default: + return fmt.Sprintf("unknown(%d)", int(d)) + } +} + +// EvalResult contains the outcome of a capability evaluation. +type EvalResult struct { + Decision Decision + Agent string + Cap Capability + Reason string +} + +// NewPolicyEngine creates a policy engine with the given registry and default policies. +func NewPolicyEngine(registry *Registry) *PolicyEngine { + pe := &PolicyEngine{ + registry: registry, + policies: make(map[Tier]*Policy), + } + pe.loadDefaults() + return pe +} + +// Evaluate checks whether the named agent can perform the given capability. +// If the agent has scoped repos and the capability is repo-scoped, the repo +// parameter is checked against the agent's allowed repos. +func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string) EvalResult { + agent := pe.registry.Get(agentName) + if agent == nil { + return EvalResult{ + Decision: Deny, + Agent: agentName, + Cap: cap, + Reason: "agent not registered", + } + } + + policy, ok := pe.policies[agent.Tier] + if !ok { + return EvalResult{ + Decision: Deny, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("no policy for tier %s", agent.Tier), + } + } + + // Check explicit denials first. + for _, denied := range policy.Denied { + if denied == cap { + return EvalResult{ + Decision: Deny, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("capability %s is denied for tier %s", cap, agent.Tier), + } + } + } + + // Check if capability requires approval. + for _, approval := range policy.RequiresApproval { + if approval == cap { + return EvalResult{ + Decision: NeedsApproval, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier), + } + } + } + + // Check if capability is allowed. + for _, allowed := range policy.Allowed { + if allowed == cap { + // For repo-scoped capabilities, verify repo access. + if isRepoScoped(cap) && len(agent.ScopedRepos) > 0 { + if !repoAllowed(agent.ScopedRepos, repo) { + return EvalResult{ + Decision: Deny, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("agent %q does not have access to repo %q", agentName, repo), + } + } + } + return EvalResult{ + Decision: Allow, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("capability %s allowed for tier %s", cap, agent.Tier), + } + } + } + + return EvalResult{ + Decision: Deny, + Agent: agentName, + Cap: cap, + Reason: fmt.Sprintf("capability %s not granted for tier %s", cap, agent.Tier), + } +} + +// SetPolicy replaces the policy for a given tier. +func (pe *PolicyEngine) SetPolicy(p Policy) error { + if !p.Tier.Valid() { + return fmt.Errorf("trust.SetPolicy: invalid tier %d", p.Tier) + } + pe.policies[p.Tier] = &p + return nil +} + +// GetPolicy returns the policy for a tier, or nil if none is set. +func (pe *PolicyEngine) GetPolicy(t Tier) *Policy { + return pe.policies[t] +} + +// loadDefaults installs the default trust policies from the issue spec. +func (pe *PolicyEngine) loadDefaults() { + // Tier 3 — Full Trust + pe.policies[TierFull] = &Policy{ + Tier: TierFull, + Allowed: []Capability{ + CapPushRepo, + CapMergePR, + CapCreatePR, + CapCreateIssue, + CapCommentIssue, + CapReadSecrets, + CapRunPrivileged, + CapAccessWorkspace, + CapModifyFlows, + }, + } + + // Tier 2 — Verified + pe.policies[TierVerified] = &Policy{ + Tier: TierVerified, + Allowed: []Capability{ + CapPushRepo, // scoped to assigned repos + CapCreatePR, // can create, not merge + CapCreateIssue, + CapCommentIssue, + CapReadSecrets, // scoped to their repos + }, + RequiresApproval: []Capability{ + CapMergePR, + }, + Denied: []Capability{ + CapAccessWorkspace, // cannot access other agents' workspaces + CapModifyFlows, + CapRunPrivileged, + }, + } + + // Tier 1 — Untrusted + pe.policies[TierUntrusted] = &Policy{ + Tier: TierUntrusted, + Allowed: []Capability{ + CapCreatePR, // fork only, checked at enforcement layer + CapCommentIssue, + }, + Denied: []Capability{ + CapPushRepo, + CapMergePR, + CapCreateIssue, + CapReadSecrets, + CapRunPrivileged, + CapAccessWorkspace, + CapModifyFlows, + }, + } +} + +// isRepoScoped returns true if the capability is constrained by repo scope. +func isRepoScoped(cap Capability) bool { + return strings.HasPrefix(string(cap), "repo.") || + strings.HasPrefix(string(cap), "pr.") || + cap == CapReadSecrets +} + +// repoAllowed checks if repo is in the agent's scoped list. +func repoAllowed(scoped []string, repo string) bool { + if repo == "" { + return false + } + for _, r := range scoped { + if r == repo { + return true + } + } + return false +} diff --git a/pkg/trust/policy_test.go b/pkg/trust/policy_test.go new file mode 100644 index 0000000..cf975d4 --- /dev/null +++ b/pkg/trust/policy_test.go @@ -0,0 +1,268 @@ +package trust + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestEngine(t *testing.T) *PolicyEngine { + t.Helper() + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "Athena", + Tier: TierFull, + })) + require.NoError(t, r.Register(Agent{ + Name: "Clotho", + Tier: TierVerified, + ScopedRepos: []string{"host-uk/core", "host-uk/docs"}, + })) + require.NoError(t, r.Register(Agent{ + Name: "BugSETI-001", + Tier: TierUntrusted, + })) + return NewPolicyEngine(r) +} + +// --- Decision --- + +func TestDecisionString_Good(t *testing.T) { + assert.Equal(t, "deny", Deny.String()) + assert.Equal(t, "allow", Allow.String()) + assert.Equal(t, "needs_approval", NeedsApproval.String()) +} + +func TestDecisionString_Bad_Unknown(t *testing.T) { + assert.Contains(t, Decision(99).String(), "unknown") +} + +// --- Tier 3 (Full Trust) --- + +func TestEvaluate_Good_Tier3CanDoAnything(t *testing.T) { + pe := newTestEngine(t) + + caps := []Capability{ + CapPushRepo, CapMergePR, CapCreatePR, CapCreateIssue, + CapCommentIssue, CapReadSecrets, CapRunPrivileged, + CapAccessWorkspace, CapModifyFlows, + } + for _, cap := range caps { + result := pe.Evaluate("Athena", cap, "") + assert.Equal(t, Allow, result.Decision, "Athena should be allowed %s", cap) + } +} + +// --- Tier 2 (Verified) --- + +func TestEvaluate_Good_Tier2CanCreatePR(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapCreatePR, "host-uk/core") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluate_Good_Tier2CanPushToScopedRepo(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapPushRepo, "host-uk/core") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluate_Good_Tier2NeedsApprovalToMerge(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core") + assert.Equal(t, NeedsApproval, result.Decision) +} + +func TestEvaluate_Good_Tier2CanCreateIssue(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapCreateIssue, "") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluate_Bad_Tier2CannotAccessWorkspace(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapAccessWorkspace, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier2CannotModifyFlows(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapModifyFlows, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier2CannotRunPrivileged(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapRunPrivileged, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier2CannotPushToUnscopedRepo(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Clotho", CapPushRepo, "host-uk/secret-repo") + assert.Equal(t, Deny, result.Decision) + assert.Contains(t, result.Reason, "does not have access") +} + +func TestEvaluate_Bad_Tier2RepoScopeEmptyRepo(t *testing.T) { + pe := newTestEngine(t) + // Push without specifying a repo should be denied for scoped agents. + result := pe.Evaluate("Clotho", CapPushRepo, "") + assert.Equal(t, Deny, result.Decision) +} + +// --- Tier 1 (Untrusted) --- + +func TestEvaluate_Good_Tier1CanCreatePR(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapCreatePR, "") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluate_Good_Tier1CanCommentIssue(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapCommentIssue, "") + assert.Equal(t, Allow, result.Decision) +} + +func TestEvaluate_Bad_Tier1CannotPush(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapPushRepo, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier1CannotMerge(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapMergePR, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier1CannotCreateIssue(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapCreateIssue, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier1CannotReadSecrets(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapReadSecrets, "") + assert.Equal(t, Deny, result.Decision) +} + +func TestEvaluate_Bad_Tier1CannotRunPrivileged(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("BugSETI-001", CapRunPrivileged, "") + assert.Equal(t, Deny, result.Decision) +} + +// --- Edge cases --- + +func TestEvaluate_Bad_UnknownAgent(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Unknown", CapCreatePR, "") + assert.Equal(t, Deny, result.Decision) + assert.Contains(t, result.Reason, "not registered") +} + +func TestEvaluate_Good_EvalResultFields(t *testing.T) { + pe := newTestEngine(t) + result := pe.Evaluate("Athena", CapPushRepo, "") + assert.Equal(t, "Athena", result.Agent) + assert.Equal(t, CapPushRepo, result.Cap) + assert.NotEmpty(t, result.Reason) +} + +// --- SetPolicy --- + +func TestSetPolicy_Good(t *testing.T) { + pe := newTestEngine(t) + err := pe.SetPolicy(Policy{ + Tier: TierVerified, + Allowed: []Capability{CapPushRepo, CapMergePR}, + }) + require.NoError(t, err) + + // Verify the new policy is in effect. + result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core") + assert.Equal(t, Allow, result.Decision) +} + +func TestSetPolicy_Bad_InvalidTier(t *testing.T) { + pe := newTestEngine(t) + err := pe.SetPolicy(Policy{Tier: Tier(0)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid tier") +} + +func TestGetPolicy_Good(t *testing.T) { + pe := newTestEngine(t) + p := pe.GetPolicy(TierFull) + require.NotNil(t, p) + assert.Equal(t, TierFull, p.Tier) +} + +func TestGetPolicy_Bad_NotFound(t *testing.T) { + pe := newTestEngine(t) + assert.Nil(t, pe.GetPolicy(Tier(99))) +} + +// --- isRepoScoped / repoAllowed helpers --- + +func TestIsRepoScoped_Good(t *testing.T) { + assert.True(t, isRepoScoped(CapPushRepo)) + assert.True(t, isRepoScoped(CapCreatePR)) + assert.True(t, isRepoScoped(CapMergePR)) + assert.True(t, isRepoScoped(CapReadSecrets)) +} + +func TestIsRepoScoped_Bad_NotScoped(t *testing.T) { + assert.False(t, isRepoScoped(CapRunPrivileged)) + assert.False(t, isRepoScoped(CapAccessWorkspace)) + assert.False(t, isRepoScoped(CapModifyFlows)) +} + +func TestRepoAllowed_Good(t *testing.T) { + scoped := []string{"host-uk/core", "host-uk/docs"} + assert.True(t, repoAllowed(scoped, "host-uk/core")) + assert.True(t, repoAllowed(scoped, "host-uk/docs")) +} + +func TestRepoAllowed_Bad_NotInScope(t *testing.T) { + scoped := []string{"host-uk/core"} + assert.False(t, repoAllowed(scoped, "host-uk/secret")) +} + +func TestRepoAllowed_Bad_EmptyRepo(t *testing.T) { + scoped := []string{"host-uk/core"} + assert.False(t, repoAllowed(scoped, "")) +} + +func TestRepoAllowed_Bad_EmptyScope(t *testing.T) { + assert.False(t, repoAllowed(nil, "host-uk/core")) + assert.False(t, repoAllowed([]string{}, "host-uk/core")) +} + +// --- Tier 3 ignores repo scoping --- + +func TestEvaluate_Good_Tier3IgnoresRepoScope(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{ + Name: "Virgil", + Tier: TierFull, + ScopedRepos: []string{}, // empty scope should not restrict Tier 3 + })) + pe := NewPolicyEngine(r) + + result := pe.Evaluate("Virgil", CapPushRepo, "any-repo") + assert.Equal(t, Allow, result.Decision) +} + +// --- Default rate limits --- + +func TestDefaultRateLimit(t *testing.T) { + assert.Equal(t, 10, defaultRateLimit(TierUntrusted)) + assert.Equal(t, 60, defaultRateLimit(TierVerified)) + assert.Equal(t, 0, defaultRateLimit(TierFull)) + assert.Equal(t, 10, defaultRateLimit(Tier(99))) // unknown defaults to 10 +} diff --git a/pkg/trust/trust.go b/pkg/trust/trust.go new file mode 100644 index 0000000..d5c0636 --- /dev/null +++ b/pkg/trust/trust.go @@ -0,0 +1,165 @@ +// Package trust implements an agent trust model with tiered access control. +// +// Agents are assigned trust tiers that determine their capabilities: +// +// - Tier 3 (Full Trust): Internal agents with full access (e.g., Athena, Virgil, Charon) +// - Tier 2 (Verified): Partner agents with scoped access (e.g., Clotho, Hypnos) +// - Tier 1 (Untrusted): External/community agents with minimal access +// +// The package provides a Registry for managing agent identities and a PolicyEngine +// for evaluating capability requests against trust policies. +package trust + +import ( + "fmt" + "sync" + "time" +) + +// Tier represents an agent's trust level in the system. +type Tier int + +const ( + // TierUntrusted is for external/community agents with minimal access. + TierUntrusted Tier = 1 + // TierVerified is for partner agents with scoped access. + TierVerified Tier = 2 + // TierFull is for internal agents with full access. + TierFull Tier = 3 +) + +// String returns the human-readable name of the tier. +func (t Tier) String() string { + switch t { + case TierUntrusted: + return "untrusted" + case TierVerified: + return "verified" + case TierFull: + return "full" + default: + return fmt.Sprintf("unknown(%d)", int(t)) + } +} + +// Valid returns true if the tier is a recognised trust level. +func (t Tier) Valid() bool { + return t >= TierUntrusted && t <= TierFull +} + +// Capability represents a specific action an agent can perform. +type Capability string + +const ( + CapPushRepo Capability = "repo.push" + CapMergePR Capability = "pr.merge" + CapCreatePR Capability = "pr.create" + CapCreateIssue Capability = "issue.create" + CapCommentIssue Capability = "issue.comment" + CapReadSecrets Capability = "secrets.read" + CapRunPrivileged Capability = "cmd.privileged" + CapAccessWorkspace Capability = "workspace.access" + CapModifyFlows Capability = "flows.modify" +) + +// Agent represents an agent identity in the trust system. +type Agent struct { + // Name is the unique identifier for the agent (e.g., "Athena", "Clotho"). + Name string + // Tier is the agent's trust level. + Tier Tier + // ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access. + // Tier 3 agents ignore this field (they have access to all repos). + ScopedRepos []string + // RateLimit is the maximum requests per minute. 0 means unlimited. + RateLimit int + // TokenExpiresAt is when the agent's token expires. + TokenExpiresAt time.Time + // CreatedAt is when the agent was registered. + CreatedAt time.Time +} + +// Registry manages agent identities and their trust tiers. +type Registry struct { + mu sync.RWMutex + agents map[string]*Agent +} + +// NewRegistry creates an empty agent registry. +func NewRegistry() *Registry { + return &Registry{ + agents: make(map[string]*Agent), + } +} + +// Register adds or updates an agent in the registry. +// Returns an error if the agent name is empty or the tier is invalid. +func (r *Registry) Register(agent Agent) error { + if agent.Name == "" { + return fmt.Errorf("trust.Register: agent name is required") + } + if !agent.Tier.Valid() { + return fmt.Errorf("trust.Register: invalid tier %d for agent %q", agent.Tier, agent.Name) + } + if agent.CreatedAt.IsZero() { + agent.CreatedAt = time.Now() + } + if agent.RateLimit == 0 { + agent.RateLimit = defaultRateLimit(agent.Tier) + } + + r.mu.Lock() + defer r.mu.Unlock() + r.agents[agent.Name] = &agent + return nil +} + +// Get returns the agent with the given name, or nil if not found. +func (r *Registry) Get(name string) *Agent { + r.mu.RLock() + defer r.mu.RUnlock() + return r.agents[name] +} + +// Remove deletes an agent from the registry. +func (r *Registry) Remove(name string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.agents[name]; !ok { + return false + } + delete(r.agents, name) + return true +} + +// List returns all registered agents. The returned slice is a snapshot. +func (r *Registry) List() []Agent { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Agent, 0, len(r.agents)) + for _, a := range r.agents { + out = append(out, *a) + } + return out +} + +// Len returns the number of registered agents. +func (r *Registry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.agents) +} + +// defaultRateLimit returns the default rate limit for a given tier. +func defaultRateLimit(t Tier) int { + switch t { + case TierUntrusted: + return 10 + case TierVerified: + return 60 + case TierFull: + return 0 // unlimited + default: + return 10 + } +} diff --git a/pkg/trust/trust_test.go b/pkg/trust/trust_test.go new file mode 100644 index 0000000..af0a9d3 --- /dev/null +++ b/pkg/trust/trust_test.go @@ -0,0 +1,164 @@ +package trust + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Tier --- + +func TestTierString_Good(t *testing.T) { + assert.Equal(t, "untrusted", TierUntrusted.String()) + assert.Equal(t, "verified", TierVerified.String()) + assert.Equal(t, "full", TierFull.String()) +} + +func TestTierString_Bad_Unknown(t *testing.T) { + assert.Contains(t, Tier(99).String(), "unknown") +} + +func TestTierValid_Good(t *testing.T) { + assert.True(t, TierUntrusted.Valid()) + assert.True(t, TierVerified.Valid()) + assert.True(t, TierFull.Valid()) +} + +func TestTierValid_Bad(t *testing.T) { + assert.False(t, Tier(0).Valid()) + assert.False(t, Tier(4).Valid()) + assert.False(t, Tier(-1).Valid()) +} + +// --- Registry --- + +func TestRegistryRegister_Good(t *testing.T) { + r := NewRegistry() + err := r.Register(Agent{Name: "Athena", Tier: TierFull}) + require.NoError(t, err) + assert.Equal(t, 1, r.Len()) +} + +func TestRegistryRegister_Good_SetsDefaults(t *testing.T) { + r := NewRegistry() + err := r.Register(Agent{Name: "Athena", Tier: TierFull}) + require.NoError(t, err) + + a := r.Get("Athena") + require.NotNil(t, a) + assert.Equal(t, 0, a.RateLimit) // full trust = unlimited + assert.False(t, a.CreatedAt.IsZero()) +} + +func TestRegistryRegister_Good_TierDefaults(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "A", Tier: TierUntrusted})) + require.NoError(t, r.Register(Agent{Name: "B", Tier: TierVerified})) + require.NoError(t, r.Register(Agent{Name: "C", Tier: TierFull})) + + assert.Equal(t, 10, r.Get("A").RateLimit) + assert.Equal(t, 60, r.Get("B").RateLimit) + assert.Equal(t, 0, r.Get("C").RateLimit) +} + +func TestRegistryRegister_Good_PreservesExplicitRateLimit(t *testing.T) { + r := NewRegistry() + err := r.Register(Agent{Name: "Custom", Tier: TierVerified, RateLimit: 30}) + require.NoError(t, err) + assert.Equal(t, 30, r.Get("Custom").RateLimit) +} + +func TestRegistryRegister_Good_Update(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierVerified})) + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull})) + + assert.Equal(t, 1, r.Len()) + assert.Equal(t, TierFull, r.Get("Athena").Tier) +} + +func TestRegistryRegister_Bad_EmptyName(t *testing.T) { + r := NewRegistry() + err := r.Register(Agent{Tier: TierFull}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestRegistryRegister_Bad_InvalidTier(t *testing.T) { + r := NewRegistry() + err := r.Register(Agent{Name: "Bad", Tier: Tier(0)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid tier") +} + +func TestRegistryGet_Good(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull})) + a := r.Get("Athena") + require.NotNil(t, a) + assert.Equal(t, "Athena", a.Name) +} + +func TestRegistryGet_Bad_NotFound(t *testing.T) { + r := NewRegistry() + assert.Nil(t, r.Get("nonexistent")) +} + +func TestRegistryRemove_Good(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull})) + assert.True(t, r.Remove("Athena")) + assert.Equal(t, 0, r.Len()) +} + +func TestRegistryRemove_Bad_NotFound(t *testing.T) { + r := NewRegistry() + assert.False(t, r.Remove("nonexistent")) +} + +func TestRegistryList_Good(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull})) + require.NoError(t, r.Register(Agent{Name: "Clotho", Tier: TierVerified})) + + agents := r.List() + assert.Len(t, agents, 2) + + names := make(map[string]bool) + for _, a := range agents { + names[a.Name] = true + } + assert.True(t, names["Athena"]) + assert.True(t, names["Clotho"]) +} + +func TestRegistryList_Good_Empty(t *testing.T) { + r := NewRegistry() + assert.Empty(t, r.List()) +} + +func TestRegistryList_Good_Snapshot(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull})) + agents := r.List() + + // Modifying the returned slice should not affect the registry. + agents[0].Tier = TierUntrusted + assert.Equal(t, TierFull, r.Get("Athena").Tier) +} + +// --- Agent --- + +func TestAgentTokenExpiry(t *testing.T) { + agent := Agent{ + Name: "Test", + Tier: TierVerified, + TokenExpiresAt: time.Now().Add(-1 * time.Hour), + } + assert.True(t, time.Now().After(agent.TokenExpiresAt)) + + agent.TokenExpiresAt = time.Now().Add(1 * time.Hour) + assert.True(t, time.Now().Before(agent.TokenExpiresAt)) +}