feat: add open_url built-in toolset#3276
Conversation
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
docker-agent
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
[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 platformopenhelper."
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.compasses config validation and is forwarded verbatim toxdg-open/open, injecting an extra argument into the OS launcher. - A bare path like
/etc/passwdwith 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) |
There was a problem hiding this comment.
[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
|
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 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
}
To make this property visible and lock it in within this PR, I pushed
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 |
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_urltoolset 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_urlexposes a single tool that opens the configured URL in the user's default browser. Any OS-dispatchable scheme works, includinghttps://, custom app deep links likedocker-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 usesopenon macOS,xdg-openon Linux, andrundll32on Windows, viapkg/browser. A second commit wraps the execution context withcontext.WithoutCancelso a finishing agent turn cannot kill the fire-and-forget launcher before the OS hands off to the browser.No breaking changes. The feature is additive and fully opt-in.