fix(runner): show redacted shell commands

This commit is contained in:
Dotta committed 2026-08-20 10:48:14 -05:00
1 parent 357caf4e3c
commit f5f5997cb8
5 files changed
+54 -7

No files matched your search

@@ -88,7 +88,7 @@ function ProgressItem({
<div className="pit-activity-detail">
<p>
{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.
</p>
</div>
</details>
@@ -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
@@ -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,
});
}
@@ -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",
@@ -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, unknown>): 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<string, unknown>,
names: readonly string[],
@@ -210,10 +226,17 @@ export function redactCapabilityEvidenceData(
data: Record<string, unknown>,
): Record<string, CapabilityJsonValue> {
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);