Merge pull request #217 from BaaRai/feat/secrets-phase3-tools-exec

secrets: encrypt the exec spec of custom tools at rest
This commit is contained in:
BaaRai
2026-11-13 22:07:01 +01:00
committed by GitHub
10 changed files with 589 additions and 15 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ gunzip -c backups/artex-db-<timestamp>.sql.gz | docker compose exec -T postgres
Stored secrets (LLM API keys and proxies, MCP env, task credentials,
notification channel config, the Brave and Tavily search keys, the web search
and global proxies) are encrypted in the database with the key in `keys/secret.key`
and global proxies, the execution spec of custom tools) are encrypted in the database with the key in `keys/secret.key`
(generated at first start, mode 0600, next to `jwt.key`). **A database backup
without that key can no longer restore those secrets**: back up `keys/` with the
dump, separately. Deployments without a persistent disk set `ARTEX_SECRET_KEY`
+9 -4
View File
@@ -46,17 +46,21 @@ func (c secretColumn) aad() string {
}
// rowFilter limits a settings entry to its own row; the key is a code-owned
// constant from secretSettingKeys, never input.
// constant from secretSettingKeys, never input. tools.exec covers the
// user-created tools only: the seeds rewrite the built-in rows in clear.
func (c secretColumn) rowFilter() string {
if c.setting != "" {
return ` AND key='` + c.setting + `'`
}
if c == colToolExec {
return ` AND system=false`
}
return ""
}
// idCol names the row key: settings rows are keyed by name, the rest by id.
// idCol names the row key: settings and tools rows are keyed by name, the rest by id.
func (c secretColumn) idCol() string {
if c.setting != "" {
if c.setting != "" || c == colToolExec {
return "key"
}
return "id"
@@ -76,7 +80,8 @@ var (
colMCPEnv = secretColumn{"mcp_servers", "env", true, ""}
colCredPass = secretColumn{"task_credentials", "password", false, ""}
colNotifConfig = secretColumn{"notification_channels", "config", true, ""}
secretColumns = append([]secretColumn{colLLMKey, colLLMProxy, colMCPEnv, colCredPass, colNotifConfig}, secretSettingColumns()...)
colToolExec = secretColumn{"tools", "exec", true, ""} // system=false rows: headers, proxy, body, code, command
secretColumns = append([]secretColumn{colLLMKey, colLLMProxy, colMCPEnv, colCredPass, colNotifConfig, colToolExec}, secretSettingColumns()...)
)
// secretSettingKeys are the settings stored encrypted, AAD "settings.<key>".
+23 -8
View File
@@ -110,6 +110,13 @@ func scanTool(rows interface{ Scan(...any) error }) (*Tool, error) {
if err := rows.Scan(&t.Key, &t.System, &t.Description, &t.Schema, &agents, &t.Enabled, &t.Kind, &t.Exec, &t.Deferred); err != nil {
return nil, err
}
if !t.System { // a custom tool's exec is sealed at rest
plain, err := openSecret(colToolExec, string(t.Exec))
if err != nil {
return nil, err
}
t.Exec = json.RawMessage(plain)
}
if len(agents) > 0 {
_ = json.Unmarshal(agents, &t.Agents)
}
@@ -154,15 +161,15 @@ func (d *DB) CreateCustomTool(t *Tool) error {
if len(schema) == 0 {
schema = json.RawMessage("{}")
}
exec := t.Exec
if len(exec) == 0 {
exec = json.RawMessage("{}")
exec, err := sealToolExec(t.Exec)
if err != nil {
return err
}
agents, _ := json.Marshal(t.Agents)
if len(agents) == 0 {
agents = json.RawMessage("[]")
}
_, err := d.Exec(`
_, err = d.Exec(`
INSERT INTO tools(key, system, description, schema, agents, enabled, kind, exec, deferred)
VALUES ($1, false, $2, $3, $4, $5, $6, $7, $8)`,
t.Key, t.Description, schema, agents, t.Enabled, t.Kind, exec, t.Deferred)
@@ -176,21 +183,29 @@ func (d *DB) UpdateCustomTool(t *Tool) error {
if len(schema) == 0 {
schema = json.RawMessage("{}")
}
exec := t.Exec
if len(exec) == 0 {
exec = json.RawMessage("{}")
exec, err := sealToolExec(t.Exec)
if err != nil {
return err
}
agents, _ := json.Marshal(t.Agents)
if len(agents) == 0 {
agents = json.RawMessage("[]")
}
_, err := d.Exec(`
_, err = d.Exec(`
UPDATE tools SET description=$2, schema=$3, agents=$4, enabled=$5, kind=$6, exec=$7, deferred=$8
WHERE key=$1 AND system=false`,
t.Key, t.Description, schema, agents, t.Enabled, t.Kind, exec, t.Deferred)
return err
}
// sealToolExec encrypts a custom tool's exec spec for storage; empty means {}.
func sealToolExec(exec json.RawMessage) (string, error) {
if len(exec) == 0 {
exec = json.RawMessage("{}")
}
return sealSecret(colToolExec, string(exec))
}
// DeleteCustomTool removes a custom tool (system=false only; built-ins protected).
func (d *DB) DeleteCustomTool(key string) error {
_, err := d.Exec(`DELETE FROM tools WHERE key=$1 AND system=false`, key)
+184
View File
@@ -0,0 +1,184 @@
package db
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
// toolExecPlain is a custom http tool's exec carrying a secret in every field
// the phase 3 encryption covers.
const toolExecPlain = `{"method":"GET","url":"https://api.example/x","headers":{"Authorization":"Bearer tool-hdr-plain"},` +
`"body":"tool-body-plain","proxy":"http://u:tool-proxy-plain@p.example:3128"}`
var toolPlainSecrets = []string{"tool-hdr-plain", "tool-body-plain", "tool-proxy-plain", "tool-cmd-plain"}
func rawToolExec(t *testing.T, d *DB, key string) string {
t.Helper()
var v string
if err := d.QueryRow(`SELECT exec::text FROM tools WHERE key=$1`, key).Scan(&v); err != nil {
t.Fatal(err)
}
return v
}
// assertToolExecSealed checks a custom tool's exec is sealed at rest and read
// back in clear, equal to want, by every db getter.
func assertToolExecSealed(t *testing.T, d *DB, key, want string) {
t.Helper()
raw := rawToolExec(t, d, key)
if !isSealed(colToolExec, raw) {
t.Fatalf("tools.exec of %s not sealed at rest: %s", key, raw)
}
for _, p := range toolPlainSecrets {
if strings.Contains(raw, p) {
t.Fatalf("plaintext %q found at rest", p)
}
}
var got []json.RawMessage
if tl, err := d.GetTool(key); err != nil || tl == nil {
t.Fatalf("GetTool: %v", err)
} else {
got = append(got, tl.Exec)
}
for _, list := range []func() ([]*Tool, error){d.ListTools, d.ListCustomTools} {
ts, err := list()
if err != nil {
t.Fatal(err)
}
for _, tl := range ts {
if tl.Key == key {
got = append(got, tl.Exec)
}
}
}
if len(got) != 3 {
t.Fatalf("tool %s read by %d getters, want 3", key, len(got))
}
for _, g := range got {
if !jsonEqual(t, string(g), want) {
t.Fatalf("exec read back as %s, want %s", g, want)
}
}
}
func jsonEqual(t *testing.T, a, b string) bool {
t.Helper()
var x, y any
if json.Unmarshal([]byte(a), &x) != nil || json.Unmarshal([]byte(b), &y) != nil {
return false
}
ca, _ := json.Marshal(x)
cb, _ := json.Marshal(y)
return string(ca) == string(cb)
}
func TestToolExecSealedAtRest(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
t.Cleanup(func() {
d.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`)
d.Close()
})
if err := d.CreateCustomTool(&Tool{Key: "t_secexec", Kind: "http", Exec: json.RawMessage(toolExecPlain), Enabled: true}); err != nil {
t.Fatal(err)
}
assertToolExecSealed(t, d, "t_secexec", toolExecPlain)
first := rawToolExec(t, d, "t_secexec")
cmd := `{"command":"curl -H 'X-Key: tool-cmd-plain' {args}","timeout_ms":5000}`
if err := d.UpdateCustomTool(&Tool{Key: "t_secexec", Kind: "command", Exec: json.RawMessage(cmd), Enabled: true}); err != nil {
t.Fatal(err)
}
assertToolExecSealed(t, d, "t_secexec", cmd)
if err := d.UpdateCustomTool(&Tool{Key: "t_secexec", Kind: "http", Exec: json.RawMessage(toolExecPlain), Enabled: true}); err != nil {
t.Fatal(err)
}
if rawToolExec(t, d, "t_secexec") == first {
t.Fatal("the same exec must seal under a fresh nonce")
}
// A shell tool has no exec: {} stays {}.
if err := d.CreateCustomTool(&Tool{Key: "t_secexec_shell", Kind: "shell", Enabled: true}); err != nil {
t.Fatal(err)
}
if raw := rawToolExec(t, d, "t_secexec_shell"); raw != "{}" {
t.Fatalf("empty exec stored as %s, want {}", raw)
}
if err := d.CreateCustomTool(&Tool{Key: "t_secexec_resv", Kind: "command", Exec: json.RawMessage(`{"__artex_enc":"x"}`)}); err == nil {
t.Fatal("reserved key must be refused on write")
}
// A value sealed under another key fails the read (fail closed).
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
foreign, _ := other.seal(colToolExec, toolExecPlain)
if _, err := d.Exec(`UPDATE tools SET exec=$1::jsonb WHERE key='t_secexec'`, foreign); err != nil {
t.Fatal(err)
}
if _, err := d.GetTool("t_secexec"); err == nil || !strings.Contains(err.Error(), "tools.exec") {
t.Fatalf("a foreign ciphertext must fail the read, got %v", err)
}
if _, err := d.ListCustomTools(); err == nil {
t.Fatal("ListCustomTools must fail on a foreign ciphertext")
}
}
func TestToolExecBackfillAndKeyChecks(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
t.Cleanup(func() {
d.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`)
d.Close()
})
sysExec := `{"command":"echo system-plain"}`
for _, q := range []struct {
key, exec string
system bool
}{{"t_secexec_legacy", toolExecPlain, false}, {"t_secexec_sys", sysExec, true}, {"t_secexec_empty", `{}`, false}} {
if _, err := d.Exec(`INSERT INTO tools(key, system, kind, exec) VALUES ($1, $2, 'http', $3::jsonb)`, q.key, q.system, q.exec); err != nil {
t.Fatal(err)
}
}
t.Setenv(secretKeyEnv, base64.StdEncoding.EncodeToString(testSecretKey))
if err := d.InitSecrets(t.TempDir()); err != nil {
t.Fatal(err)
}
assertToolExecSealed(t, d, "t_secexec_legacy", toolExecPlain)
first := rawToolExec(t, d, "t_secexec_legacy")
if raw := rawToolExec(t, d, "t_secexec_sys"); !jsonEqual(t, raw, sysExec) {
t.Fatalf("a system tool's exec must stay in clear, got %s", raw)
}
if raw := rawToolExec(t, d, "t_secexec_empty"); raw != "{}" {
t.Fatalf("an empty exec must stay {}, got %s", raw)
}
if err := d.InitSecrets(t.TempDir()); err != nil || rawToolExec(t, d, "t_secexec_legacy") != first {
t.Fatalf("second backfill must leave the ciphertext untouched (%v)", err)
}
// Missing key: the sealed exec counts in the refusal.
t.Setenv(secretKeyEnv, "")
dir := t.TempDir()
withTool := encryptedCount(t, d, dir)
if _, err := d.Exec(`UPDATE tools SET exec='{}'::jsonb WHERE key='t_secexec_legacy'`); err != nil {
t.Fatal(err)
}
if n := encryptedCount(t, d, dir); withTool-n != 1 {
t.Fatalf("no-key refusal counted %d then %d encrypted values; tools.exec must count", withTool, n)
}
// Wrong key: an exec sealed under another key fails the startup key check.
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
foreign, _ := other.seal(colToolExec, toolExecPlain)
if _, err := d.Exec(`UPDATE tools SET exec=$1::jsonb WHERE key='t_secexec_legacy'`, foreign); err != nil {
t.Fatal(err)
}
t.Setenv(secretKeyEnv, base64.StdEncoding.EncodeToString(testSecretKey))
if err := d.InitSecrets(t.TempDir()); err == nil || !strings.Contains(err.Error(), "tools.exec") {
t.Fatalf("want a key-check failure on tools.exec, got %v", err)
}
}
+108
View File
@@ -21,6 +21,7 @@ import (
"github.com/Autumn-27/artex/egress"
"github.com/Autumn-27/artex/guard"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/artex/notify"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
@@ -72,6 +73,11 @@ func (s *Server) pgCreateCustomTool(w http.ResponseWriter, r *http.Request) {
writeErr(w, 409, "this key already exists (built-in or custom tool)")
return
}
var err error
if req.Exec, err = unmaskToolExec("", nil, req.Kind, req.Exec); err != nil { // nothing stored: a placeholder is refused
writeErr(w, 400, err.Error())
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,
@@ -110,6 +116,10 @@ func (s *Server) pgUpdateCustomTool(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "http tools must provide a parameter JSON Schema (cannot be left empty)")
return
}
if req.Exec, err = unmaskToolExec(existing.Kind, existing.Exec, req.Kind, req.Exec); err != nil {
writeErr(w, 400, err.Error())
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,
@@ -138,6 +148,7 @@ func (s *Server) pgDeleteCustomTool(w http.ResponseWriter, r *http.Request) {
// 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 {
Key string `json:"key"` // the saved tool being edited, if any: its stored values replace placeholders
Kind string `json:"kind"` // command | script | http
Exec json.RawMessage `json:"exec"`
Params map[string]any `json:"params"`
@@ -156,6 +167,23 @@ func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "invalid request body")
return
}
var storedKind string
var stored json.RawMessage
if req.Key != "" {
t, err := pg.GetTool(req.Key)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if t != nil && !t.System {
storedKind, stored = t.Kind, t.Exec
}
}
var err error
if req.Exec, err = unmaskToolExec(storedKind, stored, req.Kind, req.Exec); err != nil {
writeErr(w, 400, err.Error())
return
}
params := req.Params
if params == nil {
params = map[string]any{}
@@ -236,6 +264,86 @@ type httpExec struct {
UseRecordingProxy bool `json:"use_recording_proxy"`
}
// toolExecFlags are the response-only *_set flags of maskToolExec; never stored.
var toolExecFlags = []string{"headers_set", "proxy_set", "body_set"}
// maskToolExec is what the API shows of a custom tool's exec: every header value,
// the proxy and the body become the placeholder, each with a *_set flag.
func maskToolExec(raw json.RawMessage) json.RawMessage {
var e map[string]any
if json.Unmarshal(raw, &e) != nil || e == nil {
return raw
}
if h, ok := e["headers"].(map[string]any); ok {
set := false
for k, v := range h {
if v != "" {
h[k], set = notify.MaskedPrefix, true
}
}
e["headers_set"] = set
}
for _, k := range []string{"proxy", "body"} {
if s, ok := e[k].(string); ok {
e[k], e[k+"_set"] = maskedSecret(s), s != ""
}
}
out, _ := json.Marshal(e)
return out
}
// unmaskToolExec puts the stored value back for every header, proxy or body sent
// as the placeholder, so a client echoing the list response keeps the stored
// secret; an explicit value replaces it and "" clears it. A placeholder with
// nothing stored to keep (stored nil on create) is refused. As for the MCP env,
// a stored secret is only kept for the destination it was configured for: when
// the url (exact string) or the kind changes, a placeholder is refused.
func unmaskToolExec(storedKind string, stored json.RawMessage, kind string, in json.RawMessage) (json.RawMessage, error) {
var e, old map[string]any
if json.Unmarshal(in, &e) != nil || e == nil {
return in, nil // not an object: stored or refused as before
}
_ = json.Unmarshal(stored, &old)
changed, kept := false, false
for _, k := range toolExecFlags {
if _, ok := e[k]; ok {
delete(e, k)
changed = true
}
}
oldHeaders, _ := old["headers"].(map[string]any)
headers, _ := e["headers"].(map[string]any)
for k, v := range headers {
if s, ok := v.(string); !ok || !isMaskedSecret(s) {
continue
}
prev, ok := oldHeaders[k]
if !ok {
return nil, fmt.Errorf("header %q: masked value but nothing stored to keep", k)
}
headers[k], changed, kept = prev, true, true
}
for _, k := range []string{"proxy", "body"} {
if s, ok := e[k].(string); !ok || !isMaskedSecret(s) {
continue
}
prev, _ := old[k].(string)
if prev == "" {
return nil, fmt.Errorf("%s: masked value but nothing stored to keep", k)
}
e[k], changed, kept = prev, true, true
}
oldURL, _ := old["url"].(string)
newURL, _ := e["url"].(string)
if kept && (oldURL != newURL || storedKind != kind) {
return nil, fmt.Errorf("destination changed (url or kind) while a stored secret was kept: re-enter the masked header/proxy/body or clear it; stored secrets are not sent to a new destination")
}
if !changed {
return in, nil
}
return json.Marshal(e)
}
func timeoutOr(ms, def int) time.Duration {
if ms <= 0 {
return time.Duration(def) * time.Millisecond
+8
View File
@@ -216,6 +216,10 @@ func (s *Server) toolCreateCustomTool() actool.CoreTool {
if exist, _ := s.m.pg.GetTool(a.Key); exist != nil {
return actool.Errorf("this key already exists: " + a.Key), nil
}
var err error
if a.Exec, err = unmaskToolExec("", nil, a.Kind, a.Exec); err != nil { // nothing stored: a placeholder is refused
return actool.Errorf(err.Error()), nil
}
if err := s.m.pg.CreateCustomTool(toDBTool(a)); err != nil {
return actool.Errorf(err.Error()), nil
}
@@ -239,6 +243,10 @@ func (s *Server) toolUpdateCustomTool() actool.CoreTool {
if a.Kind == "http" && !hasSchemaProps(a.Schema) {
return actool.Errorf("http tools must provide a parameter JSON Schema (cannot be empty)"), nil
}
var err error
if a.Exec, err = unmaskToolExec(existing.Kind, existing.Exec, a.Kind, a.Exec); err != nil {
return actool.Errorf(err.Error()), nil
}
if err := s.m.pg.UpdateCustomTool(toDBTool(a)); err != nil {
return actool.Errorf(err.Error()), nil
}
+5
View File
@@ -754,6 +754,11 @@ func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) {
if ts == nil {
ts = []*db.Tool{}
}
for _, tool := range ts {
if !tool.System {
tool.Exec = maskToolExec(tool.Exec)
}
}
// Usage is best-effort decoration. Runtime tool resolution keeps using the plain
// catalog query, so agent assembly never pays for this aggregate.
counts, countErr := pg.ToolUsageCounts()
+248
View File
@@ -0,0 +1,248 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// A placeholder keeps the stored secret only for the url and kind it was saved
// with: a changed destination is refused before any write or request.
func TestCustomToolExecDestinationGuard(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
var hits atomic.Int32
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
io.WriteString(w, "auth="+r.Header.Get("Authorization"))
}))
t.Cleanup(echo.Close)
t.Cleanup(func() { pg.Exec(`DELETE FROM tools WHERE key LIKE 't_secguard%'`) })
const secret = "Bearer guard-plain-0001"
schema := `{"type":"object","properties":{"q":{"type":"string"}}}`
body := func(kind, url, hdr string) string {
e, _ := json.Marshal(map[string]any{"method": "GET", "url": url, "headers": map[string]string{"Authorization": hdr}})
return `{"key":"t_secguard","kind":"` + kind + `","enabled":true,"schema":` + schema + `,"exec":` + string(e) + `}`
}
storedExec := func() (url, auth string) {
tl, err := pg.GetTool("t_secguard")
if err != nil || tl == nil {
t.Fatalf("GetTool: %v", err)
}
var e struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
}
_ = json.Unmarshal(tl.Exec, &e)
return e.URL, e.Headers["Authorization"]
}
if resp := request("POST", "/api/tools/custom", body("http", echo.URL, secret)); resp.Code != 200 {
t.Fatalf("create: HTTP %d %s", resp.Code, resp.Body.String())
}
moved := echo.URL + "/elsewhere"
// 1. Masked header, changed url: refused, nothing written.
if resp := request("PUT", "/api/tools/custom/t_secguard", body("http", moved, "__masked__")); resp.Code != 400 {
t.Fatalf("masked header with a changed url must be refused, got HTTP %d", resp.Code)
}
if url, auth := storedExec(); url != echo.URL || auth != secret {
t.Fatalf("a refused update changed the stored exec: %s %s", url, auth)
}
// An empty url against a stored one is a change too.
if resp := request("PUT", "/api/tools/custom/t_secguard", body("http", "", "__masked__")); resp.Code != 400 {
t.Fatalf("masked header with an emptied url must be refused, got HTTP %d", resp.Code)
}
// 2. Masked header, same url: kept.
if resp := request("PUT", "/api/tools/custom/t_secguard", body("http", echo.URL, "__masked__")); resp.Code != 200 {
t.Fatalf("masked header with the same url: HTTP %d %s", resp.Code, resp.Body.String())
}
if url, auth := storedExec(); url != echo.URL || auth != secret {
t.Fatalf("the stored secret was not kept: %s %s", url, auth)
}
// 3. Test endpoint, masked header, changed url: refused, no request sent.
e, _ := json.Marshal(map[string]any{"method": "GET", "url": moved, "headers": map[string]string{"Authorization": "__masked__"}})
if resp := request("POST", "/api/tools/custom/test", `{"key":"t_secguard","kind":"http","exec":`+string(e)+`}`); resp.Code != 400 {
t.Fatalf("test endpoint with a changed url must be refused, got HTTP %d %s", resp.Code, resp.Body.String())
}
if n := hits.Load(); n != 0 {
t.Fatalf("the echo server received %d request(s); the stored secret left", n)
}
// 4. Kind change while keeping a placeholder: refused.
if resp := request("PUT", "/api/tools/custom/t_secguard", body("command", echo.URL, "__masked__")); resp.Code != 400 {
t.Fatalf("a kind change with a placeholder must be refused, got HTTP %d", resp.Code)
}
if tl, _ := pg.GetTool("t_secguard"); tl == nil || tl.Kind != "http" {
t.Fatalf("a refused kind change was written: %v", tl)
}
// 5. An explicit value with a changed url is allowed.
if resp := request("PUT", "/api/tools/custom/t_secguard", body("http", moved, "Bearer typed-0002")); resp.Code != 200 {
t.Fatalf("explicit header with a changed url: HTTP %d %s", resp.Code, resp.Body.String())
}
if url, auth := storedExec(); url != moved || auth != "Bearer typed-0002" {
t.Fatalf("explicit replace not stored: %s %s", url, auth)
}
}
// A custom tool's exec: headers, proxy and body are masked by GET /api/tools;
// the placeholder keeps the stored value on update and in the test endpoint, a
// create with it is refused, and the executor runs on the clear exec.
func TestCustomToolExecMasking(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
io.WriteString(w, "auth="+r.Header.Get("Authorization")+" body="+string(b))
}))
t.Cleanup(echo.Close)
t.Cleanup(func() { pg.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`) })
secrets := []string{"hdr-plain-0001", "body-plain-0002", "proxy-plain-0003"}
schema := `{"type":"object","properties":{"q":{"type":"string"}}}`
execOf := func(hdr, body, proxy string) string {
e, _ := json.Marshal(map[string]any{"method": "POST", "url": echo.URL,
"headers": map[string]string{"Authorization": hdr, "X-Empty": ""}, "body": body, "proxy": proxy})
return string(e)
}
toolBody := func(key, exec string) string {
return `{"key":"` + key + `","kind":"http","enabled":true,"schema":` + schema + `,"exec":` + exec + `}`
}
stored := func() map[string]any {
tl, err := pg.GetTool("t_secexec")
if err != nil || tl == nil {
t.Fatalf("GetTool: %v", err)
}
var e map[string]any
_ = json.Unmarshal(tl.Exec, &e)
return e
}
plain := execOf("Bearer "+secrets[0], secrets[1], "http://u:"+secrets[2]+"@127.0.0.1:1")
if resp := request("POST", "/api/tools/custom", toolBody("t_secexec", plain)); resp.Code != 200 {
t.Fatalf("create: HTTP %d %s", resp.Code, resp.Body.String())
}
var raw string
if err := pg.QueryRow(`SELECT exec::text FROM tools WHERE key='t_secexec'`).Scan(&raw); err != nil || !strings.Contains(raw, "__artex_enc") {
t.Fatalf("exec not sealed at rest: %s %v", raw, err)
}
// List: masked with *_set flags, nothing in clear.
resp := request("GET", "/api/tools", "")
for _, sec := range secrets {
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
t.Fatalf("GET /api/tools: HTTP %d or a secret returned in clear", resp.Code)
}
}
var list struct {
Tools []struct {
Key string `json:"key"`
Exec json.RawMessage `json:"exec"`
}
}
_ = json.Unmarshal(resp.Body.Bytes(), &list)
var masked json.RawMessage
for _, tl := range list.Tools {
if tl.Key == "t_secexec" {
masked = tl.Exec
}
}
var m map[string]any
_ = json.Unmarshal(masked, &m)
h, _ := m["headers"].(map[string]any)
if h["Authorization"] != "__masked__" || h["X-Empty"] != "" || m["body"] != "__masked__" || m["proxy"] != "__masked__" ||
m["headers_set"] != true || m["body_set"] != true || m["proxy_set"] != true || m["url"] != echo.URL {
t.Fatalf("exec not masked as expected: %s", masked)
}
// Update echoing the masked exec: every stored value is kept, no flag stored.
if resp := request("PUT", "/api/tools/custom/t_secexec", toolBody("t_secexec", string(masked))); resp.Code != 200 {
t.Fatalf("update with placeholders: HTTP %d %s", resp.Code, resp.Body.String())
}
e := stored()
if e["headers"].(map[string]any)["Authorization"] != "Bearer "+secrets[0] || e["body"] != secrets[1] ||
!strings.Contains(e["proxy"].(string), secrets[2]) || e["body_set"] != nil {
t.Fatalf("placeholders did not keep the stored exec: %v", e)
}
// Test endpoint: the saved tool's key resolves the placeholders.
noProxy := execOf("__masked__", "__masked__", "")
if resp := request("POST", "/api/tools/custom/test", `{"kind":"http","exec":`+noProxy+`}`); resp.Code != 400 {
t.Fatalf("a placeholder without a saved tool must be refused, got HTTP %d", resp.Code)
}
resp = request("POST", "/api/tools/custom/test", `{"key":"t_secexec","kind":"http","exec":`+noProxy+`}`)
if resp.Code != 200 || !strings.Contains(resp.Body.String(), "auth=Bearer "+secrets[0]+" body="+secrets[1]) {
t.Fatalf("test endpoint did not use the stored values: HTTP %d %s", resp.Code, resp.Body.String())
}
// Replace the header, keep the body, clear the proxy.
if resp := request("PUT", "/api/tools/custom/t_secexec", toolBody("t_secexec", execOf("Bearer new-0004", "__masked__", ""))); resp.Code != 200 {
t.Fatalf("update: HTTP %d %s", resp.Code, resp.Body.String())
}
e = stored()
if e["headers"].(map[string]any)["Authorization"] != "Bearer new-0004" || e["body"] != secrets[1] || e["proxy"] != "" {
t.Fatalf("replace/keep/clear: %v", e)
}
if resp := request("GET", "/api/tools", ""); strings.Contains(resp.Body.String(), "new-0004") {
t.Fatal("a replaced secret is returned in clear")
}
// Create with a placeholder: refused, nothing written.
for _, ex := range []string{execOf("__masked__", "", ""), execOf("", "__masked__", ""), execOf("", "", "__masked__")} {
if resp := request("POST", "/api/tools/custom", toolBody("t_secexec_new", ex)); resp.Code != 400 {
t.Fatalf("create with a placeholder must be refused, got HTTP %d", resp.Code)
}
}
if tl, _ := pg.GetTool("t_secexec_new"); tl != nil {
t.Fatal("a refused create wrote a row")
}
// The executor receives the clear exec from the sealed row.
tools, err := s.customTools()
if err != nil {
t.Fatal(err)
}
for _, tl := range tools {
if tl.Name() != "t_secexec" {
continue
}
res, err := tl.Call(context.Background(), json.RawMessage(`{"q":"x"}`), nil)
if err != nil || !strings.Contains(res.Flatten(), "auth=Bearer new-0004 body="+secrets[1]) {
t.Fatalf("executor output %q %v", res.Flatten(), err)
}
return
}
t.Fatal("t_secexec not built by customTools")
}
// The platform tools follow the API: create refuses a placeholder, update keeps the stored value.
func TestPlatformCustomToolPlaceholders(t *testing.T) {
s, _, _ := trafficEvidenceServer(t)
pg := s.m.PG()
t.Cleanup(func() { pg.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`) })
in := func(exec string) json.RawMessage {
return json.RawMessage(`{"key":"t_secexec_pt","kind":"command","exec":` + exec + `}`)
}
create, update := s.toolCreateCustomTool(), s.toolUpdateCustomTool()
if res, _ := create.Call(context.Background(), in(`{"command":"echo","proxy":"__masked__"}`), nil); !res.IsError {
t.Fatalf("create with a placeholder must fail: %s", res.Flatten())
}
if res, _ := create.Call(context.Background(), in(`{"command":"echo","body":"pt-body-plain"}`), nil); res.IsError {
t.Fatalf("create: %s", res.Flatten())
}
if res, _ := update.Call(context.Background(), in(`{"command":"echo 2","body":"__masked__"}`), nil); res.IsError {
t.Fatalf("update: %s", res.Flatten())
}
tl, err := pg.GetTool("t_secexec_pt")
if err != nil || tl == nil || !jsonHas(tl.Exec, "pt-body-plain") || !jsonHas(tl.Exec, "echo 2") {
t.Fatalf("update with a placeholder must keep the stored body: %v %v", tl, err)
}
}
func jsonHas(raw json.RawMessage, s string) bool { return strings.Contains(string(raw), s) }
+2 -1
View File
@@ -662,7 +662,8 @@ function CustomToolDialog({
setTesting(true);
setTestResult(null);
try {
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
// key: a saved tool's stored secrets replace the masked placeholders.
const r = await api.testCustomTool({ key: tool?.key, kind, exec: buildExec(), params });
setTestResult(r);
} catch (e) {
setTestResult({ output: (e as Error).message, is_error: true });
+1 -1
View File
@@ -1113,7 +1113,7 @@ export const api = {
t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
) => put<{ ok: boolean }>(`/tools/custom/${key}`, t),
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
testCustomTool: (body: { key?: string; kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
detectPython: () => post<{ python_interpreter: string }>("/settings/python/detect", {}),