mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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", {}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user