From 5c796e2bd408ab7c9d1dc10e582ccb27f4aa53ce Mon Sep 17 00:00:00 2001 From: zeno Date: Thu, 23 Jul 2026 17:59:04 +0800 Subject: [PATCH] fix: redact internal context from share transcripts --- deploy/cloudflare-worker/src/index.js | 116 ++++++++++++++++++-- deploy/cloudflare-worker/test/index.test.js | 93 +++++++++++++++- internal/capsule/capsule_test.go | 27 +++++ internal/capsule/preview.go | 18 ++- 4 files changed, 240 insertions(+), 14 deletions(-) diff --git a/deploy/cloudflare-worker/src/index.js b/deploy/cloudflare-worker/src/index.js index 95b9752..7f090d6 100644 --- a/deploy/cloudflare-worker/src/index.js +++ b/deploy/cloudflare-worker/src/index.js @@ -1635,9 +1635,113 @@ function hexFromBytes(bytes) { return out; } +function sanitizeTranscriptEntries(entries) { + const sanitized = []; + for (const entry of Array.isArray(entries) ? entries : []) { + if (!entry || typeof entry !== "object") continue; + const next = { ...entry }; + if (next.kind === "message") { + const text = redactInternalContext(next.text).trim(); + next.text = text === "[internal context omitted]" ? "" : text; + const images = Array.isArray(next.images) ? next.images : []; + if (!next.text && !images.length && !Number(next.omitted_images || 0)) continue; + } + if (next.kind === "tool") { + next.input_preview = redactInternalContext(next.input_preview); + next.output = redactInternalContext(next.output); + next.output_bytes = byteLengthInBrowser(next.output || ""); + } + sanitized.push(next); + } + return sanitized; +} + +function redactInternalContext(text) { + if (!text) return ""; + const visible = []; + let inHiddenBlock = false; + let silentHiddenBlock = false; + let redactedPrevious = false; + for (const line of String(text).split("\\n")) { + const trimmed = line.trim(); + if (inHiddenBlock) { + if (!silentHiddenBlock && !redactedPrevious) { + visible.push("[internal context omitted]"); + redactedPrevious = true; + } + if (endsInternalContext(trimmed)) { + inHiddenBlock = false; + silentHiddenBlock = false; + } + continue; + } + if (containsInternalContext(trimmed)) { + const silent = silentlyRedactsInternalContext(trimmed); + if (!silent && !redactedPrevious) { + visible.push("[internal context omitted]"); + redactedPrevious = true; + } + if (startsInternalContext(trimmed) && !endsInternalContext(trimmed)) { + inHiddenBlock = true; + silentHiddenBlock = silent; + } + continue; + } + visible.push(line); + redactedPrevious = false; + } + return visible.join("\\n").trim(); +} + +function silentlyRedactsInternalContext(text) { + return String(text || "").trim().startsWith(""); +} + +function containsInternalContext(text) { + const value = String(text || ""); + if (startsInternalContext(value)) return true; + return [ + "# AGENTS.md instructions", + "", + "", + "", + "", + "", + "" + ].some((marker) => value.includes(marker)); +} + +function startsInternalContext(text) { + const value = String(text || "").trim(); + return [ + "# AGENTS.md instructions", + "", + "", + "", + "", + "", + "" + ].some((prefix) => value.startsWith(prefix)); +} + +function endsInternalContext(text) { + const value = String(text || "").trim(); + return [ + "", + "", + "", + "", + "", + "", + "" + ].some((suffix) => value.includes(suffix)); +} + function renderTranscript(transcript, options = {}) { activeTranscriptSource = String(transcript.source || transcript.source_agent || ""); - const entries = (transcript.entries || []).filter((entry) => !isInternalContextEntry(entry)); + const entries = sanitizeTranscriptEntries(transcript.entries || []); const messageCount = entries.filter((entry) => entry.kind === "message").length; const toolCount = entries.filter((entry) => entry.kind === "tool").length; const imageCount = entries.reduce((count, entry) => count + (entry.images || []).filter((image) => !image.omitted).length, 0); @@ -1695,16 +1799,6 @@ function lastAssistantMessageIndex(entries) { return -1; } -function isInternalContextEntry(entry) { - if (!entry || entry.kind !== "message") return false; - const text = String(entry.text || "").trim(); - return text.startsWith("# AGENTS.md instructions for ") || - text.startsWith("") || - text.startsWith("") || - text.startsWith(""); -} - function messageNode(entry) { const article = document.createElement("article"); article.className = "message-row " + (entry.role || ""); diff --git a/deploy/cloudflare-worker/test/index.test.js b/deploy/cloudflare-worker/test/index.test.js index 8eab947..f45f3eb 100644 --- a/deploy/cloudflare-worker/test/index.test.js +++ b/deploy/cloudflare-worker/test/index.test.js @@ -257,7 +257,9 @@ test("share page serves human preview shell and agent metadata", async () => { assert.match(html, /function toolActionNode/); assert.match(html, /function renderMarkdown/); assert.match(html, /function imageGallery/); - assert.match(html, /function isInternalContextEntry/); + assert.match(html, /function sanitizeTranscriptEntries/); + assert.match(html, /function redactInternalContext/); + assert.match(html, /const entries = sanitizeTranscriptEntries\(transcript\.entries \|\| \[\]\)/); assert.match(html, /skill-chip/); assert.match(html, /function skillDetailsNode/); assert.match(html, /function stripSkillInvocation/); @@ -289,6 +291,95 @@ test("share page serves human preview shell and agent metadata", async () => { assert.equal(manifestJSON.import.skill_url, "https://github.com/z2z23n0/agent-capsule/tree/main/skills/agent-capsule"); }); +test("share page redacts internal context from full transcript entries", async () => { + const env = fakeEnv(); + const upload = await worker.fetch(new Request(BASE_URL + "/v1/shares", { + method: "POST", + body: shareForm(new Blob(["hello"])) + }), env); + assert.equal(upload.status, 201); + const created = await upload.json(); + const html = await (await worker.fetch(new Request(created.share_url), env)).text(); + const entries = [ + { + kind: "message", + role: "user", + text: [ + "", + "private plugin inventory", + "", + "# AGENTS.md instructions", + "", + "private project rules", + "", + "", + "/Users/private/workspace", + "" + ].join("\n") + }, + { + kind: "message", + role: "assistant", + text: [ + "visible final answer", + "", + "", + "", + "MEMORY.md:10-12|note=[private provenance]", + "", + "", + "019f523a-60d4-70d2-893e-c93ac9f92f43", + "", + "" + ].join("\n") + }, + { + kind: "tool", + tool: "exec", + input_preview: "visible input before\n\nprivate input\n\nvisible input after", + output: "visible output before\n# AGENTS.md instructions\n\nprivate output\n\nvisible output after" + } + ]; + + const sanitized = runSharePageFunction( + html, + [ + "sanitizeTranscriptEntries", + "redactInternalContext", + "containsInternalContext", + "startsInternalContext", + "endsInternalContext", + "silentlyRedactsInternalContext", + "byteLengthInBrowser" + ], + "sanitizeTranscriptEntries(entries)", + { entries } + ); + const visible = JSON.stringify(sanitized); + for (const leaked of [ + "recommended_plugins", + "private plugin inventory", + "AGENTS.md", + "private project rules", + "environment_context", + "/Users/private/workspace", + "oai-mem-citation", + "citation_entries", + "MEMORY.md", + "private provenance", + "rollout_ids", + "019f523a", + "private input", + "private output" + ]) { + assert.doesNotMatch(visible, new RegExp(leaked.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assert.equal(sanitized.length, 2); + assert.equal(sanitized[0].text, "visible final answer"); + assert.match(sanitized[1].input_preview, /visible input before[\s\S]*\[internal context omitted\][\s\S]*visible input after/); + assert.match(sanitized[1].output, /visible output before[\s\S]*\[internal context omitted\][\s\S]*visible output after/); +}); + test("share page renders a complete Chinese shell only when explicitly requested", async () => { const env = fakeEnv(); const upload = await worker.fetch(new Request(BASE_URL + "/v1/shares", { diff --git a/internal/capsule/capsule_test.go b/internal/capsule/capsule_test.go index 829a006..b8e8953 100644 --- a/internal/capsule/capsule_test.go +++ b/internal/capsule/capsule_test.go @@ -646,6 +646,33 @@ func TestPreviewTranscriptRedactsInternalContextAcrossMessageParts(t *testing.T) } } +func TestPreviewTranscriptRedactsMemoryCitationSuffix(t *testing.T) { + manifest := Manifest{ + ThreadID: testThreadID, + ThreadTitle: "Preview demo", + CreatedAt: "2026-07-23T00:00:00Z", + } + session := strings.Join([]string{ + `{"timestamp":"2026-07-23T00:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"visible final answer\n\n\n\nMEMORY.md:10-12|note=[private provenance]\n\n\n019f523a-60d4-70d2-893e-c93ac9f92f43\n\n"}]}}`, + }, "\n") + "\n" + transcript := buildPreviewTranscript(manifest, []byte(session)) + if transcript.MessageCount != 1 { + t.Fatalf("message_count = %d", transcript.MessageCount) + } + if len(transcript.Entries) != 1 || transcript.Entries[0].Text != "visible final answer" { + t.Fatalf("visible answer = %#v", transcript.Entries) + } + preview := previewEntriesText(transcript) + if !strings.Contains(preview, "visible final answer") { + t.Fatalf("preview lost visible answer:\n%s", preview) + } + for _, leaked := range []string{"oai-mem-citation", "citation_entries", "MEMORY.md", "private provenance", "rollout_ids", "019f523a"} { + if strings.Contains(preview, leaked) { + t.Fatalf("memory citation leaked %q into preview:\n%s", leaked, preview) + } + } +} + func TestPreviewTranscriptAttachesSkillMessages(t *testing.T) { manifest := Manifest{ ThreadID: testThreadID, diff --git a/internal/capsule/preview.go b/internal/capsule/preview.go index 5851916..8e36970 100644 --- a/internal/capsule/preview.go +++ b/internal/capsule/preview.go @@ -470,27 +470,31 @@ func previewRedactHiddenContext(text string) string { } var b strings.Builder inHiddenBlock := false + silentHiddenBlock := false redactedPrevious := false for _, part := range strings.SplitAfter(text, "\n") { line := strings.TrimRight(part, "\r\n") trimmed := strings.TrimSpace(line) if inHiddenBlock { - if !redactedPrevious { + if !silentHiddenBlock && !redactedPrevious { b.WriteString("[internal context omitted]\n") redactedPrevious = true } if previewEndsHiddenContext(trimmed) { inHiddenBlock = false + silentHiddenBlock = false } continue } if previewContainsHiddenContext(trimmed) { - if !redactedPrevious { + silent := previewSilentlyRedactsContext(trimmed) + if !silent && !redactedPrevious { b.WriteString("[internal context omitted]\n") redactedPrevious = true } if previewStartsHiddenContext(trimmed) && !previewEndsHiddenContext(trimmed) { inHiddenBlock = true + silentHiddenBlock = silent } continue } @@ -500,6 +504,10 @@ func previewRedactHiddenContext(text string) string { return b.String() } +func previewSilentlyRedactsContext(text string) bool { + return strings.HasPrefix(strings.TrimSpace(text), "") +} + func previewContainsHiddenContext(text string) bool { if previewStartsHiddenContext(text) { return true @@ -511,6 +519,8 @@ func previewContainsHiddenContext(text string) bool { "", "", "", + "", + "", } { if strings.Contains(text, marker) { return true @@ -527,6 +537,8 @@ func previewStartsHiddenContext(text string) bool { "", "", "", + "", + "", } { if strings.HasPrefix(text, prefix) { return true @@ -542,6 +554,8 @@ func previewEndsHiddenContext(text string) bool { "", "", "", + "", + "", } { if strings.HasPrefix(text, prefix) { return true