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:
- 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 /.
- 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.
Description
A configured
denypermission 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
matchGlobinpkg/permissions/permissions.gohas two paths:*fast path: if the pattern ends in*and the prefix contains no other globmetacharacters, it does a plain
strings.HasPrefix. This is whyshell:cmd=rm*andshell:cmd=sudo*correctly block
rm -rf /.filepath.Match(pattern, value).filepath.Match's*matchesany run of non-separator characters, so it stops at
/.As a result, a
denypattern with a non-trailing wildcard - e.g.shell:cmd=*rm -rf*- matchesrm -rf tmpbut notrm -rf /. The docstring onmatchGlobsays "we want*to match any charactersincluding spaces," but that only holds for the fast path; the
filepath.Matchfallback silently reintroducesseparator-boundary behavior. Because this is a
denyrule, the failure direction is fail-open.Expected Behavior
A
denypattern with a wildcard should match the same command regardless of whether the argument valuehappens to contain a
/.shell:cmd=*rm -rf*should deny bothrm -rf tmpandrm -rf /.Actual Behavior
shell:cmd=*rm -rf*deniesrm -rf tmpbut returnsAskforrm -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
/:shellwithcmd = "rm -rf tmp"→ denied ✅shellwithcmd = "rm -rf /"→ not denied (falls through to Ask) ❌Isolated unit reproduction against
pkg/permissions(the checker's public API + the matcher):go test ./pkg/permissions/ -run 'TestDenyGlob...|TestMatchGlob...' -vDocker Agent version
Docker version 29.5.3, build d1c06ef
OS & terminal
Reproduced on Linux (in a
golang:1.26.4container). The separator-boundary behavior is inherent tofilepath.Matchand 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
Askstill prompts, so this is a degraded control rather thanimmediate silent execution - but it becomes a real bypass under
--yolo, non-interactive / auto-approvalruns, or when an overlapping
allowrule is present. The samefilepath.Matchpath also breaksallowrules, but those fail closed.
rm*,sudo*) hit the fast path and work, which tends to hide this in casual testing.