Skip to content

fix(guardrails): mask PII inside a tool_result's nested content - #12930

Merged
diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.51from
ntdatt812:fix/pii-nested-tool-result
Sep 10, 2026
Merged

diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.51from
ntdatt812:fix/pii-nested-tool-result

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

applyToContentValue() descends into a content block's content only when it is a string:

if (typeof record.content === "string") { … }

A tool_result carries its payload as an array of parts — the shape every agentic client sends back after running a tool — so the string test walks past it, and the tool output reaches the provider unmasked.

Driving PIIMaskerGuardrail.preCall() with PII_REDACTION_ENABLED=true:

{"type":"text","text":"visible [SSN_REDACTED]"}
{"type":"tool_result","tool_use_id":"toolu_1",
 "content":[{"type":"text","text":"tool output 123-45-6789"}]}

Why this is worse than it looks

The sibling block is redacted. So the guardrail returns modified: true, reports {detections: 1, redacted: true}, and the payload visibly contains a [SSN_REDACTED]. Every signal downstream says the masker did its job, while a second copy of the same value went out untouched. A guardrail that fails loudly is a bug; one that fails while reporting success is the reason I wrote a test for the counted-detection case too.

CredentialMaskerGuardrail, in the same directory, walks the entire payload via walkValue. Only the PII masker stopped at depth one.

The change

sanitizeMessageLikeList() already calls applyToContentValue for a message's content. Making applyToContentValue do the same for a nested block is the same call, one level down, so the two agree on how deep masking goes:

if ("content" in record) {
  const result = applyToContentValue(record.content, detections);
  modified ||= result.modified;
  record.content = result.value;
}

This subsumes the string branch it replaces — applyToContentValue sanitizes a string and recurses into an array — so no case is lost. The payload is a JSON.parse(JSON.stringify(...)) round-trip before this runs, so it is acyclic and the recursion is bounded by its nesting; no seen set is needed.

maskResponsesOutput() on the response path has the same depth-one shape. I have left it alone rather than widen this PR, and I am happy to send it separately.

Verification

tests/unit/pii-nested-tool-result.test.ts, 6 tests. Checked by breaking it:

reverted killed
back to masking only a string content the 3 nesting tests + the detection-count test
stop descending into content at all those 4, plus the string-content regression guard
recurse but drop the modified flag the deep-nesting and detection-count tests

Under the first mutation the two guards — a string content on a block is still masked and a payload with nothing to mask is passed through unchanged — stay green, which is what should happen: that mutation restores the old behaviour for exactly the cases the old code handled.

eslint clean on both files; tsc -p tsconfig.typecheck-core.json reports 0 errors.

applyToContentValue() descended into a content block's `content` only when
it was a string. A tool_result carries its payload as an array of parts --
the shape every agentic client sends back after running a tool -- so the
string test walked past it and the tool output reached the provider intact.

    {"type":"text","text":"visible [SSN_REDACTED]"}
    {"type":"tool_result","content":[{"type":"text","text":"ssn 123-45-6789"}]}

The failure is quiet in the worst way: the sibling text block is redacted,
so the payload comes back modified with a redaction in it and the
guardrail reports a detection. Nothing downstream can tell that a second
copy of the same value went out untouched.

sanitizeMessageLikeList() already calls applyToContentValue for `content`
one level up. Making applyToContentValue do the same for a nested block
makes the two agree on how deep masking goes, and covers the string case
it replaces, since applyToContentValue sanitizes a string and recurses
into an array. The payload is a JSON round-trip, so it is acyclic and the
recursion is bounded by its nesting.

CredentialMaskerGuardrail, in the same directory, already walks the whole
payload. Only the PII masker stopped at depth one.

Verified: 6 tests. Three mutations, all killed -- reverting to the
string-only test leaves exactly the two regression guards green, which is
what should happen. eslint clean, typecheck-core reports 0 errors.
@diegosouzapw
diegosouzapw merged commit 403a1a6 into cheaper-inference:release/v3.8.51 Sep 10, 2026
9 of 16 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…per-inference#12930)

Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants