Files
ARTEX/server/customtool.go
T
BaaRai c0b741bcf6 feat(intercept): gate the rendered targets of custom tools (S3b 1.8)
The hook reads the parameters of a custom tool call, not the URL or the
command they render, and not a target written in the template itself. The
custom command and http tools now run the target gate inside the tool, on what
they are about to run, before anything is sent or started. Gate off (the
default) is unchanged: with no scope or the flag off nothing is parsed,
resolved, read or written.

- intercept.GateRenderedURL: the target is read as net/http dials it (host of
  the parsed URL, explicit port, else the scheme default), then goes through
  the same decision as at the hook (asset block rules, exemptions, resolution,
  pin lookup) and the same request-and-wait, at most three passes. A URL whose
  target cannot be read is blocked with TARGET_GATE_UNAVAILABLE and logged as
  a decided row; no request, no lookup.
- intercept.GateRenderedCommand: the rendered command is read with the
  command-string rules of Bash, and so is the rendering without the shell
  quoting of the parameters (a quoted parameter inside a host name hides the
  name from the rules). A command that names no target passes, as in Bash.
- A gated scope without the interceptor that owns the gate blocks
  (TARGET_GATE_UNAVAILABLE), it never lets the call through.
- runCommandTool and runHTTPTool take the tool key, which names the request
  rows. The gate runs before the tool's own timeout starts.
- guard.TargetBlockMessage (was targetBlockMessage) is exported so the tools
  return the same model-facing text as the hook.
- Unchanged: the script tool (its target is in stored code; advisory, D9), the
  custom-tool test endpoint (no scope: the operator's own action, D14), the
  hook order (a rule or judge deny blocks first; the tool then never runs, so
  its rendered target is never read), the evaluation of asset rules.

Tests: TestRendered* in intercept (no database); in server, against
PostgreSQL: TestCustomHTTPRenderedTargetGated,
TestCustomCommandRenderedTargetGated, TestCustomToolTestEndpointUngated,
TestTargetGateEndToEnd (a scripted worker's WebFetch waits, a click pins, the
next call passes, revoke, it asks again), TestDenyRuleWinsBeforeRenderedTarget
and TestGateOffTaskIsUnchanged.

Not in this change: redirect targets of WebFetch and of the custom http tool
(1.9).
2026-11-11 15:04:42 +01:00

545 lines
18 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)}
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
}
}