Skip to content

fix(permissions): match glob wildcards across path separators - #3605

Merged
dgageot merged 3 commits into
docker:mainfrom
parveshsaini:parveshsaini/fix/permission-deny-glob-separator
Jul 16, 2026
Merged

dgageot merged 3 commits into
docker:mainfrom
parveshsaini:parveshsaini/fix/permission-deny-glob-separator

Conversation

@parveshsaini

Copy link
Copy Markdown
Contributor

Closes #3604

What

matchGlob in pkg/permissions falls back to filepath.Match for any pattern that isn't a plain trailing
wildcard. filepath.Match's * and ? match only non-separator characters, so they stop at / (and at
\ on Windows). This translates the glob to an anchored regexp whose */? span any character instead.

Why

Permission patterns match argument values — shell commands, file paths, URLs — which routinely contain /.
Because of the separator boundary, a rule with a non-trailing wildcard silently fails to match once the
value contains a /:

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

For a deny rule this is the unsafe direction — it fails open. In an interactive session the fall-through
to Ask still prompts, so the practical exposure is a degraded control; it becomes a real bypass under
--yolo, non-interactive / auto-approval runs, or when an overlapping allow rule is present. The same
boundary also silently breaks allow rules, though those fail closed.

The existing docstring already stated the intent — "for argument matching we want * to match any characters
including spaces"
— but that only held for the trailing-wildcard fast path (sudo*), which is why casual
testing with trailing-only patterns never surfaced this.

How

  • Replace the filepath.Match fallback in matchGlob with globToRegexp + regexp.MatchString.
  • globToRegexp translates a filepath.Match-style glob into an anchored regexp: * → .*, ? → .,
    character classes ([...]) copied through (bracket syntax is shared with regexp), everything else matched
    literally via regexp.QuoteMeta.
  • The trailing-* prefix-match fast path and the case-insensitive lowercasing are unchanged.
  • No new dependencies (regexp is stdlib; path/filepath import dropped).

This keeps all existing matchGlob semantics (*, ?, [...], case-insensitivity) — only the separator
boundary changes.

