Skip to content
Merged
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
52 changes: 22 additions & 30 deletions pkg/permissions/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package permissions
import (
"fmt"
"path/filepath"
"strconv"
"strings"

"github.com/docker/docker-agent/pkg/config/latest"
Expand Down Expand Up @@ -85,30 +84,34 @@ func (c *Checker) Check(toolName string) Decision {
// (e.g. read-only tools). Note that --yolo mode takes precedence over ForceAsk.
func (c *Checker) CheckWithArgs(toolName string, args map[string]any) Decision {
// Deny patterns are checked first - they take priority
for _, pattern := range c.denyPatterns {
if matchToolPattern(pattern, toolName, args) {
return Deny
}
if matchAny(c.denyPatterns, toolName, args) {
return Deny
}

// Allow patterns are checked second
for _, pattern := range c.allowPatterns {
if matchToolPattern(pattern, toolName, args) {
return Allow
}
if matchAny(c.allowPatterns, toolName, args) {
return Allow
}

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

// Default is Ask
return Ask
}

// matchAny reports whether any pattern in the list matches the tool name and args.
func matchAny(patterns []string, toolName string, args map[string]any) bool {
for _, pattern := range patterns {
if matchToolPattern(pattern, toolName, args) {
return true
}
}
return false
}

// IsEmpty returns true if no permissions are configured
func (c *Checker) IsEmpty() bool {
return len(c.allowPatterns) == 0 && len(c.askPatterns) == 0 && len(c.denyPatterns) == 0
Expand Down Expand Up @@ -176,12 +179,7 @@ func matchToolPattern(pattern, toolName string, args map[string]any) bool {
return true
}

// If pattern has argument conditions but no args provided, no match
if args == nil {
return false
}

// All argument patterns must match
// All argument patterns must match (indexing a nil args map is safe in Go)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low Severity: Misleading comment

The comment states "indexing a nil args map is safe in Go", but the code doesn't index a nil map—it iterates over argPatterns and reads from args. When args is nil and argPatterns is non-empty, the exists check at line 184 returns false, causing an early return at line 185.

The behavior is correct (matches the old explicit nil check), but the comment is misleading. Consider clarifying:

// All argument patterns must match (reading from a nil map returns zero value and false for exists check)

for argName, argPattern := range argPatterns {
argValue, exists := args[argName]
if !exists {
Expand All @@ -203,16 +201,9 @@ func argToString(v any) string {
switch val := v.(type) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low Severity: Behavioral change for custom types

The simplified argToString now uses fmt.Sprintf("%v", v) for bool/int/int64 instead of explicit formatting. This works correctly for built-in types, but changes behavior for custom types that implement fmt.Stringer.

For example, a custom type wrapping int with a String() method would now call String() instead of formatting as a decimal number.

This is unlikely in practice (tool args are typically JSON-unmarshaled primitives), but worth noting as a subtle behavioral change.

case string:
return val
case bool:
return strconv.FormatBool(val)
case float64:
// JSON numbers are float64 - format without trailing zeros
if val == float64(int64(val)) {
return strconv.FormatInt(int64(val), 10)
}
// JSON numbers are float64 - use %g for shortest representation
return fmt.Sprintf("%g", val)
case int, int64:
return fmt.Sprintf("%d", val)
default:
return fmt.Sprintf("%v", v)
}
Expand All @@ -237,10 +228,11 @@ func matchGlob(pattern, value string) bool {

// Handle trailing wildcard for prefix matching
// This allows "sudo*" to match "sudo rm -rf /"
if strings.HasSuffix(pattern, "*") && !strings.HasSuffix(pattern, "\\*") {
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
// If prefix contains no other glob characters, do simple prefix match
if !strings.ContainsAny(prefix, "*?[") {
// If prefix contains no other glob characters, do simple prefix match.
// Including \ catches escaped asterisks (e.g. "foo\*").
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low Severity: Overly broad backslash check

The new code includes backslash in ContainsAny to catch escaped asterisks, but this prevents the prefix optimization for ANY pattern with a backslash in the prefix, not just those with escaped trailing asterisks.

For example, path\to\dir* would have used the prefix optimization in the old code (doesn't end with \*), but now falls through to filepath.Match (prefix contains backslashes).

This is safe (filepath.Match handles escaping correctly), but more conservative than before. The comment could be clearer about this trade-off.

if !strings.ContainsAny(prefix, `*?[\`) {
return strings.HasPrefix(value, prefix)
}
}
Expand Down
Loading