diff --git a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx index 2dafedf6b7e..9f6b1059837 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx @@ -88,7 +88,7 @@ function ProgressItem({

{item.eventCount} sanitized progress update{item.eventCount === 1 ? "" : "s"} observed. - Raw chain-of-thought and provider text are not exposed. + Raw output, chain-of-thought, and credential values are not exposed.

diff --git a/packages/paperclip-runner/docs/capability-clean-room-chat.md b/packages/paperclip-runner/docs/capability-clean-room-chat.md index 981cdfd49ce..f986acf9e3e 100644 --- a/packages/paperclip-runner/docs/capability-clean-room-chat.md +++ b/packages/paperclip-runner/docs/capability-clean-room-chat.md @@ -130,8 +130,9 @@ While a turn runs, a live status rail appears immediately after send and tracks the newest safe Codex activity. Reasoning, planning, shell-command, file-change, MCP/dynamic-tool, assistant-stream, and Paperclip semantic-tool lifecycle events each trigger an interim frame. Discrete tools remain separate rows; only noisy -text deltas are grouped. Raw provider text, command output, and chain of thought -stay withheld. +text deltas are grouped. Structured shell items show a bounded, +credential-redacted command preview; raw command output, other provider +payloads, and chain of thought stay withheld. ## Remote preview gateway diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index 75320734769..497068b5a06 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -772,6 +772,7 @@ export function projectCapabilityIssueThread( event: keyof typeof PROGRESS_EVENTS; at: string; count: number; + command: string; } >(); for (const entry of snapshot.evidence) { @@ -784,7 +785,7 @@ export function projectCapabilityIssueThread( const key = event.endsWith("_delta") ? `${entry.turnId}:${event}` : entry.id; const group = progressGroups.get(key); if (group === undefined) { - progressGroups.set(key, { id: entry.id, turnId: entry.turnId, event, at: entry.at, count: 1 }); + progressGroups.set(key, { id: entry.id, turnId: entry.turnId, event, at: entry.at, count: 1, command: readString(entry.data.command) }); } else { group.count += 1; } @@ -799,7 +800,7 @@ export function projectCapabilityIssueThread( activity: copy.activity, status: running ? "running" : "complete", label: copy.label, - summary: running ? copy.running : copy.complete, + summary: group.command || (running ? copy.running : copy.complete), eventCount: group.count, }); } diff --git a/packages/paperclip-runner/src/issue-thread/public-view.test.ts b/packages/paperclip-runner/src/issue-thread/public-view.test.ts index 3683fc7a095..f5488eb8e73 100644 --- a/packages/paperclip-runner/src/issue-thread/public-view.test.ts +++ b/packages/paperclip-runner/src/issue-thread/public-view.test.ts @@ -219,6 +219,27 @@ describe("Capability evidence redaction", () => { } }); + it("shows bounded structured shell commands while redacting credential values", () => { + const redacted = redactCapabilityEvidenceData("provider_event", { + method: "item/started", + params: { + item: { + type: "commandExecution", + source: "unifiedExecStartup", + command: "/bin/bash -lc 'curl -H \"Authorization: Bearer hidden-token\" --token pcp_1234567890 https://example.test && printf ok'", + aggregatedOutput: CANARY, + }, + }, + }); + + expect(redacted.event).toBe("command_started"); + expect(redacted.command).toContain("/bin/bash -lc"); + expect(redacted.command).toContain("printf ok"); + expect(redacted.command).not.toContain("hidden-token"); + expect(redacted.command).not.toContain("pcp_1234567890"); + expect(JSON.stringify(redacted)).not.toContain(CANARY); + }); + it("collapses an unknown provider method rather than echoing it", () => { expect(redactCapabilityEvidenceData("provider_event", { method: `x/${CANARY}` })).toEqual({ event: "other", diff --git a/packages/paperclip-runner/src/live/evidence-redaction.ts b/packages/paperclip-runner/src/live/evidence-redaction.ts index 733d9aadd5c..315574bebe1 100644 --- a/packages/paperclip-runner/src/live/evidence-redaction.ts +++ b/packages/paperclip-runner/src/live/evidence-redaction.ts @@ -41,6 +41,7 @@ export type CapabilityEvidenceKind = /** Disposition copy is rendered, so it is bounded rather than unbounded. */ const MAX_DISPOSITION_CHARS = 500; +const MAX_COMMAND_CHARS = 2_000; const MAX_FIELD_NAMES = 32; const MAX_ENTITY_REFS = 64; @@ -130,6 +131,21 @@ function clamp(value: string, limit: number): string { return value.length <= limit ? value : `${value.slice(0, limit)}…`; } +function commandPreview(item: Record): string | null { + // `unifiedExecStartup` is the Codex app-server's structured shell item. Do + // not start echoing arbitrary future provider item shapes just because they + // happen to grow a field named `command`. + if (asString(item.source) !== "unifiedExecStartup") return null; + let command = clamp(asString(item.command), MAX_COMMAND_CHARS); + if (command.length === 0) return null; + command = command + .replace(/bearer\s+[a-z0-9._-]+/gi, "Bearer [redacted]") + .replace(/\b(?:sk|pcp)_[a-z0-9._-]{8,}\b/gi, "[redacted]") + .replace(/((?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret)\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1[redacted]") + .replace(/(--(?:api-key|token|password|secret)\s+)(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1[redacted]"); + return command; +} + function copyFields( data: Record, names: readonly string[], @@ -210,10 +226,17 @@ export function redactCapabilityEvidenceData( data: Record, ): Record { switch (kind) { - case "provider_event": + case "provider_event": { + const event = capabilityProviderEventCategory(asString(data.method), asRecord(data.params)); + const item = asRecord(asRecord(data.params).item); + const command = event === "command_started" || event === "command_completed" + ? commandPreview(item) + : null; return { - event: capabilityProviderEventCategory(asString(data.method), asRecord(data.params)), + event, + ...(command === null ? {} : { command }), }; + } case "diagnostic": // A provider diagnostic is operator surface, not board surface. Only the // fact that one was recorded survives. @@ -343,6 +366,7 @@ export function capabilityEvidenceDetails( switch (kind) { case "provider_event": add("Event", data.event); + add("Command", data.command); break; case "diagnostic": add("Visibility", data.diagnostic);