redact: a tool call's arguments are masked again, whatever their keys are called (7137feb4 let them out)
7137feb4kept 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; before7137feb4both 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 as7137feb4left 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:
1 parent
e4a90aa62f
commit
1965e1f0f4
2 files changed
+81
-23
No files matched your search
+60
-23
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user