redact: a tool call's arguments are masked again, whatever their keys are called (7137feb4 let them out)

7137feb4 kept two kinds of strings from the masking rules, and both
reached what the agent itself wrote:

- Any object with a non-empty "signature", "thoughtSignature",
  "thought_signature" or "encrypted_content" counted as sealed by the
  vendor, so its strings went out unmasked but for the placeholders of
  values masked elsewhere. A tool_use input like a mail tool's
  {"signature":"Best, Bob","body":"DB_PASSWORD=… call 13800138000"} sent
  the password and the number in the clear; before 7137feb4 both were
  masked. A text block carrying a "signature" key did the same.
- Every key named id, …_id or …Id was kept, in a tool's arguments too:
  {"user_id":…,"phone_id":"13800138000"} went out as written.

Now only a thinking or reasoning block (Anthropic's signature, Responses'
encrypted content, reasoning.* details) or a part with Gemini's thought
signature counts as sealed, and nothing inside a tool_use input or a
functionCall's args does. In those arguments the keys are the tool's, so
no key keeps its value there (an encoded data: value still is); ids
outside them stay as 7137feb4 left them. A signed Gemini functionCall
part still goes back as written. The response side (writer.go) restores
ids again, which changes nothing for a real id and puts back a
placeholder the vendor echoed into a tool's user_id.

Ran (Docker golang:1.26.8, GOTOOLCHAIN=local): TestMaskJSONToolArgsMasked
(new: signature key and id keys in tool_use input, Gemini args, a text
block with a signature key) fails on origin/main in all four cases and
passes; 7137feb4's TestMaskJSONSignedGoesBackAsWritten,
TestMaskJSONKeepsIDs, TestSignedThinkingGoesBackAsSigned and
TestToolCallIDsGoBackAsGiven pass. gofmt -l clean; go vet -tags nogui
for linux, darwin and windows on internal/redact; go test -tags nogui
./internal/redact ./internal/gateway ./internal/usage pass. Not tried
against a real vendor.
This commit is contained in:
yetone committed 2026-10-09 11:29:07 +08:00
1 parent e4a90aa62f
commit 1965e1f0f4
2 files changed
+81 -23

No files matched your search

+60 -23
View File
@@ -20,7 +20,21 @@ var kept = map[string]bool{
}
func keep(key, s string) bool {
return kept[key] || isID(key) || strings.HasPrefix(s, "data:")
return kept[key] || strings.HasPrefix(s, "data:")
}
// keepIn is keep for a request's string at path, with its ids kept too.
// In a tool call's arguments (args) the keys are the tool's, not the
// vendor's: a "signature", "name" or "user_id" there holds what the agent
// wrote, so only an encoded data: value is kept.
func keepIn(path, key, s string, args []string) bool {
if strings.HasPrefix(s, "data:") {
return true
}
if under(path, args) {
return false
}
return kept[key] || isID(key)
}
// isID says a key holds an identifier the vendor matches against another:
@@ -38,43 +52,66 @@ func isID(key string) bool {
// Responses' encrypted reasoning.
var signatureKeys = []string{"signature", "thoughtSignature", "thought_signature", "encrypted_content"}
// signedPaths are the paths of the objects in body that carry a signature
// with a value: what is in them the vendor wrote and checks, so it goes
// back exactly as the vendor wrote it.
func signedPaths(body []byte) []string {
// scan finds two kinds of objects in body. signed are those a vendor
// sealed with a signature: what is in them the vendor wrote and checks, so
// it goes back exactly as the vendor wrote it. args are a tool call's
// arguments (Anthropic's tool_use input, Gemini's functionCall args), whose
// keys are the tool's own; nothing in them counts as sealed.
func scan(body []byte) (signed, args []string) {
found := false
for _, k := range signatureKeys {
for _, k := range append(signatureKeys, "tool_use", "functionCall", "function_call") {
found = found || bytes.Contains(body, []byte(`"`+k+`"`))
}
if !found {
return nil
return nil, nil
}
var v any
if json.Unmarshal(body, &v) != nil {
return nil
return nil, nil
}
var out []string
var visit func(path string, v any)
visit = func(path string, v any) {
var visit func(path, key string, v any)
visit = func(path, key string, v any) {
switch x := v.(type) {
case map[string]any:
for _, k := range signatureKeys {
if s, _ := x[k].(string); s != "" {
out = append(out, path)
return
}
if sealed(x) {
signed = append(signed, path)
return
}
t, _ := x["type"].(string)
for k, c := range x {
visit(join(path, k), c)
if k == "input" && (t == "tool_use" || t == "server_tool_use") ||
k == "args" && (key == "functionCall" || key == "function_call") {
args = append(args, join(path, k))
continue
}
visit(join(path, k), k, c)
}
case []any:
for i, c := range x {
visit(join(path, strconv.Itoa(i)), c)
visit(join(path, strconv.Itoa(i)), key, c)
}
}
}
visit("", v)
return out
visit("", "", v)
return signed, args
}
// sealed says x is a block the vendor signed: a part with Gemini's thought
// signature, or a thinking or reasoning block with Anthropic's signature or
// Responses' encrypted content. Another object with a key of that name
// (a text block, the request itself) holds the agent's words.
func sealed(x map[string]any) bool {
t, _ := x["type"].(string)
for _, k := range signatureKeys {
if s, _ := x[k].(string); s == "" {
continue
}
if k == "thoughtSignature" || k == "thought_signature" ||
t == "thinking" || t == "reasoning" || strings.HasPrefix(t, "reasoning.") {
return true
}
}
return false
}
// under says path is in one of the objects at roots.
@@ -255,7 +292,7 @@ func jsonEscape(s string) string {
// placeholder: either way the text no longer matched its signature, and
// Anthropic answers 400 "Invalid `signature` in `thinking` block".
func MaskJSON(body []byte, o Options) ([]byte, int) {
signed := signedPaths(body)
signed, args := scan(body)
total := 0
var seen []string // value, placeholder, value, placeholder…
put := func(kind, v string) string {
@@ -264,7 +301,7 @@ func MaskJSON(body []byte, o Options) ([]byte, int) {
return p
}
out, ok := walk(body, func(path, key, s string) string {
if keep(key, s) || signed != nil && under(path, signed) {
if keepIn(path, key, s, args) || signed != nil && under(path, signed) {
return s
}
t, n := mask(s, o, put)
@@ -280,7 +317,7 @@ func MaskJSON(body []byte, o Options) ([]byte, int) {
}
remask := replacerOf(seen)
out, _ = walk(out, func(path, key, s string) string {
if keep(key, s) || !under(path, signed) {
if keepIn(path, key, s, args) || !under(path, signed) {
return s
}
t := remask.Replace(s)
+21
View File
@@ -215,3 +215,24 @@ func TestMaskJSONKeepsIDs(t *testing.T) {
t.Errorf("the same digits in text should be masked: %s", out)
}
}
// A tool call's arguments are the agent's: a key there named like a
// signature or an id doesn't keep its value, or its neighbours', from the
// rules.
func TestMaskJSONToolArgsMasked(t *testing.T) {
o := Options{Secrets: true, Personal: true}
for _, c := range []struct{ name, body string }{
{"signature key", `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"send_email","input":{"signature":"Best, Bob","body":"DB_PASSWORD=hunter2abc1 call 13800138000"}}]}]}`},
{"id keys", `{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"user_id":"DB_PASSWORD=hunter2abc1","phone_id":"13800138000"}}]}]}`},
{"gemini args", `{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"thoughtSignature":"x","customer_id":"13800138000","note":"DB_PASSWORD=hunter2abc1"}}}]}]}`},
{"text block", `{"messages":[{"role":"user","content":[{"type":"text","text":"DB_PASSWORD=hunter2abc1 call 13800138000","signature":"x"}]}]}`},
} {
out, _ := MaskJSON([]byte(c.body), o)
if strings.Contains(string(out), "hunter2abc1") || strings.Contains(string(out), "13800138000") {
t.Errorf("%s: a value went out: %s", c.name, out)
}
if !strings.Contains(string(out), `"toolu_1"`) && strings.Contains(c.body, "toolu_1") {
t.Errorf("%s: the call's id changed: %s", c.name, out)
}
}
}