Files
ARTEX/server/customtool.go
T
BaaRai f6b29b835b feat(intercept): pinned dialer for WebFetch and the custom http tool (S3b 2.1)
In a gated scope, the two HTTP clients the platform owns connect only to
addresses that a pin or a stored exemption covers at the moment of the
connection, on every hop.

- intercept.PinnedDialer(tool, scoped): the DialContext of both clients.
  It reads the target scope from the context net/http passes to the dial.
  Gate off, or no scope in a client built without one: plain dial, as
  today. A client built for a scope whose scope is missing at the dial
  fails closed with TARGET_GATE_UNAVAILABLE.
- dialTarget runs the gate decision on the dialled host:port with a fresh
  lookup (block rules first, exemptions, pins, port precedence) and
  connects only to the addresses it compared, never to the name. Derived
  exemptions and name-only pins dial the address as received. An address
  no pin covers finds or opens the request (new_ip / no_pin, wait 0) and
  fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled
  against the hop's own (host, port).
- WebFetch (withWebFetch, now given the run context): DialContext set on
  every agent kind; DisableDirectRetry when the run's scope is gated.
- Custom http tool: a direct call in a gated scope gets a DefaultTransport
  clone with the pinned dialer, no environment proxy and keep-alive off.
  Proxied calls are unchanged (the recording proxy is step 2.2).
- Name-only: request reason says the platform does not bind the
  connection; the first-use pin_match row records the effective state.
- Derived exemptions: a proxy URL without a port is listed with the port
  net/http dials (1080 for socks5) (OPEN-9).
- go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
2026-11-11 19:19:55 +01:00

567 lines
19 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/artex/guard"
"github.com/Autumn-27/artex/intercept"
"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, "test", req.Exec, params, tc)
case "script":
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
case "http":
res, _ = s.runHTTPTool(ctx, "test", 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, key, execRaw, params, tc)
case "script":
return s.runScriptTool(ctx, key, execRaw, params, tc)
case "http":
return s.runHTTPTool(ctx, key, 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, key string, 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)
// The hook saw the parameters, not the command they render: the target gate
// reads the rendered command here, also without the quoting of the parameters.
if res := intercept.GateRenderedCommand(ctx, key, cmd, renderTemplate(spec.Command, params, identity)); res.Code != "" {
return actool.Errorf(guard.TargetBlockMessage(res)), nil
}
// 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, key string, 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
}
// The hook saw the parameters, not the URL they render (nor a URL written in
// the template): the target gate reads the rendered URL here, before the
// tool's own timeout starts, so a wait for an approval does not consume it.
if res := intercept.GateRenderedURL(ctx, key, rawURL); res.Code != "" {
return actool.Errorf(guard.TargetBlockMessage(res)), 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)}
// Every redirect hop goes through the target gate, as the rendered URL did.
client.CheckRedirect = intercept.RedirectCheck(key)
if tr := s.httpToolTransport(ctx, key, 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
}
// httpToolTransport is the transport of one http tool call, or nil
// (http.DefaultTransport). A call through a proxy keeps httpProxyTransport: it
// dials the proxy, which makes the connection to the target. A direct call in
// a gated scope connects through the pinned dialer, to addresses a pin covers.
func (s *Server) httpToolTransport(ctx context.Context, key string, spec httpExec) *http.Transport {
if tr := s.httpProxyTransport(spec); tr != nil {
return tr
}
if !intercept.GateOn(ctx) {
return nil
}
tr := http.DefaultTransport.(*http.Transport).Clone()
// An environment proxy would be dialled instead of the target.
tr.Proxy = nil
tr.DialContext = intercept.PinnedDialer(key, true)
// One request per call: no idle connection outlives the call.
tr.DisableKeepAlives = true
return tr
}
// 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
}
}