Compare commits

..
Author SHA1 Message Date
BaaRai fcee6af45e secrets: encrypt the evidence snapshot heads at rest
traffic_evidence_snapshots.req_head and resp_head join the secret column registry (AAD per column, enc:v1:). Heads are sealed at insert and opened on every read; the startup key check, the no-key refusal and the idempotent backfill cover them. Archives keep the heads sealed; the import opens them to check the snapshot ID and the insert seals them once.
2026-11-13 22:56:52 +01:00
BaaRai b74ff21e11 Merge pull request #217 from BaaRai/feat/secrets-phase3-tools-exec
secrets: encrypt the exec spec of custom tools at rest
2026-11-13 22:07:01 +01:00
BaaRai 7899662aee secrets: keep a masked tool secret only for its saved destination
As for the MCP env (#213), a placeholder in a custom tool's exec keeps the
stored header, proxy or body only while the url (exact string) and the kind
are unchanged. A changed or emptied url, or a kind change, with a resolved
placeholder is refused (400 on the API and the test endpoint, a tool error
for update_custom_tool) before any write and before the test request is
sent. An explicit value with a new url is allowed.
2026-11-13 21:58:19 +01:00
BaaRai 68976a7680 secrets: encrypt the exec spec of custom tools at rest
Phase 3 of secrets at rest (#213, #216), same AES-256-GCM scheme and key.

- tools.exec joins the secret columns for system=false rows (AAD
  tools.exec): the whole JSON is sealed in the {"__artex_enc":"enc:v1:..."}
  envelope, so headers, body, proxy, script code and command are covered;
  {} stays {}
- CreateCustomTool and UpdateCustomTool seal it; scanTool opens it, so
  GetTool, ListTools and ListCustomTools (and through them customTools,
  the platform tools and the test endpoint) see the clear JSON in memory
- the startup backfill, the no-key refusal and the wrong-key check cover
  it, for system=false rows only; built-in rows stay in clear for the seeds

API: GET /api/tools returns the header values, proxy and body of a custom
tool masked, with headers_set, proxy_set and body_set. On update (API and
update_custom_tool) the placeholder keeps the stored value, an explicit
value replaces it and "" clears it; on create (API and create_custom_tool)
a placeholder is refused. The test endpoint takes an optional key: the
saved tool's stored values replace the placeholders; the editor sends it.
2026-11-13 21:52:27 +01:00
BaaRai 5aab3d3df8 Merge pull request #216 from BaaRai/feat/secrets-phase2
secrets: encrypt search keys, proxies and the LLM profile proxy at rest
2026-11-13 21:40:04 +01:00
15 changed files with 882 additions and 19 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`
+245
View File
@@ -0,0 +1,245 @@
package db
import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"
)
const (
evCookie = "session=ev-cookie-secret"
evBearer = "Bearer ev-auth-secret"
)
// evidenceFixture records a finding. With legacy=false the snapshot goes through
// the normal insert path; with legacy=true it is written in clear by raw SQL.
func evidenceFixture(t *testing.T, d *DB, tag string, legacy bool) (*Task, int64, TrafficEvidenceSnapshot) {
t.Helper()
task, err := d.CreateTask("evidence heads at rest "+tag, "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
s := TrafficEvidenceSnapshot{SourceTrafficID: "t-sec-ev-" + tag, CapturedAt: 1, URL: "http://ev.example/" + tag, Method: "GET", Status: 200,
ReqHead: "GET /" + tag + " HTTP/1.1\nCookie: " + evCookie + "\nAuthorization: " + evBearer + "\n",
RespHead: "HTTP/1.1 200 OK\nSet-Cookie: " + evCookie + "\n", ReqHash: "rh-" + tag, RespHash: "ph-" + tag}
s.ID = TrafficSnapshotID(s)
t.Cleanup(func() {
d.Exec(`DELETE FROM task_archives WHERE task_id=$1`, task.ID)
d.DeleteTaskCascade(task.ID, false, true) // findings and their bindings too
if _, err := d.Exec(`DELETE FROM traffic_evidence_snapshots WHERE id=$1`, s.ID); err != nil {
t.Errorf("cleanup snapshot: %v", err)
}
})
f, err := d.Exploration(task.ExplorationID).RecordFinding(t.Context(), RecordFindingInput{TaskID: task.ID, ExplorationID: task.ExplorationID, Summary: "evidence " + tag, Severity: "low"})
if err != nil {
t.Fatal(err)
}
if legacy {
_, err = d.Exec(`INSERT INTO traffic_evidence_snapshots(id,source_traffic_id,captured_at,url,method,status,req_head,resp_head,req_hash,resp_hash,req_len,resp_len)
VALUES($1,$2,1,$3,'GET',200,$4,$5,$6,$7,0,0)`, s.ID, s.SourceTrafficID, s.URL, s.ReqHead, s.RespHead, s.ReqHash, s.RespHash)
if err == nil {
_, err = d.Exec(`INSERT INTO finding_traffic_bindings(finding_id,snapshot_id,position) VALUES($1,$2,0)`, f.FindingID, s.ID)
}
} else {
err = d.WithEvidenceTx(t.Context(), func(tx *sql.Tx) error {
return AddFindingTrafficTx(tx, f.FindingID, []PreparedTrafficEvidence{{Ref: TrafficRef{TrafficID: s.SourceTrafficID, Role: "proof"}, Snapshot: s}})
})
}
if err != nil {
t.Fatal(err)
}
return task, f.FindingID, s
}
// rawHeads reads the stored heads; both must be sealed and hide the secrets.
func rawHeads(t *testing.T, d *DB, id string) (req, resp string) {
t.Helper()
if err := d.QueryRow(`SELECT req_head,resp_head FROM traffic_evidence_snapshots WHERE id=$1`, id).Scan(&req, &resp); err != nil {
t.Fatal(err)
}
for _, v := range []string{req, resp} {
if !isEncrypted(v) || strings.Contains(v, evCookie) || strings.Contains(v, evBearer) {
t.Fatalf("head stored in clear: %q", v)
}
}
return req, resp
}
// assertClearHeads checks the application read and that the stored value is
// sealed exactly once.
func assertClearHeads(t *testing.T, d *DB, findingID int64, want TrafficEvidenceSnapshot) {
t.Helper()
got, err := d.GetFindingTraffic(t.Context(), findingID)
if err != nil {
t.Fatal(err)
}
if len(got.Bindings) != 1 || got.Bindings[0].Snapshot.ReqHead != want.ReqHead || got.Bindings[0].Snapshot.RespHead != want.RespHead || got.Bindings[0].SnapshotID != want.ID {
t.Fatalf("read back: %+v", got.Bindings)
}
req, resp := rawHeads(t, d, want.ID)
box := activeSecretBox.Load()
if p, err := box.decrypt(colEvReqHead.aad(), req); err != nil || p != want.ReqHead {
t.Fatalf("req_head not sealed once: %q %v", p, err)
}
if p, err := box.decrypt(colEvRespHead.aad(), resp); err != nil || p != want.RespHead {
t.Fatalf("resp_head not sealed once: %q %v", p, err)
}
}
func TestEvidenceHeadsSealedAtInsert(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
t.Cleanup(func() { d.Close() })
_, fid, s := evidenceFixture(t, d, "insert", false)
assertClearHeads(t, d, fid, s)
var url, method string
if err := d.QueryRow(`SELECT url,method FROM traffic_evidence_snapshots WHERE id=$1`, s.ID).Scan(&url, &method); err != nil || url != s.URL || method != "GET" {
t.Fatalf("metadata must stay in clear: %q %q %v", url, method, err)
}
}
func TestEvidenceHeadsBackfillAndKeyChecks(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
t.Cleanup(func() { d.Close() })
_, fid, s := evidenceFixture(t, d, "legacy", true)
t.Setenv(secretKeyEnv, base64.StdEncoding.EncodeToString(testSecretKey))
if err := d.InitSecrets(t.TempDir()); err != nil {
t.Fatal(err)
}
assertClearHeads(t, d, fid, s)
req, resp := rawHeads(t, d, s.ID)
if err := d.InitSecrets(t.TempDir()); err != nil {
t.Fatal(err)
}
if r2, p2 := rawHeads(t, d, s.ID); r2 != req || p2 != resp {
t.Fatal("second backfill must leave the ciphertexts untouched")
}
// A head sealed under another key: the read and the startup check fail and
// name the column. The startup check samples one sealed row per column, so
// this row must be the only sealed snapshot.
var sealedRows int
if err := d.QueryRow(`SELECT count(*) FROM traffic_evidence_snapshots WHERE req_head LIKE 'enc:v1:%' OR resp_head LIKE 'enc:v1:%'`).Scan(&sealedRows); err != nil || sealedRows != 1 {
t.Fatalf("want only this fixture's snapshot sealed, got %d (%v)", sealedRows, err)
}
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
for _, c := range []secretColumn{colEvReqHead, colEvRespHead} {
bad, _ := other.encrypt(c.aad(), "GET / HTTP/1.1\nCookie: other\n")
if _, err := d.Exec(`UPDATE traffic_evidence_snapshots SET `+c.col+`=$1 WHERE id=$2`, bad, s.ID); err != nil {
t.Fatal(err)
}
if _, err := d.GetFindingTraffic(t.Context(), fid); err == nil || !strings.Contains(err.Error(), c.aad()) {
t.Fatalf("want a read failure naming %s, got %v", c.aad(), err)
}
if err := d.InitSecrets(t.TempDir()); err == nil || !strings.Contains(err.Error(), "secret key check") || !strings.Contains(err.Error(), c.aad()) {
t.Fatalf("want a key-check failure naming %s, got %v", c.aad(), err)
}
if _, err := d.Exec(`UPDATE traffic_evidence_snapshots SET req_head=$1,resp_head=$2 WHERE id=$3`, req, resp, s.ID); err != nil {
t.Fatal(err)
}
}
assertClearHeads(t, d, fid, s)
// No key with sealed heads: refuse, and each head counts. One head stays
// sealed so the refusal never turns into a new key.
t.Setenv(secretKeyEnv, "")
dir := t.TempDir()
withHeads := encryptedCount(t, d, dir)
for _, c := range []secretColumn{colEvReqHead, colEvRespHead} {
if _, err := d.Exec(`UPDATE traffic_evidence_snapshots SET req_head=$1,resp_head=$2 WHERE id=$3`, req, resp, s.ID); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`UPDATE traffic_evidence_snapshots SET `+c.col+`='' WHERE id=$1`, s.ID); err != nil {
t.Fatal(err)
}
if n := encryptedCount(t, d, dir); withHeads-n != 1 {
t.Fatalf("no-key refusal counted %d then %d encrypted values; %s must count", withHeads, n, c.aad())
}
}
}
// Export keeps the heads sealed; import opens them to check the snapshot ID,
// then the insert seals them once. A legacy clear archive is sealed on import.
func TestEvidenceHeadsArchiveRoundTrip(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
t.Cleanup(func() { d.Close() })
if err = d.EnsureLLMRecordsTable(); err != nil {
t.Fatal(err)
}
if err = d.EnsureLLMUsageTable(); err != nil {
t.Fatal(err)
}
for _, legacy := range []bool{false, true} {
t.Run(fmt.Sprint("legacy=", legacy), func(t *testing.T) {
task, fid, s := evidenceFixture(t, d, fmt.Sprint("archive-", legacy), false)
if err := d.SetPaused(task.ID, true); err != nil {
t.Fatal(err)
}
archive, err := d.QueueTaskArchive(task.ID)
if err != nil {
t.Fatal(err)
}
if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil {
t.Fatal(err)
}
snapshot, err := d.SnapshotTaskArchive(task.ID)
if err != nil {
t.Fatal(err)
}
exported := string(snapshot.Tables["traffic_evidence_snapshots"])
if !strings.Contains(exported, secretPrefix) || strings.Contains(exported, evCookie) || strings.Contains(exported, evBearer) {
t.Fatalf("archive must keep the heads sealed: %s", exported)
}
if err = d.CompleteTaskArchive(archive.ID, snapshot, "/tmp/evidence-heads-test.tar.zst", "fixture", 1, 1); err != nil {
t.Fatal(err)
}
var rows []map[string]any
if err = json.Unmarshal(snapshot.Tables["traffic_evidence_snapshots"], &rows); err != nil || len(rows) != 1 {
t.Fatal(rows, err)
}
if legacy { // an archive written before the heads were sealed
rows[0]["req_head"], rows[0]["resp_head"] = s.ReqHead, s.RespHead
snapshot.Tables["traffic_evidence_snapshots"], _ = json.Marshal(rows)
} else { // a head sealed under another key fails the import, naming the column
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
bad, _ := other.encrypt(colEvReqHead.aad(), s.ReqHead)
good := rows[0]["req_head"]
rows[0]["req_head"] = bad
raw, _ := json.Marshal(rows)
if _, err := ArchiveEvidenceSnapshots(&TaskArchiveSnapshot{FormatVersion: snapshot.FormatVersion, Tables: map[string]json.RawMessage{"traffic_evidence_snapshots": raw}}); err == nil || !strings.Contains(err.Error(), colEvReqHead.aad()) {
t.Fatalf("want an import failure naming %s, got %v", colEvReqHead.aad(), err)
}
rows[0]["req_head"] = good
}
list, err := ArchiveEvidenceSnapshots(snapshot)
if err != nil || len(list) != 1 || list[0].ReqHead != s.ReqHead || list[0].RespHead != s.RespHead {
t.Fatalf("archive snapshots: %+v %v", list, err)
}
// The hot row is gone (GC), so the restore inserts it again.
if _, err = d.Exec(`DELETE FROM traffic_evidence_snapshots WHERE id=$1`, s.ID); err != nil {
t.Fatal(err)
}
if _, err = d.QueueTaskArchiveRestore(archive.ID); err != nil {
t.Fatal(err)
}
if _, err = d.ClaimTaskArchiveJob(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = d.RestoreTaskArchive(archive.ID, snapshot, 0); err != nil {
t.Fatal(err)
}
assertClearHeads(t, d, fid, s)
})
}
}
+24 -2
View File
@@ -195,10 +195,29 @@ func InsertEvidenceSnapshotTx(tx *sql.Tx, s TrafficEvidenceSnapshot) error {
if s.CreatedAt.IsZero() {
s.CreatedAt = time.Now().UTC()
}
_, err := tx.Exec(`INSERT INTO traffic_evidence_snapshots
// The ID hashes the clear heads; only the stored heads are sealed.
reqHead, err := sealSecret(colEvReqHead, s.ReqHead)
if err != nil {
return err
}
respHead, err := sealSecret(colEvRespHead, s.RespHead)
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO traffic_evidence_snapshots
(id,source_traffic_id,captured_at,url,method,status,content_type,req_head,resp_head,req_hash,resp_hash,req_len,resp_len,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(id) DO NOTHING`,
s.ID, s.SourceTrafficID, s.CapturedAt, s.URL, s.Method, s.Status, s.ContentType, s.ReqHead, s.RespHead, s.ReqHash, s.RespHash, s.ReqLen, s.RespLen, s.CreatedAt)
s.ID, s.SourceTrafficID, s.CapturedAt, s.URL, s.Method, s.Status, s.ContentType, reqHead, respHead, s.ReqHash, s.RespHash, s.ReqLen, s.RespLen, s.CreatedAt)
return err
}
// openHeads decrypts the heads of a snapshot read from the table or an archive.
// Clear legacy values pass through; a value that fails to decrypt is an error.
func (s *TrafficEvidenceSnapshot) openHeads() (err error) {
if s.ReqHead, err = openSecret(colEvReqHead, s.ReqHead); err != nil {
return err
}
s.RespHead, err = openSecret(colEvRespHead, s.RespHead)
return err
}
@@ -263,6 +282,9 @@ FROM finding_traffic_bindings b JOIN traffic_evidence_snapshots s ON s.id=b.snap
if err := json.Unmarshal(raw, &b.Snapshot); err != nil {
return nil, err
}
if err := b.Snapshot.openHeads(); err != nil {
return nil, err
}
out.Bindings = append(out.Bindings, b)
}
return out, rows.Err()
+11 -2
View File
@@ -15,8 +15,17 @@ func ArchiveEvidenceSnapshots(snapshot *TaskArchiveSnapshot) ([]TrafficEvidenceS
if snapshot.FormatVersion < 3 {
return nil, ErrTaskArchiveFormatMismatch
}
err := json.Unmarshal(snapshot.Tables["traffic_evidence_snapshots"], &out)
return out, err
// The archive keeps the heads as stored (sealed). They are opened here so the
// restore can check each content-addressed ID; the insert then seals them once.
if err := json.Unmarshal(snapshot.Tables["traffic_evidence_snapshots"], &out); err != nil {
return nil, err
}
for i := range out {
if err := out[i].openHeads(); err != nil {
return nil, err
}
}
return out, nil
}
func restoreFindingTrafficTx(tx *sql.Tx, snapshot *TaskArchiveSnapshot) error {
+12 -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,11 @@ 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
// Evidence head blocks carry the captured Cookie and Authorization headers.
colEvReqHead = secretColumn{"traffic_evidence_snapshots", "req_head", false, ""}
colEvRespHead = secretColumn{"traffic_evidence_snapshots", "resp_head", false, ""}
secretColumns = append([]secretColumn{colLLMKey, colLLMProxy, colMCPEnv, colCredPass, colNotifConfig, colToolExec, colEvReqHead, colEvRespHead}, 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)
}
}
+5
View File
@@ -34,5 +34,10 @@ func runEvidenceSuite(m *testing.M) int {
panic(err)
}
defer conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(7337741002)`)
// Snapshot inserts seal the heads: load a fixed test key (env, no key file).
os.Setenv("ARTEX_SECRET_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
if err = pg.InitSecrets(""); err != nil {
panic(err)
}
return m.Run()
}
+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
+5
View File
@@ -172,6 +172,11 @@ func TestFindingTrafficAPIAndExport(t *testing.T) {
if w = req("GET", fmt.Sprintf("%s/%d", base, first), ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"binary":true`) {
t.Fatal(w.Code, w.Body)
}
// The viewer reads the heads in clear; the table stores them sealed.
var rawHead string
if err := s.m.pg.QueryRow(`SELECT req_head FROM traffic_evidence_snapshots WHERE id=$1`, list.Bindings[0].SnapshotID).Scan(&rawHead); err != nil || !strings.HasPrefix(rawHead, "enc:v1:") || !strings.Contains(w.Body.String(), `"req_head":"POST / HTTP/1.1"`) {
t.Fatalf("head at rest %q (%v), viewer %s", rawHead, err, w.Body)
}
if w = req("GET", fmt.Sprintf("%s/%d/body?side=response&offset=20001", base, first), ""); w.Code != 422 {
t.Fatal(w.Code, w.Body)
}
+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", {}),