Tests

  • New TestDenyGlobCrossesPathSeparator: end-to-end via CheckWithArgs, asserting a *rm -rf* deny rule
    blocks both rm -rf tmp and rm -rf /.
  • Extended the TestMatchGlob table with separator-crossing cases (*rm -rf*, */etc/*, a?c vs a/c).

These fail on main and pass with the fix. Full pkg/permissions suite, go vet, gofmt, and
golangci-lint are all green (Go 1.26.4).

--- PASS: TestDenyGlobCrossesPathSeparator (0.00s)
--- PASS: TestMatchGlob (0.00s)
ok  	github.com/docker/docker-agent/pkg/permissions

@parveshsaini
parveshsaini requested a review from a team as a code owner July 11, 2026 15:03
@aheritier aheritier added area/security Authentication, authorization, secrets, vulnerabilities kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Jul 11, 2026
@Sayt-0
Sayt-0 self-requested a review July 13, 2026 15:26

@Sayt-0 Sayt-0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The diagnosis in #3604 is correct and the glob-to-regexp translation is the right direction: RE2 is linear-time (no ReDoS), the \A/\z anchoring is right, and the fast path is preserved. Two correctness issues in globToRegexp block the merge; both have small fixes, detailed in the inline comments.

# Finding Severity Effect
1 Translated regexp lacks the (?s) flag, so . and .* stop at \n blocker multi-line commands still bypass non-trailing-wildcard deny rules; strict regression vs main for newline-containing values
2 Byte-wise QuoteMeta(string(c)) corrupts multi-byte UTF-8 runes blocker patterns containing non-ASCII stop matching (café no longer matches café)
3 \ escape semantics drift outside character classes minor foo\* matched literal foo* on main (non-Windows), now matches foo\ + anything
4 Classes copied verbatim expose RE2 Perl classes minor [\d] meant literal d under filepath.Match, now means any digit
5 Regexp compiled on every matchGlob call nit avoidable, patterns are static per Checker

Finding 1 matters most: it recreates the exact failure class this PR sets out to fix, with the boundary moved from / to \n.

Pre-existing and out of scope, noted for a possible follow-up: a pattern that fails to compile silently disables its rule (return false, same direction as ErrBadPattern on main), so a typo in a deny rule fails open; validating patterns at config load would surface this.

Comment thread pkg/permissions/permissions.go Outdated
// Full glob match (also handles exact matches). Unlike filepath.Match, the
// translated "*"/"?" span any character, so patterns match values that
// contain path separators.
re, err := globToRegexp(pattern)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

regexp.Compile runs on every matchGlob call, i.e. per pattern per tool call (tool name plus each argument). Patterns are static for a Checker's lifetime, so regexps could be compiled once at construction, or memoized in a package-level sync.Map keyed by the lowered pattern. Fine as a follow-up; not blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Did it rather than defer, it was small 😅
Package-level sync.Map keyed by the lowered pattern, so a rule compiles once. Cache is bounded by the number of configured rules since patterns come from config, not from tool input.

Comment thread pkg/permissions/permissions.go Outdated
// regexp share, are copied through verbatim; every other character is matched literally.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
var b strings.Builder
b.WriteString(`\A`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In Go regexp, . does not match \n unless the s flag is set, so the translated wildcards stop at newlines exactly the way filepath.Match stopped at /. Shell commands are routinely multi-line (heredocs, && chains), so a non-trailing-wildcard deny rule still fails open for them, and some values that matched on main stop matching:

pattern value main this branch
*rm -rf* "echo hi\nrm -rf /" no match no match (still fails open)
*b* "a\nb" match no match (regression)
a?b "a\nb" match no match (regression)

(filepath.Match wildcards stop only at path separators; they do cross \n, hence the regression rows.)

Fix: b.WriteString(`(?s)\A`). Included in the full rewrite suggested in the comment below on the default: branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added your regression rows: {"*b*", "a\nb", true} and {"a?b", "a\nb", true} (both were regressions vs main),
plus *rm -rf* against "echo hi\nrm -rf /", and a multi-line case in the deny test since that's the whole point of the PR.

Comment thread pkg/permissions/permissions.go Outdated
i++
case '[':
if end := classEnd(pattern, i); end > 0 {
b.WriteString(pattern[i : end+1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two semantic notes on copying class bodies verbatim:

  • RE2 accepts more inside [...] than glob: [\d] meant literal d under filepath.Match but is the digit class in RE2 (d no longer matches, 5 does). Perl/POSIX classes silently leak into glob syntax. Worth either escaping \ inside copied classes or documenting the extension.
  • The leading-]-is-literal rule ([]a], [^]]) is POSIX glob behavior, not filepath.Match behavior (main returned ErrBadPattern, so no match). Arguably an improvement, but the PR description's "keeps all existing matchGlob semantics" does not hold for these; a line in the doc comment would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fixed rather than documented. Class bodies are translated member-by-member now: glob \x escapes resolve
to the literal, and the other members get escaped, so [\d] is the literal d and [[:alpha:]] can't smuggle
in a POSIX class. And I dropped my leading-]-is-literal rule , you're right it was POSIX, not
filepath.Match, so []a] is back to matching nothing like it did under ErrBadPattern. Table has [\d]
against both d and 5.

Comment thread pkg/permissions/permissions.go Outdated
b.WriteString(regexp.QuoteMeta("["))
i++
default:
b.WriteString(regexp.QuoteMeta(string(c)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

c is a single byte. For a multi-byte UTF-8 rune, string(c) converts each byte to its own (wrong) rune, so the compiled regexp contains mojibake and literal non-ASCII patterns stop matching:

pattern value main this branch
café café match no match (emitted regexp: \Acafé\z)
*café* xx café yy match no match

Byte-wise scanning itself is safe (all glob metacharacters are ASCII, UTF-8 continuation bytes are >= 0x80); the fix is to accumulate literal runs and quote whole substrings instead of one byte at a time. Suggested replacement (also includes the (?s) fix):

func globToRegexp(pattern string) (*regexp.Regexp, error) {
	var b strings.Builder
	// (?s): argument values are often multi-line, "." must match "\n" too.
	b.WriteString(`(?s)\A`)
	lit := 0
	flush := func(end int) {
		if lit < end {
			b.WriteString(regexp.QuoteMeta(pattern[lit:end]))
		}
	}
	for i := 0; i < len(pattern); {
		switch pattern[i] {
		case '*':
			flush(i)
			b.WriteString(`.*`)
			i++
			lit = i
		case '?':
			flush(i)
			b.WriteByte('.')
			i++
			lit = i
		case '[':
			if end := classEnd(pattern, i); end > 0 {
				flush(i)
				b.WriteString(pattern[i : end+1])
				i = end + 1
				lit = i
				continue
			}
			// Unterminated class: "[" stays in the literal run.
			i++
		default:
			i++
		}
	}
	flush(len(pattern))
	b.WriteString(`\z`)
	return regexp.Compile(b.String())
}

This variant passes the existing TestMatchGlob table, the new separator-crossing cases, and the newline/UTF-8 cases from this review.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! string(c) on a byte was just wrong. Took your rewrite , accumulating literal runs and quoting
whole substrings. Kept the byte-wise scan for the reasons you gave (metacharacters are all ASCII, continuation
bytes are >= 0x80) and noted that in the doc comment so the next reader doesn't "fix" it back. café and
*café* are in the table now.

}
for ; i < len(pattern); i++ {
switch pattern[i] {
case '\\':

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

classEnd honors \] escapes inside a class, but outside classes \ is now always a literal backslash. On main, filepath.Match treated \ as an escape on non-Windows (foo\* matched literal foo*, and the fast-path comment mentions exactly that case) and as a literal on Windows. This branch makes it a literal everywhere, which is a defensible unification but is a behavior change on non-Windows. Options:

  1. Handle \x outside classes as literal x (one extra case '\\': in the translator), keeping non-Windows main behavior and making escapes work consistently inside and outside classes.
  2. Keep backslash-as-literal and document the divergence in the matchGlob doc comment.

Either works; the current state (escape inside classes, literal outside) is the one combination that is hard to explain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Went with option 1. \x outside a class is now a literal x, so escapes behave the same on both sides of a
bracket and foo\* matches literal foo* again like it did on main (non-Windows). You were right that the
old split , escape inside, literal outside , was the one combination nobody could explain. The remaining
Windows divergence (\ was a literal there) is deliberate and now in the doc comment.

// paths, URLs) routinely contain "/", so wildcards must not stop at it.
{"*rm -rf*", "rm -rf /", true},
{"*/etc/*", "cat /etc/passwd", true},
{"a?c", "a/c", true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new rows cover the / boundary but not the two other boundaries the rewrite is sensitive to. Suggested additions:

// "*" and "?" must also span newlines: shell commands are often multi-line.
{"*rm -rf*", "echo hi\nrm -rf /", true},
{"a?b", "a\nb", true},
// Multi-byte UTF-8 in patterns must keep matching literally.
{"café", "café", true},
{"*café*", "xx café yy", true},

All four rows fail on this branch as-is and pass with the fixes from the other comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All four added, plus {"*b*", "a\nb", true} and the escape/class rows (foo\* vs foo*/foobar, [\d] vs
d/5). Also extended TestDenyGlobCrossesPathSeparator with the multi-line command.

Confirmed they fail on the previous commit and pass now 👍

@parveshsaini
parveshsaini requested a review from Sayt-0 July 13, 2026 18:56
@parveshsaini

Copy link
Copy Markdown
Contributor Author

Hey @Sayt-0! I've addressed the requested changes here. Would appreciate a quick review whenever you get the chance!

Many Thanks 🚀

@Sayt-0 Sayt-0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM ! Thank you for the contribution ! One more thing : can you sign your commits ?

@parveshsaini
parveshsaini force-pushed the parveshsaini/fix/permission-deny-glob-separator branch from 7a2f515 to 0892c5a Compare July 15, 2026 19:56
@parveshsaini

Copy link
Copy Markdown
Contributor Author

LGTM ! Thank you for the contribution ! One more thing : can you sign your commits ?

Many Thanks @Sayt-0! I just signed them 🚀

@Sayt-0

Sayt-0 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

CI is currently red, so this cannot be merged as is. The failure is not caused by this PR's code, but it still needs to be resolved on this branch.

Item Detail
Failing job build-and-test (run 29446416662)
Failing test TestDocYAMLSnippetsAreValid/../../docs/tools/scheduler/index.md:74 (pkg/config)
Related to this diff no; the PR only touches pkg/permissions, whose suite is green
Cause the branch is ~200 commits behind main; the CI merge snapshot hit a docs/schema mismatch on main that has since been fixed
Verified with current main merged into this branch, go test ./pkg/config/ and go test ./pkg/permissions/ both pass

