go-crypt/trust/policy.go
Claude 7407b89b8d
refactor(ax): AX RFC-025 compliance sweep pass 1
Remove banned imports (fmt, strings, os, errors, path/filepath) across all
production and test files, replace with core.* primitives, coreio.ReadStream,
and coreerr.E. Upgrade dappco.re/go/core v0.5.0 → v0.7.0 for core.PathBase
and core.Is. Fix isRepoScoped to exclude pr.* capabilities (enforcement is at
the forge layer, not the policy engine). Add Good/Bad/Ugly test coverage to
all packages missing the mandatory three-category naming convention.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 08:48:56 +01:00

291 lines
7.4 KiB
Go

package trust
import (
"slices"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// 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.
//
// Allow.String() // "allow"
func (d Decision) String() string {
switch d {
case Deny:
return "deny"
case Allow:
return "allow"
case NeedsApproval:
return "needs_approval"
default:
return core.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.
//
// engine := trust.NewPolicyEngine(registry)
// result := engine.Evaluate("Clotho", trust.CapPushRepo, "host-uk/core")
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: core.Sprintf("no policy for tier %s", agent.Tier),
}
}
// Check explicit denials first.
if slices.Contains(policy.Denied, cap) {
return EvalResult{
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: core.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
}
}
// Check if capability requires approval.
if slices.Contains(policy.RequiresApproval, cap) {
return EvalResult{
Decision: NeedsApproval,
Agent: agentName,
Cap: cap,
Reason: core.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 for restricted tiers.
if isRepoScoped(cap) && agent.Tier != TierFull {
if len(agent.ScopedRepos) == 0 || !repoAllowed(agent.ScopedRepos, repo) {
return EvalResult{
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: core.Sprintf("agent %q does not have access to repo %q", agentName, repo),
}
}
}
return EvalResult{
Decision: Allow,
Agent: agentName,
Cap: cap,
Reason: core.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
}
}
}
return EvalResult{
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: core.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
}
}
// SetPolicy replaces the policy for a given tier.
//
// err := engine.SetPolicy(trust.Policy{Tier: trust.TierFull, Allowed: []trust.Capability{trust.CapPushRepo}})
func (pe *PolicyEngine) SetPolicy(p Policy) error {
if !p.Tier.Valid() {
return coreerr.E("trust.SetPolicy", core.Sprintf("invalid tier %d", p.Tier), nil)
}
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.
// Only repo.* capabilities and secrets.read require explicit repo authorisation.
// PR and issue capabilities are not repo-scoped — enforcement happens at the
// forge layer.
func isRepoScoped(cap Capability) bool {
return core.HasPrefix(string(cap), "repo.") ||
cap == CapReadSecrets
}
// 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 _, 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
}
// Star means unrestricted access for all repos.
if pattern == "*" {
return true
}
// Check for wildcard patterns.
if !core.Contains(pattern, "*") {
return false
}
// "prefix/**" — recursive: matches anything under prefix/.
if core.HasSuffix(pattern, "/**") {
prefix := pattern[:len(pattern)-3] // strip "/**"
if !core.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 core.HasSuffix(pattern, "/*") {
prefix := pattern[:len(pattern)-2] // strip "/*"
if !core.HasPrefix(repo, prefix+"/") {
return false
}
remainder := repo[len(prefix)+1:]
// Must have a non-empty name, and no further slashes.
return remainder != "" && !core.Contains(remainder, "/")
}
// Unsupported wildcard position — fall back to no match.
return false
}