secret-guard: count a secret once when bash returns the same output in content and structuredContent

Measured in a full-extension session: one `echo "$SECRET"` was reported as
"redacted ... x2". pi's bash tool returns its output both as content text and
as structuredContent.output, and both are (correctly) redacted. The count
now takes, per name, the highest count from any one field instead of the
sum. A test pins it: it failed before the change and passes after. Re-run
in the full-extension TUI: the warning now reads "redacted SG_TEST_TOKEN
from bash output"; 0 raw copies sent, 0 in the session file. Suite: 46/46.
This commit is contained in:
Joakim Persson
2026-10-12 14:38:41 +02:00
parent b3dc979aba
commit a88fa99d39
3 changed files with 24 additions and 3 deletions
+12 -3
View File
@@ -126,6 +126,13 @@ function addHits(into: Hits, from: Hits) {
for (const [k, n] of from) into.set(k, (into.get(k) ?? 0) + n);
}
// One tool result can carry the same output in several fields: pi's bash returns
// it as content text AND structuredContent.output. Count a name by the field that
// holds it most often, not by the sum, so a single echo is reported once.
function maxHits(into: Hits, from: Hits) {
for (const [k, n] of from) into.set(k, Math.max(into.get(k) ?? 0, n));
}
/** Replace every known value. Text with no secret comes back as the same string. */
export function redactText(text: string, known: Known): { text: string; hits: Hits } {
const hits: Hits = new Map();
@@ -185,6 +192,7 @@ interface ToolResultLike {
export function toolResultChange(event: ToolResultLike, known: Known) {
if (known.length === 0) return undefined;
const hits: Hits = new Map();
const contentHits: Hits = new Map();
const result: Record<string, unknown> = {};
let contentChanged = false;
@@ -193,14 +201,15 @@ export function toolResultChange(event: ToolResultLike, known: Known) {
const r = redactText(part.text, known);
if (r.hits.size === 0) return part;
contentChanged = true;
addHits(hits, r.hits);
addHits(contentHits, r.hits);
return { ...part, text: r.text };
});
if (contentChanged) result.content = content;
maxHits(hits, contentHits);
if (event.structuredContent !== undefined) {
const r = redactDeep(event.structuredContent, known);
addHits(hits, r.hits);
maxHits(hits, r.hits);
// Replacing content without returning structuredContent DROPS it (types.d.ts),
// so return it whenever content changed, redacted or not.
if (r.value !== event.structuredContent || contentChanged) result.structuredContent = r.value;
@@ -208,7 +217,7 @@ export function toolResultChange(event: ToolResultLike, known: Known) {
if (event.details !== undefined && event.details !== null) {
const r = redactDeep(event.details, known);
if (r.value !== event.details) {
addHits(hits, r.hits);
maxHits(hits, r.hits);
result.details = r.value;
}
}