Skip to content

Permission deny rules fail open for argument values containing / (matchGlob uses filepath.Match) #3604

Description

@parveshsaini

Description

A configured deny permission whose argument value contains a path separator (/, or \ on Windows)
can silently fail to match, so a tool call the operator explicitly tried to block is not denied.

The matcher matchGlob in pkg/permissions/permissions.go has two paths:

  1. A trailing-* fast path: if the pattern ends in * and the prefix contains no other glob
    metacharacters, it does a plain strings.HasPrefix. This is why shell:cmd=rm* and shell:cmd=sudo*
    correctly block rm -rf /.
  2. Every other pattern falls through to filepath.Match(pattern, value). filepath.Match's * matches
    any run of non-separator characters, so it stops at /.

As a result, a deny pattern with a non-trailing wildcard - e.g. shell:cmd=*rm -rf* - matches
rm -rf tmp but not rm -rf /. The docstring on matchGlob says "we want * to match any characters
including spaces," but that only holds for the fast path; the filepath.Match fallback silently reintroduces
separator-boundary behavior. Because this is a deny rule, the failure direction is fail-open.

Expected Behavior

A deny pattern with a wildcard should match the same command regardless of whether the argument value
happens to contain a /. shell:cmd=*rm -rf* should deny both rm -rf tmp and rm -rf /.

Actual Behavior

shell:cmd=*rm -rf* denies rm -rf tmp but returns Ask for rm -rf / - the command is not blocked.
The decision varies with argument content and with OS (separator is / on Unix, \ on Windows).

Steps to Reproduce

Config-level: configure a deny rule with a non-trailing wildcard and invoke the shell tool with an
argument containing /:

permissions:
  deny:
    - "shell:cmd=*rm -rf*"
  • shell with cmd = "rm -rf tmp" → denied ✅
  • shell with cmd = "rm -rf /"not denied (falls through to Ask) ❌

Isolated unit reproduction against pkg/permissions (the checker's public API + the matcher):

func TestDenyGlobDoesNotCrossPathSeparator(t *testing.T) {
	checker := NewCheckerFromRules(nil, nil, []string{"shell:cmd=*rm -rf*"})
	assert.Equal(t, Deny, checker.CheckWithArgs("shell", map[string]any{"cmd": "rm -rf tmp"})) // passes
	assert.Equal(t, Deny, checker.CheckWithArgs("shell", map[string]any{"cmd": "rm -rf /"}))   // FAILS: got Ask
}

func TestMatchGlobCrossesPathSeparator(t *testing.T) {
	assert.True(t, matchGlob("*rm -rf*", "rm -rf /"))       // FAILS
	assert.True(t, matchGlob("*/etc/*", "cat /etc/passwd")) // FAILS
}

go test ./pkg/permissions/ -run 'TestDenyGlob...|TestMatchGlob...' -v

Docker Agent version

Docker version 29.5.3, build d1c06ef

OS & terminal

Reproduced on Linux (in a golang:1.26.4 container). The separator-boundary behavior is inherent to filepath.Match and also affects Windows (where the boundary is \), so the result is platform-dependent.

Model used

N/A - this is in the permission matcher; it's independent of the model.

Error output

--- FAIL: TestDenyGlobDoesNotCrossPathSeparator (0.00s)
    Not equal: expected: 2 (Deny)   actual: 0 (Ask)
    Messages: deny rule must also block 'rm -rf /' (the most dangerous case)
--- FAIL: TestMatchGlobCrossesPathSeparator (0.00s)
    Should be true
    Messages: "*rm -rf*" should match "rm -rf /"
FAIL    github.com/docker/docker-agent/pkg/permissions


(Decision enum: `Ask=0, Allow=1, Deny=2`.)

Screenshots

No response

Additional context

  • Impact: in a plain interactive session Ask still prompts, so this is a degraded control rather than
    immediate silent execution - but it becomes a real bypass under --yolo, non-interactive / auto-approval
    runs, or when an overlapping allow rule is present. The same filepath.Match path also breaks allow
    rules, but those fail closed.
  • Trigger: only patterns with a non-trailing wildcard are affected; trailing-only patterns (rm*,
    sudo*) hit the fast path and work, which tends to hide this in casual testing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/securityAuthentication, authorization, secrets, vulnerabilities

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions