Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Comment on lines +846 to +850
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

CurlConfig embeds ToolConfig with json:"-", which prevents curl.enabled from being set via the JSON config file (unlike other tool configs such as ExecConfig/WebToolsConfig). Remove the json:"-" tag so enabled can be configured normally while keeping envPrefix for env var support.

Copilot uses AI. Check for mistakes.

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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

This PR adds ToolsConfig.Curl and IsToolEnabled("curl"), but there is no corresponding tool registration/use of this config anywhere (e.g. no IsToolEnabled("curl") checks or NewCurlTool(...) calls in pkg/agent/*). As a result the tool will never be available at runtime even when enabled in config. Please wire CurlTool into the agent tool registry (likely in AgentLoop or NewAgentInstance) using this config.

Copilot uses AI. Check for mistakes.
case "mcp":
return t.MCP.Enabled
default:
Expand Down
268 changes: 268 additions & 0 deletions pkg/tools/curl.go
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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

CheckRedirect validates redirects against opts.AllowedDomains (unnormalized) rather than the normalized domain list stored on the tool. This can incorrectly block redirects when config includes schemes, mixed case, or trailing slashes. Normalize once in NewCurlTool and use that normalized slice for both initial request and redirect validation.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The schema for timeout declares type: integer but uses floating-point values for minimum/maximum (1.0/120.0). Some JSON-schema consumers treat this as inconsistent. Use integer values for min/max to match the declared type.

Suggested change
"minimum": 1.0,
"maximum": 120.0,
"minimum": 1,
"maximum": 120,

Copilot uses AI. Check for mistakes.
},
},
"required": []string{"url"},
}
}

func (t *CurlTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
urlStr, ok := args["url"].(string)
if !ok || strings.TrimSpace(urlStr) == "" {
return ErrorResult("url is required")
}

parsedURL, err := url.Parse(urlStr)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid URL: %v", err))
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return ErrorResult("only http/https URLs are allowed")
}

if parsedURL.Host == "" {
return ErrorResult("missing domain in URL")
}

if !isDomainAllowed(parsedURL.Hostname(), t.allowedDomains) {
return ErrorResult(fmt.Sprintf("domain %q is not in the allowed domains list", parsedURL.Hostname()))
}

method := "GET"
if m, ok := args["method"].(string); ok && m != "" {
method = strings.ToUpper(m)
}

var bodyReader io.Reader
if bodyStr, ok := args["body"].(string); ok && bodyStr != "" {
bodyReader = strings.NewReader(bodyStr)
}

req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}

req.Header.Set("User-Agent", fmt.Sprintf("picoclaw/curl (+https://github.com/sipeed/picoclaw)"))

if headers, ok := args["headers"].(map[string]any); ok {
for k, v := range headers {
if vs, ok := v.(string); ok {
req.Header.Set(k, vs)
}
}
}

timeout := curlDefaultTimeout
if tSec, ok := args["timeout"].(float64); ok && tSec > 0 {
timeout = time.Duration(tSec) * time.Second
if timeout > 120*time.Second {
timeout = 120 * time.Second
Comment on lines +158 to +162
Copy link

Copilot AI Apr 7, 2026

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 uses AI. Check for mistakes.
}
}

ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req = req.WithContext(ctxWithTimeout)

resp, err := t.client.Do(req)
if err != nil {
if ctxWithTimeout.Err() == context.DeadlineExceeded {
return ErrorResult(fmt.Sprintf("request timed out after %v", timeout))
}
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, t.maxBytes))
if err != nil {
if err == io.ErrUnexpectedEOF || strings.Contains(err.Error(), "http: request body too large") {
return ErrorResult(fmt.Sprintf("response body exceeded %d bytes limit", t.maxBytes))
}
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
Comment on lines +179 to +184
Copy link

Copilot AI Apr 7, 2026

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 uses AI. Check for mistakes.
}

var headersOut map[string]string
if resp.Header != nil {
headersOut = make(map[string]string)
for k, v := range resp.Header {
if len(v) > 0 {
headersOut[k] = v[0]
}
}
}

result := map[string]any{
"status": resp.StatusCode,
"status_text": resp.Status,
"headers": headersOut,
"body": string(body),
"url": urlStr,
"method": method,
"truncated": int64(len(body)) >= t.maxBytes,
}
Comment on lines +201 to +205
Copy link

Copilot AI Apr 7, 2026

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.

Copilot uses AI. Check for mistakes.

return &ToolResult{
ForLLM: formatCurlResult(result),
ForUser: fmt.Sprintf("HTTP %d from %s %s", resp.StatusCode, method, urlStr),
}
}

func formatCurlResult(result map[string]any) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Status: %v\n", result["status"]))
b.WriteString(fmt.Sprintf("URL: %v\n", result["url"]))
b.WriteString(fmt.Sprintf("Method: %v\n", result["method"]))

if headers, ok := result["headers"].(map[string]string); ok && len(headers) > 0 {
b.WriteString("Headers:\n")
for k, v := range headers {
b.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
}
}

if truncated, ok := result["truncated"].(bool); ok && truncated {
b.WriteString("\n[Response body truncated due to size limit]\n")
}

if body, ok := result["body"].(string); ok {
b.WriteString("\nBody:\n")
b.WriteString(body)
}

return b.String()
}

func isDomainAllowed(hostname string, allowedDomains []string) bool {
if len(allowedDomains) == 0 {
return true
}
hostname = strings.ToLower(strings.TrimSuffix(hostname, "."))
for _, domain := range allowedDomains {
if hostname == domain || strings.HasSuffix(hostname, "."+domain) {
return true
}
}
return false
}

func normalizeDomains(domains []string) []string {
result := make([]string, 0, len(domains))
seen := make(map[string]struct{})
for _, d := range domains {
d = strings.ToLower(strings.TrimSpace(d))
d = strings.TrimPrefix(d, "http://")
d = strings.TrimPrefix(d, "https://")
d = strings.TrimSuffix(d, "/")
if d == "" {
continue
}
if _, exists := seen[d]; !exists {
seen[d] = struct{}{}
result = append(result, d)
}
}
return result
}
Loading
Loading