Files
ARTEX/server/customtool.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

532 lines
17 KiB
Go

package server
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
// This file implements the custom tool executor (docs/custom-tool-design.md). tools rows
// with system=false are dispatched by kind: command (render the command → reuse the Bash
// low-level run), script (Python only; write a temp file, pass parameters via stdin JSON
// + env TOOL_*, use the configured interpreter), http (native request + optional proxy). Like traffic/orchestration tools they need no seeding (they already live in the tools table); they are injected via hostTools and filtered by binding.
// ---------- custom tool CRUD ----------
type customToolReq struct {
Key string `json:"key"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
Agents []string `json:"agents"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"` // command | script | http
Exec json.RawMessage `json:"exec"`
Deferred bool `json:"deferred"`
}
var reToolKey = reAgentKey // same rule as agent key: starts with a lowercase letter, lowercase letters/digits/underscores only
func (s *Server) pgCreateCustomTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req customToolReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
req.Key = strings.TrimSpace(req.Key)
if !reToolKey.MatchString(req.Key) {
writeErr(w, 400, "key must start with a lowercase letter and contain only lowercase letters/digits/underscores")
return
}
if req.Kind != "command" && req.Kind != "script" && req.Kind != "http" && req.Kind != "shell" {
writeErr(w, 400, "kind must be command / script / http / shell")
return
}
if req.Kind == "http" && !hasSchemaProps(req.Schema) {
writeErr(w, 400, "http tools must provide a parameter JSON Schema (cannot be left empty)")
return
}
if exist, _ := pg.GetTool(req.Key); exist != nil {
writeErr(w, 409, "this key already exists (built-in or custom tool)")
return
}
if err := pg.CreateCustomTool(&db.Tool{
Key: req.Key, Description: req.Description, Schema: req.Schema, Agents: req.Agents,
Enabled: req.Enabled, Kind: req.Kind, Exec: req.Exec, Deferred: req.Deferred,
}); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"key": req.Key})
}
func (s *Server) pgUpdateCustomTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
key := r.PathValue("key")
existing, err := pg.GetTool(key)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if existing == nil || existing.System {
writeErr(w, 400, "only custom tools can be edited")
return
}
var req customToolReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if req.Kind != "command" && req.Kind != "script" && req.Kind != "http" && req.Kind != "shell" {
writeErr(w, 400, "kind must be command / script / http / shell")
return
}
if req.Kind == "http" && !hasSchemaProps(req.Schema) {
writeErr(w, 400, "http tools must provide a parameter JSON Schema (cannot be left empty)")
return
}
if err := pg.UpdateCustomTool(&db.Tool{
Key: key, Description: req.Description, Schema: req.Schema, Agents: req.Agents,
Enabled: req.Enabled, Kind: req.Kind, Exec: req.Exec, Deferred: req.Deferred,
}); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) pgDeleteCustomTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
key := r.PathValue("key")
if err := pg.DeleteCustomTool(key); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": key})
}
// testToolReq is a dry-run request from the editor: run the given (possibly unsaved)
// exec spec with sample params, without persisting the tool. Same executor path as a
// real tool call — it runs arbitrary command/script/http on the server, which the
// custom-tool feature already allows, so no new capability is granted.
type testToolReq struct {
Kind string `json:"kind"` // command | script | http
Exec json.RawMessage `json:"exec"`
Params map[string]any `json:"params"`
}
// pgTestCustomTool executes an exec spec once and returns its raw output + error
// flag, so the editor can debug a tool before saving it. Per-kind timeouts still
// apply from the exec spec (with defaults); the outer ceiling is a hard backstop.
func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req testToolReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "invalid request body")
return
}
params := req.Params
if params == nil {
params = map[string]any{}
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
tc := &actool.ToolContext{WorkingDir: s.m.dir} // run in the project dir, like a real call
var res actool.Result
switch req.Kind {
case "command":
res, _ = s.runCommandTool(ctx, req.Exec, params, tc)
case "script":
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
case "http":
res, _ = s.runHTTPTool(ctx, req.Exec, params, tc)
case "shell":
writeErr(w, 400, "shell-kind tools are bash environment declarations with no executable content")
return
default:
writeErr(w, 400, "unknown tool kind: "+req.Kind)
return
}
writeJSON(w, 200, map[string]any{"output": res.Flatten(), "is_error": res.IsError})
}
// ---------- Python interpreter (detect + persist + override) ----------
const settingPythonInterp = "python_interpreter"
// detectPython finds a python interpreter absolute path (python3 preferred).
func detectPython() string {
for _, c := range []string{"python3", "python"} {
if p, err := exec.LookPath(c); err == nil {
return p
}
}
return ""
}
// pythonInterpreter resolves the interpreter: user-set > stored auto-detect > live
// detect. "" only when truly none found.
func (s *Server) pythonInterpreter() string {
if v, ok, _ := s.m.pg.GetSetting(settingPythonInterp); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return detectPython()
}
// seedPythonInterpreter stores the auto-detected interpreter on startup if unset
// (never clobbers a user-set value).
func (s *Server) seedPythonInterpreter() {
if v, ok, _ := s.m.pg.GetSetting(settingPythonInterp); ok && strings.TrimSpace(v) != "" {
return
}
if p := detectPython(); p != "" {
_ = s.m.pg.SetSetting(settingPythonInterp, p)
log.Printf("[custom-tool] auto-detected python interpreter: %s", p)
}
}
// ---------- exec spec ----------
type commandExec struct {
Command string `json:"command"`
TimeoutMs int `json:"timeout_ms"`
}
type scriptExec struct {
Code string `json:"code"`
TimeoutMs int `json:"timeout_ms"`
}
type httpExec struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeoutMs int `json:"timeout_ms"`
Proxy string `json:"proxy"`
UseRecordingProxy bool `json:"use_recording_proxy"`
}
func timeoutOr(ms, def int) time.Duration {
if ms <= 0 {
return time.Duration(def) * time.Millisecond
}
return time.Duration(ms) * time.Millisecond
}
// ---------- common tool construction ----------
// customTools builds CoreTools for every user-defined (system=false) tool row.
// shell-kind tools are environment hints only — they surface in the Bash tool
// description via ToolResolve and do NOT create callable tool entries here.
func (s *Server) customTools() ([]actool.CoreTool, error) {
rows, err := s.m.pg.ListCustomTools()
if err != nil {
return nil, err
}
out := make([]actool.CoreTool, 0, len(rows))
for _, t := range rows {
if t.Kind == "shell" {
continue // shell hints are handled by ToolResolve → Bash description
}
out = append(out, s.buildCustomTool(t))
}
return out, nil
}
// buildCustomTool turns one custom-tool row into a CoreTool. Empty schema → a thin
// {args:string} (thin-wrapper tool), so command/http templates can use {args}.
func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
schema := ensureSchema(t.Schema)
key, kind, execRaw := t.Key, t.Kind, t.Exec
run := func(ctx context.Context, in json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
var params map[string]any
if len(in) > 0 {
_ = json.Unmarshal(in, &params)
}
if params == nil {
params = map[string]any{}
}
switch kind {
case "command":
return s.runCommandTool(ctx, execRaw, params, tc)
case "script":
return s.runScriptTool(ctx, key, execRaw, params, tc)
case "http":
return s.runHTTPTool(ctx, execRaw, params, tc)
default:
return actool.Errorf("unknown custom tool kind: " + kind), nil
}
}
return actool.Build(actool.Spec{
Name: key, Description: t.Description, Schema: schema,
Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.Allowed()
},
Run: run,
})
}
// hasSchemaProps reports whether raw is a JSON-Schema object with ≥1 property.
// http tools require an explicit schema (the auto {args} shell can't name the
// {param} placeholders in URL/headers/body), so an empty schema is rejected.
func hasSchemaProps(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return false
}
props, _ := m["properties"].(map[string]any)
return len(props) > 0
}
// ensureSchema returns the tool's schema, or a thin {args:string} when none given.
func ensureSchema(raw json.RawMessage) map[string]any {
var m map[string]any
if len(raw) > 0 {
_ = json.Unmarshal(raw, &m)
}
props, _ := m["properties"].(map[string]any)
if len(props) > 0 {
return m
}
return map[string]any{
"type": "object",
"properties": map[string]any{
"args": map[string]any{"type": "string", "description": "command/parameters (free text)"},
},
}
}
// ---------- command: render the command → reuse the Bash low-level run ----------
func (s *Server) runCommandTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec commandExec
_ = json.Unmarshal(execRaw, &spec)
if strings.TrimSpace(spec.Command) == "" {
return actool.Errorf("command is empty"), nil
}
cmd := renderTemplate(spec.Command, params, shellQuote)
// Reuse the low-level run Bash also uses (via the Bash CoreTool.Call): it
// automatically inherits the safety floor/timeout/proxy env/output overflow. The tool is a peer of Bash sharing that low level, not routed through the Bash tool for the model to call.
bashIn, _ := json.Marshal(map[string]any{"command": cmd})
if spec.TimeoutMs > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeoutOr(spec.TimeoutMs, 120000))
defer cancel()
}
return actool.NewBash().Call(ctx, bashIn, tc)
}
// ---------- script (Python only): temp file + stdin JSON + env ----------
func (s *Server) runScriptTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec scriptExec
_ = json.Unmarshal(execRaw, &spec)
if strings.TrimSpace(spec.Code) == "" {
return actool.Errorf("script code is empty"), nil
}
interp := s.pythonInterpreter()
if interp == "" {
return actool.Errorf("no python interpreter configured or detected (set it in system settings)"), nil
}
workDir := s.m.dir
var sessionEnv []string
if tc != nil {
if tc.WorkingDir != "" {
workDir = tc.WorkingDir
}
sessionEnv = tc.Env
}
body, err := execPython(ctx, interp, key, spec.Code, params, workDir, sessionEnv, timeoutOr(spec.TimeoutMs, 120000))
if err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text(actool.Capture(tc, body)), nil
}
// execPython writes the code to a temp .py under workDir/.tools, runs it via interp
// with the params JSON on stdin + scalar params mirrored to TOOL_<NAME> env, and
// returns combined stdout+stderr (with a timeout/exit note). Standalone + testable.
func execPython(ctx context.Context, interp, key, code string, params map[string]any, workDir string, sessionEnv []string, timeout time.Duration) (string, error) {
toolsDir := filepath.Join(workDir, ".tools")
if err := os.MkdirAll(toolsDir, 0o755); err != nil {
return "", err
}
f, err := os.CreateTemp(toolsDir, key+"-*.py")
if err != nil {
return "", err
}
tmp := f.Name()
defer os.Remove(tmp)
if _, err := f.WriteString(code); err != nil {
f.Close()
return "", err
}
f.Close()
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
c := exec.CommandContext(runCtx, interp, tmp)
c.Dir = workDir
c.Env = append(os.Environ(), sessionEnv...) // session proxy env
for k, v := range params { // scalar params mirrored as TOOL_<NAME>
if sv, ok := scalarStr(v); ok {
c.Env = append(c.Env, "TOOL_"+strings.ToUpper(k)+"="+sv)
}
}
pj, _ := json.Marshal(params)
c.Stdin = bytes.NewReader(pj) // parameter JSON goes via stdin
out, err := c.CombinedOutput()
body := string(out)
if runCtx.Err() == context.DeadlineExceeded {
body += "\n... [terminated on timeout] ..."
} else if err != nil {
body += "\n[exit: " + err.Error() + "]"
}
return body, nil
}
// ---------- http: native request + proxy ----------
func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec httpExec
_ = json.Unmarshal(execRaw, &spec)
method := strings.ToUpper(strings.TrimSpace(spec.Method))
if method == "" {
method = "GET"
}
rawURL := renderTemplate(spec.URL, params, identity)
if strings.TrimSpace(rawURL) == "" {
return actool.Errorf("http url is empty"), nil
}
var bodyReader io.Reader
if spec.Body != "" {
bodyReader = strings.NewReader(renderTemplate(spec.Body, params, identity))
}
runCtx, cancel := context.WithTimeout(ctx, timeoutOr(spec.TimeoutMs, 30000))
defer cancel()
req, err := http.NewRequestWithContext(runCtx, method, rawURL, bodyReader)
if err != nil {
return actool.Errorf(err.Error()), nil
}
for k, v := range spec.Headers {
req.Header.Set(k, renderTemplate(v, params, identity))
}
client := &http.Client{Timeout: timeoutOr(spec.TimeoutMs, 30000)}
if tr := s.httpProxyTransport(spec); tr != nil {
client.Transport = tr
}
resp, err := client.Do(req)
if err != nil {
return actool.Errorf("request failed: " + err.Error()), nil
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
out := map[string]any{"status": resp.StatusCode, "body": string(respBody)}
b, _ := json.Marshal(out)
return actool.Text(actool.Capture(tc, string(b))), nil
}
// httpProxyTransport builds a Transport for the http tool's proxy config, or nil
// (direct). use_recording_proxy routes through the recording proxy + trusts its CA.
func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
proxyStr := strings.TrimSpace(spec.Proxy)
var caFile string
if spec.UseRecordingProxy {
if addr := s.m.ProxyAddr(); addr != "" {
proxyStr = "http://" + addr
caFile = s.m.ProxyCACert()
}
}
if proxyStr == "" {
return nil
}
pu, err := url.Parse(proxyStr)
if err != nil {
return nil
}
tr := &http.Transport{Proxy: http.ProxyURL(pu)}
if caFile != "" {
if pem, err := os.ReadFile(caFile); err == nil {
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(pem) {
tr.TLSClientConfig = &tls.Config{RootCAs: pool}
}
}
}
return tr
}
// ---------- helpers ----------
func identity(s string) string { return s }
// shellQuote single-quotes a value for safe shell interpolation.
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// renderTemplate replaces {name} placeholders with each param's rendered value.
func renderTemplate(tmpl string, params map[string]any, quote func(string) string) string {
out := tmpl
for k, v := range params {
out = strings.ReplaceAll(out, "{"+k+"}", quote(valToStr(v)))
}
return out
}
// valToStr renders a param value: scalars as-is, arrays/objects as compact JSON.
func valToStr(v any) string {
if sv, ok := scalarStr(v); ok {
return sv
}
b, _ := json.Marshal(v)
return string(b)
}
// scalarStr returns (string, true) for scalar values, ("", false) for arrays/objects.
func scalarStr(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case bool:
return fmt.Sprintf("%t", x), true
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x)), true
}
return fmt.Sprintf("%g", x), true
case nil:
return "", true
default:
return "", false
}
}