Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions guard/casbin_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package guard

import (
"context"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"sync"

"github.com/casbin/casbin/v2"
casbinmodel "github.com/casbin/casbin/v2/model"
)

// modelText is the casbin model backing the guard. Every policy carries the
// real tri-state effect in its own column while the casbin effect column (eft)
// is always "allow"; this lets casbin do role resolution, glob matching and
// priority ordering for us, and we read the true effect off the matched rule.
// Rules are loaded highest-priority-first so the priority effector's
// first-match-wins yields the highest-priority matching rule. The last policy
// column is named "rank", not casbin's reserved "priority" token: were it
// "priority", casbin would insertion-sort policies by ascending number as soon
// as its field index gets built (an adapter LoadPolicy, GetFieldIndex, ...),
// silently reversing the highest-first order we establish in Go here.
const modelText = `
[request_definition]
r = sub, tool, cat, res

[policy_definition]
p = sub, tool, cat, res, effect, eft, rank, name

[role_definition]
g = _, _

[policy_effect]
e = priority(p.eft) || deny

[matchers]
m = (p.sub == "*" || g(r.sub, p.sub)) && gmatch(r.tool, p.tool) && gmatch(r.cat, p.cat) && gmatch(r.res, p.res)
`

// CasbinGuard is the casbin-backed Guard implementation.
type CasbinGuard struct {
enforcer *casbin.Enforcer
def Effect
}

var regexCache sync.Map // pattern string -> *regexp.Regexp

// gmatch reports whether value matches a glob pattern. "*"/"" match anything;
// a pattern without wildcards is compared for exact equality; otherwise "*"
// and "?" are treated as glob wildcards anchored to the whole string.
func gmatch(args ...interface{}) (interface{}, error) {
value, _ := args[0].(string)
pattern, _ := args[1].(string)
return globMatch(value, pattern), nil
}

func globMatch(value, pattern string) bool {
if pattern == "" || pattern == "*" {
return true
}
if !strings.ContainsAny(pattern, "*?") {
return value == pattern
}
re := compileGlob(pattern)
if re == nil {
return value == pattern
}
return re.MatchString(value)
}

func compileGlob(pattern string) *regexp.Regexp {
if cached, ok := regexCache.Load(pattern); ok {
return cached.(*regexp.Regexp)
}
var b strings.Builder
b.WriteString("^")
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return nil
}
regexCache.Store(pattern, re)
return re
}

func effectRank(e Effect) int {
switch e {
case EffectDeny:
return 3
case EffectAsk:
return 2
default:
return 1
}
}

// validEffect reports whether e is one of the three known effects.
func validEffect(e Effect) bool {
switch e {
case EffectAllow, EffectAsk, EffectDeny:
return true
}
return false
}

// NewCasbinGuard builds a Guard from a Policy. It is safe for concurrent Check
// calls but is immutable — rebuild it when the policy changes.
func NewCasbinGuard(policy Policy) (*CasbinGuard, error) {
m, err := casbinmodel.NewModelFromString(modelText)
if err != nil {
return nil, err
}

e, err := casbin.NewEnforcer(m)
if err != nil {
return nil, err
}
e.AddFunction("gmatch", gmatch)

if policy.Default != "" && !validEffect(policy.Default) {
return nil, fmt.Errorf("guard: invalid default effect %q (want allow, ask or deny)", policy.Default)
}

for _, link := range policy.Roles {
if link.Child == "" || link.Parent == "" {
continue
}
if _, err := e.AddGroupingPolicy(link.Child, link.Parent); err != nil {
return nil, err
}
}

// Sort rules highest-priority-first; ties broken deny > ask > allow so a
// deny at the same priority as an allow wins.
rules := make([]Rule, len(policy.Rules))
copy(rules, policy.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority != rules[j].Priority {
return rules[i].Priority > rules[j].Priority
}
return effectRank(rules[i].Effect) > effectRank(rules[j].Effect)
})

for _, rule := range rules {
if rule.Effect != "" && !validEffect(rule.Effect) {
return nil, fmt.Errorf("guard: invalid effect %q in rule %q (want allow, ask or deny)", rule.Effect, rule.Name)
}
eff := rule.Effect
if eff == "" {
eff = EffectAllow
}
_, err := e.AddPolicy(
orStar(rule.Subject),
orStar(rule.Tool),
orStar(rule.Category),
orStar(rule.Resource),
string(eff),
"allow",
strconv.Itoa(rule.Priority),
rule.Name,
)
if err != nil {
return nil, err
}
}

def := policy.Default
if def == "" {
// A permission engine must not silently allow when nothing is configured;
// default to ask so an unconfigured host fails safe (deny is also valid).
def = EffectAsk
}

return &CasbinGuard{enforcer: e, def: def}, nil
}

