Skip to content

Commit 37f3ed5

Browse files
committed
fix: reject truncated exec approval commands
1 parent 1675b2f commit 37f3ed5

3 files changed

Lines changed: 67 additions & 7 deletions

File tree

src/gateway/server-methods/exec-approval.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolveCommandAnalysisSummaryForDisplay } from "../../infra/command-ana
33
import {
44
resolveExecApprovalCommandDisplay,
55
sanitizeExecApprovalDisplayText,
6+
sanitizeExecApprovalDisplayTextWithStatus,
67
sanitizeExecApprovalWarningText,
78
} from "../../infra/exec-approval-command-display.js";
89
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
@@ -248,14 +249,28 @@ export function createExecApprovalHandlers(
248249
config: runtimeConfig,
249250
agentId: effectiveAgentId,
250251
});
252+
const sanitizedCommandDisplay =
253+
sanitizeExecApprovalDisplayTextWithStatus(effectiveCommandText);
254+
if (sanitizedCommandDisplay.truncated || sanitizedCommandDisplay.oversized) {
255+
respond(
256+
false,
257+
undefined,
258+
errorShape(ErrorCodes.INVALID_REQUEST, "command exceeds exec approval display limit", {
259+
details: {
260+
reason: "EXEC_APPROVAL_COMMAND_DISPLAY_LIMIT",
261+
},
262+
}),
263+
);
264+
return;
265+
}
266+
const sanitizedCommandText = sanitizedCommandDisplay.text;
251267
const commandAnalysis = resolveCommandAnalysisSummaryForDisplay({
252268
host,
253269
commandText: effectiveCommandText,
254270
commandArgv: effectiveCommandArgv,
255271
cwd: effectiveCwd,
256272
sanitizeText: sanitizeExecApprovalWarningText,
257273
});
258-
const sanitizedCommandText = sanitizeExecApprovalDisplayText(effectiveCommandText);
259274
const commandSpans =
260275
commandHighlighting && sanitizedCommandText === effectiveCommandText
261276
? normalizeCommandSpans(p.commandSpans, sanitizedCommandText.length)

src/gateway/server-methods/server-methods.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,31 @@ describe("exec approval handlers", () => {
10051005
expectRecordFields(mockCallArg(respond, 0, 2), { message: "command is required" });
10061006
});
10071007

1008+
it("rejects approval requests when the command display would be truncated", async () => {
1009+
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
1010+
await requestExecApproval({
1011+
handlers,
1012+
respond,
1013+
context,
1014+
params: {
1015+
command: `printf visible # ${"A".repeat(18 * 1024)}\nprintf hidden`,
1016+
host: "gateway",
1017+
nodeId: undefined,
1018+
systemRunPlan: undefined,
1019+
},
1020+
});
1021+
1022+
expect(mockCallArg(respond)).toBe(false);
1023+
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
1024+
expectRecordFields(mockCallArg(respond, 0, 2), {
1025+
message: "command exceeds exec approval display limit",
1026+
});
1027+
expectRecordFields((mockCallArg(respond, 0, 2) as { details?: unknown }).details, {
1028+
reason: "EXEC_APPROVAL_COMMAND_DISPLAY_LIMIT",
1029+
});
1030+
expect(broadcasts).toEqual([]);
1031+
});
1032+
10081033
it("returns pending approval details for exec.approval.get", async () => {
10091034
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
10101035

src/infra/exec-approval-command-display.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,21 @@ function escapeInvisibles(text: string, options?: { preserveLineBreaks?: boolean
3838
);
3939
}
4040

41-
function truncateForDisplay(text: string): string {
41+
export type SanitizedExecApprovalDisplayText = {
42+
text: string;
43+
truncated: boolean;
44+
oversized: boolean;
45+
};
46+
47+
function truncateForDisplay(text: string): SanitizedExecApprovalDisplayText {
4248
if (text.length <= EXEC_APPROVAL_MAX_OUTPUT) {
43-
return text;
49+
return { text, truncated: false, oversized: false };
4450
}
45-
return text.slice(0, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER;
51+
return {
52+
text: text.slice(0, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER,
53+
truncated: true,
54+
oversized: false,
55+
};
4656
}
4757

4858
// Build a boolean bitmap of positions in `text` that ANY redaction pattern would match.
@@ -92,11 +102,15 @@ function buildStrippedView(original: string): { stripped: string; strippedToOrig
92102
function sanitizeExecApprovalDisplayTextInternal(
93103
commandText: string,
94104
options?: { preserveLineBreaks?: boolean; oversizedMarker?: string },
95-
): string {
105+
): SanitizedExecApprovalDisplayText {
96106
if (commandText.length > EXEC_APPROVAL_MAX_INPUT) {
97107
// Refuse to display inputs above the hard cap; anything larger must be approved through
98108
// another channel. Running redaction on a multi-megabyte payload would be a DoS vector.
99-
return options?.oversizedMarker ?? EXEC_APPROVAL_OVERSIZED_MARKER;
109+
return {
110+
text: options?.oversizedMarker ?? EXEC_APPROVAL_OVERSIZED_MARKER,
111+
truncated: false,
112+
oversized: true,
113+
};
100114
}
101115
const rawRedacted = redactSensitiveText(commandText, { mode: "tools" });
102116
const { stripped, strippedToOrig } = buildStrippedView(commandText);
@@ -167,14 +181,20 @@ function sanitizeExecApprovalDisplayTextInternal(
167181
}
168182

169183
export function sanitizeExecApprovalDisplayText(commandText: string): string {
184+
return sanitizeExecApprovalDisplayTextInternal(commandText).text;
185+
}
186+
187+
export function sanitizeExecApprovalDisplayTextWithStatus(
188+
commandText: string,
189+
): SanitizedExecApprovalDisplayText {
170190
return sanitizeExecApprovalDisplayTextInternal(commandText);
171191
}
172192

173193
export function sanitizeExecApprovalWarningText(warningText: string): string {
174194
return sanitizeExecApprovalDisplayTextInternal(normalizeDisplayLineBreaks(warningText), {
175195
preserveLineBreaks: true,
176196
oversizedMarker: EXEC_APPROVAL_WARNING_OVERSIZED_MARKER,
177-
});
197+
}).text;
178198
}
179199

180200
function normalizePreview(commandText: string, commandPreview?: string | null): string | null {

0 commit comments

Comments
 (0)