-
Notifications
You must be signed in to change notification settings - Fork 4k
feat(tools): add curl tool with domain whitelist and configurable limits (gh:1845) #2416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -842,6 +842,13 @@ func (c ReadFileToolConfig) EffectiveMode() string { | |
| } | ||
| } | ||
|
|
||
| type CurlConfig struct { | ||
| ToolConfig `json:"-" envPrefix:"PICOCLAW_TOOLS_CURL_"` | ||
| AllowedDomains []string `json:"allowed_domains,omitempty" env:"PICOCLAW_TOOLS_CURL_ALLOWED_DOMAINS"` | ||
| TimeoutSeconds int `json:"timeout_seconds" env:"PICOCLAW_TOOLS_CURL_TIMEOUT_SECONDS"` | ||
| MaxBytes int64 `json:"max_bytes" env:"PICOCLAW_TOOLS_CURL_MAX_BYTES"` | ||
| } | ||
|
|
||
| type ToolsConfig struct { | ||
| AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` | ||
| AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` | ||
|
|
@@ -875,6 +882,7 @@ type ToolsConfig struct { | |
| Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` | ||
| WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` | ||
| WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` | ||
| Curl CurlConfig `json:"curl" yaml:"-" envPrefix:"PICOCLAW_TOOLS_CURL_"` | ||
| } | ||
|
|
||
| // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled | ||
|
|
@@ -1362,6 +1370,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { | |
| return t.SendTTS.Enabled | ||
| case "write_file": | ||
| return t.WriteFile.Enabled | ||
| case "curl": | ||
| return t.Curl.Enabled | ||
|
Comment on lines
1370
to
+1374
|
||
| case "mcp": | ||
| return t.MCP.Enabled | ||
| default: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,268 @@ | ||||||||||
| package tools | ||||||||||
|
|
||||||||||
| import ( | ||||||||||
| "context" | ||||||||||
| "fmt" | ||||||||||
| "io" | ||||||||||
| "net/http" | ||||||||||
| "net/url" | ||||||||||
| "strings" | ||||||||||
| "time" | ||||||||||
|
|
||||||||||
| "github.com/sipeed/picoclaw/pkg/utils" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| const ( | ||||||||||
| curlDefaultTimeout = 30 * time.Second | ||||||||||
| curlDefaultMaxBytes = int64(1 << 20) | ||||||||||
| curlMaxRedirects = 5 | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| type CurlTool struct { | ||||||||||
| allowedDomains []string | ||||||||||
| client *http.Client | ||||||||||
| maxBytes int64 | ||||||||||
| } | ||||||||||
|
|
||||||||||
| type CurlToolOptions struct { | ||||||||||
| AllowedDomains []string | ||||||||||
| Proxy string | ||||||||||
| TimeoutSeconds int | ||||||||||
| MaxBytes int64 | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func NewCurlTool(opts CurlToolOptions) (*CurlTool, error) { | ||||||||||
| timeout := curlDefaultTimeout | ||||||||||
| if opts.TimeoutSeconds > 0 { | ||||||||||
| timeout = time.Duration(opts.TimeoutSeconds) * time.Second | ||||||||||
| } | ||||||||||
|
|
||||||||||
| maxBytes := curlDefaultMaxBytes | ||||||||||
| if opts.MaxBytes > 0 { | ||||||||||
| maxBytes = opts.MaxBytes | ||||||||||
| } | ||||||||||
|
|
||||||||||
| client, err := utils.CreateHTTPClient(opts.Proxy, timeout) | ||||||||||
| if err != nil { | ||||||||||
| return nil, fmt.Errorf("failed to create HTTP client for curl tool: %w", err) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| client.CheckRedirect = func(req *http.Request, via []*http.Request) error { | ||||||||||
| if len(via) >= curlMaxRedirects { | ||||||||||
| return fmt.Errorf("stopped after %d redirects", curlMaxRedirects) | ||||||||||
| } | ||||||||||
| if !isDomainAllowed(req.URL.Hostname(), opts.AllowedDomains) { | ||||||||||
| return fmt.Errorf("redirect to disallowed domain %q", req.URL.Hostname()) | ||||||||||
| } | ||||||||||
|
Comment on lines
+50
to
+56
|
||||||||||
| return nil | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return &CurlTool{ | ||||||||||
| allowedDomains: normalizeDomains(opts.AllowedDomains), | ||||||||||
| client: client, | ||||||||||
| maxBytes: maxBytes, | ||||||||||
| }, nil | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func (t *CurlTool) Name() string { | ||||||||||
| return "curl" | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func (t *CurlTool) Description() string { | ||||||||||
| return "Make HTTP requests to external APIs. Use url (required), method (GET/POST/PUT/DELETE/etc), headers (optional map), body (optional string), and timeout (optional seconds). Only allowed domains can be accessed." | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func (t *CurlTool) Parameters() map[string]any { | ||||||||||
| return map[string]any{ | ||||||||||
| "type": "object", | ||||||||||
| "properties": map[string]any{ | ||||||||||
| "url": map[string]any{ | ||||||||||
| "type": "string", | ||||||||||
| "description": "URL to request (http/https only, must match allowed domains)", | ||||||||||
| }, | ||||||||||
| "method": map[string]any{ | ||||||||||
| "type": "string", | ||||||||||
| "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)", | ||||||||||
| "enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}, | ||||||||||
| }, | ||||||||||
| "headers": map[string]any{ | ||||||||||
| "type": "object", | ||||||||||
| "description": "Optional HTTP headers as key-value pairs", | ||||||||||
| "additionalProperties": map[string]any{ | ||||||||||
| "type": "string", | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| "body": map[string]any{ | ||||||||||
| "type": "string", | ||||||||||
| "description": "Optional request body (for POST, PUT, PATCH)", | ||||||||||
| }, | ||||||||||
| "timeout": map[string]any{ | ||||||||||
| "type": "integer", | ||||||||||
| "description": "Optional timeout in seconds (default: 30, max: 120)", | ||||||||||
| "minimum": 1.0, | ||||||||||
| "maximum": 120.0, | ||||||||||
|
Comment on lines
+102
to
+103
|
||||||||||
| "minimum": 1.0, | |
| "maximum": 120.0, | |
| "minimum": 1, | |
| "maximum": 120, |
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The per-call timeout argument is enforced via context.WithTimeout, but the http.Client is also created with a fixed Timeout (from opts.TimeoutSeconds/default 30s). This prevents callers from increasing the timeout beyond the client-level timeout, even though the tool parameter schema allows up to 120s. Consider relying on context timeouts only (no http.Client.Timeout) or setting the client timeout to the maximum allowed and enforcing the per-call limit via context.
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
io.ReadAll(io.LimitReader(resp.Body, t.maxBytes)) will silently truncate the response if it exceeds t.maxBytes and will not return an oversize error, so the current oversize error handling path is ineffective. To detect oversize reliably (and set truncated accurately), read t.maxBytes+1 bytes and check whether an extra byte was present, then either error or truncate explicitly.
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
truncated is computed as len(body) >= t.maxBytes, which will mark responses of exactly t.maxBytes bytes as truncated even when they fit the limit. If you implement an oversize probe (read maxBytes+1), set truncated based on whether more than maxBytes bytes were available.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CurlConfigembedsToolConfigwithjson:"-", which preventscurl.enabledfrom being set via the JSON config file (unlike other tool configs such asExecConfig/WebToolsConfig). Remove thejson:"-"tag soenabledcan be configured normally while keepingenvPrefixfor env var support.