Action needed before merge:

  • Rebase the branch onto current main (or merge main into it) and push, so CI re-runs against a fresh merge snapshot.
  • Keep the commits signed when rebasing (a rebase rewrites them).

Signed-off-by: parveshsaini <parveshsaini619@gmail.com>
Signed-off-by: parveshsaini <parveshsaini619@gmail.com>
Signed-off-by: parveshsaini <parveshsaini619@gmail.com>
@parveshsaini
parveshsaini force-pushed the parveshsaini/fix/permission-deny-glob-separator branch from 0892c5a to aa5d3b1 Compare July 16, 2026 12:17
@dgageot
dgageot merged commit c7cfaa3 into docker:main Jul 16, 2026
8 checks passed
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
docker-agent 1.111.0

Created-by: HarmonybrewBot
Commit-by: HarmonybrewBot
Merged-by: HarmonybrewBot
Description: Created by `brew bump`

---

Created with `brew bump-formula-pr`.<details>
  <summary>release notes</summary>
  <pre>This release adds a new scheduler toolset, inline Mermaid diagram rendering, model switching in the lean TUI, project config autodiscovery, and a range of bug fixes and performance improvements across the agent, TUI, and server.

## What's New

- Adds a built-in `scheduler` toolset with `create_schedule`, `list_schedules`, and `cancel_schedule` tools, enabling agents to schedule instructions to run once at a time, after a delay, or on a recurring interval
- Adds prompt cache miss warnings via an opt-in `warn_on_cache_miss` user setting that emits notifications when cached input tokens are absent after the first session response
- Adds inline Mermaid diagram rendering in the TUI
- Adds model switching (`/model`) to the lean TUI and shares fuzzy model search between the lean and full TUIs
- Adds autodiscovery of `docker-agent.yaml`, `docker-agent.yml`, and `docker-agent.hcl` project config files for no-argument local `docker agent run`
- Surfaces background-job elicitation (user-input) requests in the TUI, ensuring each request opens exactly one dialog regardless of which runtime delivers it
- Makes a custom `base_url` on a model automatically imply `bypass_models_gateway: true`, removing the need to set it explicitly

## Improvements

- Improves session cost details in the TUI: aligns layout, color-codes cost percentages per message, groups averages together, and puts "Total" on its own line
- Speeds up model switcher credential discovery by deduplicating credential names and resolving lookups concurrently, reducing model-picker latency
- Parallelizes toolset startup and prefetches the MCP catalog off the load critical path, reducing agent startup time

## Bug Fixes

- Fixes glob wildcard matching in permissions so `*` and `?` span path separators (`/`) in argument values such as file paths and URLs
- Fixes a bug where setting `defer_all: true` on a toolset caused the toolset's custom `Instructions` to be permanently dropped from the agent's context
- Fixes `SafetyPolicy` not persisting across turns, causing users to be re-prompted for tool approvals they had already opted into
- Fixes background elicitations over the API so requests from concurrent background jobs are replayable and answerable instead of being auto-declined
- Fixes Anthropic cache breakpoints exceeding the hard limit of 4 when deferred tools are present, which caused API errors at runtime
- Fixes the TUI editor not regaining focus after external editing (`Ctrl+G`), which caused Enter to route to the transcript instead of sending the composed message
- Fixes the scheduler schema so `type: scheduler` is accepted in agent config files (was previously rejected by JSON schema validation)
- Fixes foreground elicitations being delivered twice into the app event stream
- Fixes a mutex unlock in the scheduler's `setRuntime` method to use `defer` for safer lock release
- Fixes VCR cassette path normalization for portable prompt-file matching
- Fixes CI workflow YAML indentation that caused all CI runs to fail before any job could start

## Technical Changes

- Adds `MapSlice` fan-out helper in the concurrent utilities package and uses it for parallel toolset operations
- Serializes interactive OAuth flows across toolsets to prevent concurrent conflicts
- Brings the documentation portal to WCAG 2.1 AA conformance across light and dark themes and adds a pa11y-ci CI gate to maintain it
- Adds new documentation pages for the scheduler toolset, sandbox templates, custom commands, user settings, context and compaction guide, and fixes stale command syntax in the Named Commands docs
---

## What's Changed
* docs: update CHANGELOG.md for v1.110.0 by @docker-read-write[bot] in docker/docker-agent#3665
* fix: repair CI workflow YAML and failing AgentsMd e2e test on main by @dgageot in docker/docker-agent#3666
* feat(tools): add "scheduler" toolset to run instructions on a time or recurring interval by @dwin-gharibi in docker/docker-agent#3632
* fix(config): add scheduler toolset to agent-schema.json type enum by @aheritier in docker/docker-agent#3670
* feat: custom base_url implies bypass_models_gateway by @dgageot in docker/docker-agent#3667
* feat: add prompt cache miss warnings by @rumpl in docker/docker-agent#3671
* Improve session cost details by @rumpl in docker/docker-agent#3672
* perf: speed up model switcher credential discovery by @rumpl in docker/docker-agent#3673
* Add model switching to the lean TUI by @rumpl in docker/docker-agent#3674
* feat(tui,app): surface & correlate background-job elicitations (#3584) by @aheritier in docker/docker-agent#3624
* chore: bump go-isatty, openai-go, and libopenapi by @dgageot in docker/docker-agent#3675
* ci: bump golangci-lint-action to v9.3.0 and fix DeferMutexUnlock lint offense by @dgageot in docker/docker-agent#3676
* ci: bump pinned actions to latest same-major versions by @dgageot in docker/docker-agent#3677
* feat(server): session-scoped elicitation sink for API/server runtimes (#3584) by @aheritier in docker/docker-agent#3625
* Render Mermaid diagrams inline by @rumpl in docker/docker-agent#3679
* fix(server): surface background elicitations over API by @Sayt-0 in docker/docker-agent#3678
* fix(teamloader): preserve deferred toolset instructions by @Piyush0049 in docker/docker-agent#3680
* fix(permissions): match glob wildcards across path separators by @parveshsaini in docker/docker-agent#3605
* docs(agents): fix stale command syntax and sub_agents claim in Named Commands by @aheritier in docker/docker-agent#3684
* docs(sandbox): document Docker Sandboxes integration and published sbx templates by @aheritier in docker/docker-agent#3682
* docs: surface orphan pages and add compaction, commands, and user-settings docs by @aheritier in docker/docker-agent#3683
* fix(docs): WCAG 2.1 AA accessibility remediation for the docs portal by @aheritier in docker/docker-agent#3687
* fix(tui): refocus the editor after external editing so Enter sends the content by @dgageot in docker/docker-agent#3690
* Implements the path-aware, two-tier documentation accessibility CI gate by @aheritier in docker/docker-agent#3691
* feat(run): autodiscover project agent config by @HajimohammadiNet in docker/docker-agent#3681
* perf: parallelize toolset startup and prefetch MCP catalog by @dgageot in docker/docker-agent#3688
* fix(anthropic): keep cache breakpoints within Anthropic's limit of 4 with deferred tools by @dgageot in docker/docker-agent#3692
* fix(docs): render --link-hover on light-mode callout-link hover by @aheritier in docker/docker-agent#3693
* build(deps): bump the pip group across 1 directory with 2 updates by @dependabot[bot] in docker/docker-agent#3697
* fix(session): persist SafetyPolicy across turns by @trungutt in docker/docker-agent#3685

## New Contributors
* @parveshsaini made their first contribution in docker/docker-agent#3605
* @HajimohammadiNet made their first contribution in docker/docker-agent#3681

**Full Changelog**: docker/docker-agent@v1.110.0...v1.111.0
</pre>
  <p>View the full release notes at <a href="https://github.com/docker/docker-agent/releases/tag/v1.111.0">https://github.com/docker/docker-agent/releases/tag/v1.111.0</a>.</p>
</details>
<hr>

See merge request: Harmonybrew/homebrew-core!14465
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/security Authentication, authorization, secrets, vulnerabilities kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants