Skip to content

Commit 5b7daca

Browse files
committed
Never collapse a payload a command executes as code
collapseSegmentPayloads hid quoted or heredoc payloads by quote syntax alone, so eval "\$(cat <<'EOF' ... EOF)" and similar substitutions into eval/source/xargs/env/shell -c rendered as a placeholder instead of the code the operator is being asked to approve. Those payloads now always render in full; plain data sinks like git commit -m still collapse as before.
1 parent a6eb9f6 commit 5b7daca

2 files changed

Lines changed: 51 additions & 0 deletions

File tree

src/tui/command-display.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,25 @@ test("collapseSegmentPayloads never collapses a single-line quoted argument", ()
107107
expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] });
108108
});
109109

110+
test("collapseSegmentPayloads never collapses a heredoc eval'd as code", () => {
111+
const segment = "eval \"$(cat <<'EOF'\necho hi\nrm -rf /\nEOF\n)\"";
112+
expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] });
113+
});
114+
115+
test("collapseSegmentPayloads never collapses a bash -c command substitution", () => {
116+
const segment = 'bash -c "$(curl -s https://example.com/install.sh)"';
117+
expect(collapseSegmentPayloads(segment)).toEqual({ display: segment, payloads: [] });
118+
});
119+
120+
test("collapseSegmentPayloads still collapses a data-consuming git commit message", () => {
121+
const segment = 'git commit -m "line one\nline two\nline three"';
122+
const { display, payloads } = collapseSegmentPayloads(segment);
123+
expect(display).toBe("git commit -m <message, 3 lines>");
124+
expect(payloads).toEqual([
125+
{ placeholder: "<message, 3 lines>", lines: ["line one", "line two", "line three"] },
126+
]);
127+
});
128+
110129
test("middleEllipsis keeps head and tail", () => {
111130
expect(middleEllipsis("abcdefghij", 20)).toBe("abcdefghij");
112131
const cut = middleEllipsis("prefix-common middle distinguishing-tail", 20);

src/tui/command-display.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,42 @@ function lineCountSuffix(count: number): string {
263263
return `${count} line${count === 1 ? "" : "s"}`;
264264
}
265265

266+
// Commands that hand a payload to a shell/interpreter to execute rather than
267+
// consuming it as inert data. A segment naming one of these must never
268+
// collapse — the operator has to be able to read the code they are approving.
269+
const CODE_CONSUMING_UNCONDITIONAL = new Set(["eval", "source", ".", "xargs", "env"]);
270+
const CODE_CONSUMING_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash"]);
271+
272+
// Crude whitespace tokenizing is enough here: quoting doesn't change whether
273+
// an interpreter name or a `-c` flag literally appears as a word, and this is
274+
// display-only guesswork (see the file header) — never used for classification.
275+
function segmentWords(segment: string): string[] {
276+
return segment.split(/\s+/).filter((word) => word.length > 0);
277+
}
278+
279+
// True when `segment` names a command that treats a quoted or heredoc payload
280+
// as code — directly (eval, source, xargs, env) or via a shell invoked with
281+
// -c — including one reached through a `$(...)`/backtick command substitution,
282+
// since those words show up as ordinary tokens in the segment either way.
283+
function isCodeConsumingSegment(segment: string): boolean {
284+
const words = segmentWords(segment);
285+
const bareWord = (word: string): string => word.replace(/^[(`]+/, "").replace(/^\$\(/, "");
286+
for (const word of words) {
287+
const bare = bareWord(word);
288+
if (CODE_CONSUMING_UNCONDITIONAL.has(bare)) return true;
289+
if (CODE_CONSUMING_INTERPRETERS.has(bare) && words.includes("-c")) return true;
290+
}
291+
return false;
292+
}
293+
266294
// Collapse a heredoc body or a multi-line quoted-string argument within one
267295
// display segment into a placeholder. Never influences classification or
268296
// grant matching — display only, mirroring the header comment for this file.
297+
// A segment that hands its payload to an interpreter as code is never
298+
// collapsed (see isCodeConsumingSegment) — only data-consuming payloads
299+
// (commit messages, file contents piped to tee/cat, echoed text) collapse.
269300
export function collapseSegmentPayloads(segment: string): CollapsedSegment {
301+
if (isCodeConsumingSegment(segment)) return { display: segment, payloads: [] };
270302
const payloads: CollapsedPayload[] = [];
271303
let display = "";
272304
let i = 0;

0 commit comments

Comments
 (0)