func orStar(s string) string {
if s == "" {
return "*"
}
return s
}

// Check resolves the effect for a request via the casbin enforcer.
func (g *CasbinGuard) Check(ctx context.Context, req Request) (Decision, error) {
ok, explains, err := g.enforcer.EnforceEx(
orStar(req.Subject),
orStar(req.Tool),
orStar(req.Category),
orStar(req.Resource),
)
if err != nil {
return Decision{}, err
}

// A matched rule always has eft "allow", so ok==true means some rule
// matched; explains holds that rule and column 4 is its real effect.
if ok && len(explains) >= 5 {
return Decision{
Effect: Effect(explains[4]),
Reason: fmt.Sprintf("matched rule (tool=%s, res=%s, effect=%s)", explains[1], explains[3], explains[4]),
Rule: ruleName(explains),
}, nil
}

return Decision{
Effect: g.def,
Reason: "no matching rule; default effect",
}, nil
}

// ruleName returns the matched rule's Name (the last policy column), or "" when
// the rule was defined without one.
func ruleName(explains []string) string {
if len(explains) >= 8 {
return explains[7]
}
return ""
}
120 changes: 120 additions & 0 deletions guard/guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package guard is a standalone, host-agnostic permission engine for agent
// tool calls. It has no dependency on OpenAgent (no object/model/beego imports)
// so it can be reused as-is by other agents (Longxia, Hermes, ...).
//
// The host maps its own domain (agent, store, user, tool call)
// into a Request, provides policy rules, and receives an Effect back. What the
// host does with an "Ask" effect is delegated to an Approver the host supplies.
package guard

import "context"

// Effect is the outcome of a permission decision.
type Effect string

const (
// EffectAllow lets the tool call run without interruption.
EffectAllow Effect = "allow"
// EffectAsk requires an out-of-band approval before the call may run.
EffectAsk Effect = "ask"
// EffectDeny blocks the tool call.
EffectDeny Effect = "deny"
)

// Category is a coarse capability class used to write broad rules without
// enumerating every tool. Hosts tag each tool with one of these (or a custom
// value); policies may target a category instead of a specific tool name.
const (
CategoryRead = "read"
CategoryWrite = "write"
CategoryExec = "exec"
CategoryNetwork = "network"
CategorySensitive = "sensitive"
CategoryUnknown = "unknown"
)

// Request describes a single tool call to be authorized. Every field is a
// plain string so the engine stays decoupled from any host type.
type Request struct {
// Subject is the caller identity/role the rules are written against
// (e.g. an agent id, a store name, or a role). Role hierarchy is honored.
Subject string
// Tool is the concrete tool name being invoked (e.g. "shell", "web_fetch").
Tool string
// Category is the tool's capability class (see Category* constants).
Category string
// Resource is the single most security-relevant argument of the call —
// the host picks it per tool (a shell command, a file path, a URL host).
// Rules may pattern-match against it for fine-grained control.
Resource string
}

// Decision is the resolved outcome plus provenance for auditing.
type Decision struct {
Effect Effect
// Reason is a short human-readable explanation (matched rule or default).
Reason string
// Rule is the id/name of the matched policy rule, empty if defaulted.
Rule string
}

// Guard authorizes tool calls against a policy set.
type Guard interface {
// Check resolves an Effect for the request. It never executes anything.
Check(ctx context.Context, req Request) (Decision, error)
}

// Approver resolves an EffectAsk decision into a concrete allow/deny. The host
// implements the transport (prompt the user over SSE, a webhook, auto-deny in
// headless runs, ...). Returning an error is treated by callers as a denial.
type Approver interface {
RequestApproval(ctx context.Context, req Request, d Decision) (approved bool, err error)
}

// Rule is one policy entry. Pattern fields accept "*" (any), an exact string,
// or a glob using "*"/"?" wildcards (anchored). Subject participates in role
// hierarchy: a rule for subject "admin" also applies to roles that inherit it.
type Rule struct {
// Name identifies the rule for auditing (optional); surfaced as Decision.Rule.
Name string
// Subject/Tool/Category/Resource are match patterns ("*" = any).
Subject string
Tool string
Category string
Resource string
// Effect is what to apply when this rule matches.
Effect Effect
// Priority orders rules; the highest-priority matching rule wins.
// By convention deny > ask > allow so denies override, but the host is
// free to choose. Ties are broken deterministically (deny > ask > allow).
Priority int
}

// RoleLink expresses "child inherits parent" for subject role hierarchy,
// e.g. {Child: "store-x", Parent: "user"}.
type RoleLink struct {
Child string
Parent string
}

// Policy is the full rule set plus role hierarchy and the default effect used
// when no rule matches.
type Policy struct {
Rules []Rule
Roles []RoleLink
Default Effect
}
Loading
Loading