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
15 changes: 15 additions & 0 deletions cagent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,21 @@
]
]
},
"ask": {
"type": "array",
"description": "Tool patterns that always require user confirmation, even for tools that are normally auto-approved (e.g. read-only tools). Supports the same pattern syntax as allow: tool names with globs and argument matching (e.g., 'fetch' to always ask before fetching URLs).",
"items": {
"type": "string"
},
"examples": [
[
"fetch"
],
[
"mcp:github:get_*"
]
]
},
"deny": {
"type": "array",
"description": "Tool patterns that are always rejected. Takes priority over allow patterns. Supports the same pattern syntax as allow: tool names with globs and argument matching (e.g., 'shell:cmd=rm -rf*' to block dangerous rm commands).",
Expand Down
5 changes: 4 additions & 1 deletion pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,9 +532,10 @@ func (a *App) PermissionsInfo() *runtime.PermissionsInfo {
// Get session-level permissions
var sessionPerms *runtime.PermissionsInfo
if a.session != nil && a.session.Permissions != nil {
if len(a.session.Permissions.Allow) > 0 || len(a.session.Permissions.Deny) > 0 {
if len(a.session.Permissions.Allow) > 0 || len(a.session.Permissions.Ask) > 0 || len(a.session.Permissions.Deny) > 0 {
sessionPerms = &runtime.PermissionsInfo{
Allow: a.session.Permissions.Allow,
Ask: a.session.Permissions.Ask,
Deny: a.session.Permissions.Deny,
}
}
Expand All @@ -549,10 +550,12 @@ func (a *App) PermissionsInfo() *runtime.PermissionsInfo {
result := &runtime.PermissionsInfo{}
if sessionPerms != nil {
result.Allow = append(result.Allow, sessionPerms.Allow...)
result.Ask = append(result.Ask, sessionPerms.Ask...)
result.Deny = append(result.Deny, sessionPerms.Deny...)
}
if teamPerms != nil {
result.Allow = append(result.Allow, teamPerms.Allow...)
result.Ask = append(result.Ask, teamPerms.Ask...)
result.Deny = append(result.Deny, teamPerms.Deny...)
}

Expand Down
8 changes: 6 additions & 2 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1069,14 +1069,18 @@ type RAGFusionConfig struct {
// PermissionsConfig represents tool permission configuration.
// Allow/Ask/Deny model. This controls tool call approval behavior:
// - Allow: Tools matching these patterns are auto-approved (like --yolo for specific tools)
// - Ask: Tools matching these patterns always require user approval (default behavior)
// - Ask: Tools matching these patterns always require user approval, even if the tool is read-only
// - Deny: Tools matching these patterns are always rejected, even with --yolo
//
// Patterns support glob-style matching (e.g., "shell", "read_*", "mcp:github:*")
// The evaluation order is: Deny (checked first), then Allow, then Ask (default)
// The evaluation order is: Deny (checked first), then Allow, then Ask (explicit), then default
// (read-only tools auto-approved, others ask)
type PermissionsConfig struct {
// Allow lists tool name patterns that are auto-approved without user confirmation
Allow []string `json:"allow,omitempty"`
// Ask lists tool name patterns that always require user confirmation,
// even for tools that are normally auto-approved (e.g. read-only tools)
Ask []string `json:"ask,omitempty"`
// Deny lists tool name patterns that are always rejected
Deny []string `json:"deny,omitempty"`
}
Expand Down
8 changes: 6 additions & 2 deletions pkg/config/v4/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1056,14 +1056,18 @@ type RAGFusionConfig struct {
// PermissionsConfig represents tool permission configuration.
// Allow/Ask/Deny model. This controls tool call approval behavior:
// - Allow: Tools matching these patterns are auto-approved (like --yolo for specific tools)
// - Ask: Tools matching these patterns always require user approval (default behavior)
// - Ask: Tools matching these patterns always require user approval, even if the tool is read-only
// - Deny: Tools matching these patterns are always rejected, even with --yolo
//
// Patterns support glob-style matching (e.g., "shell", "read_*", "mcp:github:*")
// The evaluation order is: Deny (checked first), then Allow, then Ask (default)
// The evaluation order is: Deny (checked first), then Allow, then Ask (explicit), then default
// (read-only tools auto-approved, others ask)
type PermissionsConfig struct {
// Allow lists tool name patterns that are auto-approved without user confirmation
Allow []string `json:"allow,omitempty"`
// Ask lists tool name patterns that always require user confirmation,
// even for tools that are normally auto-approved (e.g. read-only tools)
Ask []string `json:"ask,omitempty"`
// Deny lists tool name patterns that are always rejected
Deny []string `json:"deny,omitempty"`
}
Expand Down
27 changes: 25 additions & 2 deletions pkg/permissions/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const (
Allow
// Deny means the tool is rejected and should not be executed
Deny
// ForceAsk means an explicit ask pattern matched; the tool must be
// confirmed even if it would normally be auto-approved (e.g. read-only).
ForceAsk
)

// String returns a human-readable representation of the decision
Expand All @@ -31,6 +34,8 @@ func (d Decision) String() string {
return "allow"
case Deny:
return "deny"
case ForceAsk:
return "force_ask"
default:
return "unknown"
}
Expand All @@ -39,6 +44,7 @@ func (d Decision) String() string {
// Checker evaluates tool permissions based on configured patterns
type Checker struct {
allowPatterns []string
askPatterns []string
denyPatterns []string
}

Expand All @@ -49,6 +55,7 @@ func NewChecker(cfg *latest.PermissionsConfig) *Checker {
}
return &Checker{
allowPatterns: cfg.Allow,
askPatterns: cfg.Ask,
denyPatterns: cfg.Deny,
}
}
Expand All @@ -61,7 +68,7 @@ func (c *Checker) Check(toolName string) Decision {
}

// CheckWithArgs evaluates the permission for a given tool name and its arguments.
// Evaluation order: Deny (checked first), then Allow, then Ask (default)
// Evaluation order: Deny (checked first), then Allow, then Ask (explicit), then Ask (default).
//
// The toolName can be a simple name like "shell" or a qualified name like
// "mcp:github:create_issue".
Expand All @@ -71,6 +78,10 @@ func (c *Checker) Check(toolName string) Decision {
// - Argument matching: "shell:cmd=ls*" matches shell tool with cmd argument starting with "ls"
// - Multiple arguments: "shell:cmd=ls*:cwd=/home/*" matches both conditions
// - Glob patterns in both tool names and argument values
//
// Returns ForceAsk when an explicit ask pattern matches. ForceAsk means the
// tool must always be confirmed, even when it would normally be auto-approved
// (e.g. read-only tools or --yolo mode).
func (c *Checker) CheckWithArgs(toolName string, args map[string]any) Decision {
// Deny patterns are checked first - they take priority
for _, pattern := range c.denyPatterns {
Expand All @@ -86,20 +97,32 @@ func (c *Checker) CheckWithArgs(toolName string, args map[string]any) Decision {
}
}

// Explicit ask patterns override auto-approval (e.g. read-only hints)
for _, pattern := range c.askPatterns {
if matchToolPattern(pattern, toolName, args) {
return ForceAsk
}
}

// Default is Ask
return Ask
}

// IsEmpty returns true if no permissions are configured
func (c *Checker) IsEmpty() bool {
return len(c.allowPatterns) == 0 && len(c.denyPatterns) == 0
return len(c.allowPatterns) == 0 && len(c.askPatterns) == 0 && len(c.denyPatterns) == 0
}

// AllowPatterns returns the list of allow patterns.
func (c *Checker) AllowPatterns() []string {
return c.allowPatterns
}

// AskPatterns returns the list of ask patterns.
func (c *Checker) AskPatterns() []string {
return c.askPatterns
}

// DenyPatterns returns the list of deny patterns.
func (c *Checker) DenyPatterns() []string {
return c.denyPatterns
Expand Down
65 changes: 65 additions & 0 deletions pkg/permissions/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ func TestNewChecker(t *testing.T) {
require.NotNil(t, checker)
assert.False(t, checker.IsEmpty())
})

t.Run("with only ask patterns", func(t *testing.T) {
t.Parallel()
checker := NewChecker(&latest.PermissionsConfig{
Ask: []string{"fetch"},
})
require.NotNil(t, checker)
assert.False(t, checker.IsEmpty())
})
}

func TestChecker_Check(t *testing.T) {
Expand All @@ -43,6 +52,7 @@ func TestChecker_Check(t *testing.T) {
tests := []struct {
name string
allow []string
ask []string
deny []string
toolName string
want Decision
Expand Down Expand Up @@ -138,13 +148,35 @@ func TestChecker_Check(t *testing.T) {
toolName: "read_file",
want: Allow,
},
// Ask patterns
{
name: "ask pattern returns ForceAsk",
ask: []string{"fetch"},
toolName: "fetch",
want: ForceAsk,
},
{
name: "deny takes priority over ask",
ask: []string{"fetch"},
deny: []string{"fetch"},
toolName: "fetch",
want: Deny,
},
{
name: "allow takes priority over ask",
allow: []string{"fetch"},
ask: []string{"fetch"},
toolName: "fetch",
want: Allow,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
checker := NewChecker(&latest.PermissionsConfig{
Allow: tt.allow,
Ask: tt.ask,
Deny: tt.deny,
})
got := checker.Check(tt.toolName)
Expand Down Expand Up @@ -383,6 +415,7 @@ func TestDecision_String(t *testing.T) {
{Ask, "ask"},
{Allow, "allow"},
{Deny, "deny"},
{ForceAsk, "force_ask"},
{Decision(99), "unknown"},
}

Expand All @@ -394,6 +427,38 @@ func TestDecision_String(t *testing.T) {
}
}

func TestChecker_ForceAsk(t *testing.T) {
t.Parallel()

tests := []struct {
name string
allow []string
ask []string
deny []string
toolName string
want Decision
}{
{name: "no patterns returns Ask", toolName: "fetch", want: Ask},
{name: "ask pattern returns ForceAsk", ask: []string{"fetch"}, toolName: "fetch", want: ForceAsk},
{name: "ask glob returns ForceAsk", ask: []string{"fetch*"}, toolName: "fetch_url", want: ForceAsk},
{name: "ask pattern does not match other tool", ask: []string{"fetch"}, toolName: "shell", want: Ask},
{name: "deny takes priority over ask", ask: []string{"fetch"}, deny: []string{"fetch"}, toolName: "fetch", want: Deny},
{name: "allow takes priority over ask", ask: []string{"fetch"}, allow: []string{"fetch"}, toolName: "fetch", want: Allow},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
checker := NewChecker(&latest.PermissionsConfig{
Allow: tt.allow,
Ask: tt.ask,
Deny: tt.deny,
})
assert.Equal(t, tt.want, checker.Check(tt.toolName))
})
}
}

func TestParsePattern(t *testing.T) {
t.Parallel()

Expand Down
Loading