Merge pull request 'feat(agentic): agent trust model — security wall between non-aligned agents' (#149) from feat/agentic-trust-model into new
This commit is contained in:
commit
4bc43939a6
4 changed files with 835 additions and 0 deletions
238
pkg/trust/policy.go
Normal file
238
pkg/trust/policy.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
268
pkg/trust/policy_test.go
Normal file
268
pkg/trust/policy_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
165
pkg/trust/trust.go
Normal file
165
pkg/trust/trust.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
164
pkg/trust/trust_test.go
Normal file
164
pkg/trust/trust_test.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue