Skip to content

feat: add open_url built-in toolset#3276

Merged
Sayt-0 merged 3 commits into
docker:mainfrom
dgageot:worktree-board-f7e79de3b87c0a7e
Jun 26, 2026
Merged

feat: add open_url built-in toolset#3276
Sayt-0 merged 3 commits into
docker:mainfrom
dgageot:worktree-board-f7e79de3b87c0a7e

Conversation

@dgageot

@dgageot dgageot commented Jun 26, 2026

Copy link
Copy Markdown
Member

Agents sometimes need to surface a URL to the user — a dashboard, a monitoring page, a deep link into another tool — without letting the model pick arbitrary destinations. The new open_url toolset fills that gap: the URL is fixed in the agent config, the model only decides when to invoke it.

Adding a toolset of type open_url exposes a single tool that opens the configured URL in the user's default browser. Any OS-dispatchable scheme works, including https://, custom app deep links like docker-desktop://, and anything else the OS knows how to handle. The tool name, description, and URL are all author-controlled; the launcher validates that the value is not a flag-like or schemeless string before dispatching. Cross-platform dispatch uses open on macOS, xdg-open on Linux, and rundll32 on Windows, via pkg/browser. A second commit wraps the execution context with context.WithoutCancel so a finishing agent turn cannot kill the fire-and-forget launcher before the OS hands off to the browser.

toolsets:
  - type: open_url
    name: open_dashboard
    url: https://example.com/dashboard

No breaking changes. The feature is additive and fully opt-in.

dgageot added 2 commits June 26, 2026 17:56
Opens a fixed, config-defined URL in the user's default browser via pkg/browser. Supports ${env.X} interpolation, custom tool names, and works cross-platform.

Assisted-By: Claude
exec.CommandContext kills the process when the context is canceled, so a
finishing agent turn could kill open/xdg-open/rundll32 before the OS handed
off to the browser. Wrap with context.WithoutCancel to prevent that.

Assisted-By: Claude
@dgageot dgageot requested a review from a team as a code owner June 26, 2026 16:16

@docker-agent docker-agent 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.

Assessment: 🔴 CRITICAL

Two confirmed security gaps: the docs and PR description both claim URL validation (scheme check + flag-prefix guard) exists in the launcher, but neither check is implemented in the code. A static or env-var-expanded URL starting with - is forwarded verbatim to xdg-open/open/rundll32, injecting unintended arguments into the OS launcher.

// exec.CommandContext kills the launcher when its context is canceled, so
// we strip cancellation (while keeping trace/values) to prevent a finishing
// or interrupted agent turn from killing the launcher mid-handoff.
if err := t.open(context.WithoutCancel(ctx), url); err != nil {

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.

[HIGH — Security] Documented URL validation (scheme check + flag-prefix guard) is not implemented

The Limitations section of the docs and the PR description both state:

"The URL must include a scheme (e.g. https://); bare paths are rejected."
"URLs that look like a command-line flag (starting with -) are refused to prevent argument injection into the platform open helper."

Neither check exists in the code. callTool passes the URL straight to t.open with no validation:

if err := t.open(context.WithoutCancel(ctx), url); err != nil {

validate.go only checks that the raw template string is non-empty (line 362–364); it does not validate the scheme or reject flag-like prefixes. This means:

  • A URL of -new-tab https://evil.com passes config validation and is forwarded verbatim to xdg-open/open, injecting an extra argument into the OS launcher.
  • A bare path like /etc/passwd with no scheme is also accepted and dispatched.

Suggested fix: Add pre-dispatch validation in callTool (or a shared helper called from both callTool and validate.go):

if strings.HasPrefix(url, "-") {
    return tools.ResultError("url must not start with '-' (would inject arguments into the OS launcher)"), nil
}
parsed, err := urlpkg.Parse(url)
if err != nil || parsed.Scheme == "" {
    return tools.ResultError(fmt.Sprintf("url %q must include a scheme (e.g. https://)", url)), nil
}

This is especially important for the post-expansion path (Finding 2), where an env var value controls the final URL.

func (t *ToolSet) callTool(ctx context.Context, _ struct{}) (*tools.ToolCallResult, error) {
url := t.url
if t.expander != nil {
url = t.expander.Expand(ctx, url, nil)

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.

[HIGH — Security] Post-expansion URL is used verbatim without re-validation — env var injection can produce flag-injecting or scheme-less URLs

When ${env.VAR} interpolation is used, the expanded value at call time may be entirely different from the raw template string that passed config-load validation. For example:

url: "${env.BROWSER_URL}"

At call time, BROWSER_URL=-new-tab https://evil.com expands to a URL starting with -, which is passed directly to xdg-open/open, injecting -new-tab as an argument to the OS launcher. Similarly, BROWSER_URL= (empty) produces a bare empty string with no scheme.

The validation in validate.go (lines 361–364) only checks the raw template string before expansion — it cannot catch what the env var resolves to at runtime:

case "open_url":
    if t.URL == "" {
        return errors.New("open_url toolset requires a url to be set")
    }

Suggested fix: Validate the expanded URL inside callTool, before calling t.open:

url = t.expander.Expand(ctx, url, nil)
// validate AFTER expansion, not before
if strings.HasPrefix(url, "-") {
    return tools.ResultError("expanded url must not start with '-'"), nil
}
if u, err := urlpkg.Parse(url); err != nil || u.Scheme == "" {
    return tools.ResultError(fmt.Sprintf("expanded url %q must include a scheme", url)), nil
}

This guards both the static-URL path and the env-var-expanded path.

The validation already lives in pkg/browser.validate (reused, not modified).
Adds a comment at the browser.Open call site and a table-driven regression
test that exercises the real opener to confirm flag-like, schemeless, empty,
and env-expanded-to-flag URLs are all rejected before any launcher runs.

Assisted-By: docker/docker-agent
@dgageot

dgageot commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Thanks for the careful security review. I looked into the CRITICAL finding closely.

The flag-prefix guard and scheme check do exist and are enforced — they live in pkg/browser.validate, which browser.Open calls before launching the OS helper. Because this PR reuses pkg/browser (rather than modifying it), that file is not in the diff, which is why the checks were not visible to the review.

The relevant guard:

func validate(raw string) error {
    if raw == "" { return errors.New("empty URL") }
    if strings.HasPrefix(raw, "-") {
        return fmt.Errorf("refusing to open URL that looks like a command-line flag: %q", raw)
    }
    u, err := url.Parse(raw)
    if err != nil { return fmt.Errorf("invalid URL %q: %w", raw, err) }
    if u.Scheme == "" {
        return fmt.Errorf("URL must include a scheme (e.g. https://): %q", raw)
    }
    return nil
}

open_url calls browser.Open after ${env.X} expansion, so an env-expanded value such as -foo is validated too — it never reaches xdg-open/open/rundll32.

To make this property visible and lock it in within this PR, I pushed 64b6e5cb0:

  • TestOpenURL_RejectsUnsafeURLs — a table-driven regression test that exercises the real default opener (browser.Open) and asserts that flag-like (-foo, --version), schemeless (example.com), empty, and env-expanded-to-flag (${env.FLAG}-malicious) URLs are all rejected before any launcher process is spawned.
  • A comment at the browser.Open call site in openurl.go documenting the guard, so the security property is discoverable without cross-referencing pkg/browser.

So the docs/PR claims are accurate; the gap was visibility, not enforcement. Happy to also add an explicit allow-list of schemes if you would prefer a stricter default than "any OS-dispatchable scheme" (kept permissive on purpose to support deep links like docker-desktop://).

@aheritier aheritier added area/config For configuration parsing, YAML, environment variables area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Jun 26, 2026
@Sayt-0 Sayt-0 merged commit fbe5050 into docker:main Jun 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config For configuration parsing, YAML, environment variables area/docs Documentation changes area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants