feat(coding-agent): strip trailing | head/tail from single-line bash commands

Drop trailing `| head [args]` / `| tail [args]` pipes that exist purely
to limit output — the harness already truncates bash output and exposes
the full result via the bash-original artifact, so these pipes only
hide content from the agent.

Conservative gates (any failing leaves the command verbatim):

  - single-line only; multi-line scripts may legitimately end pipelines
    with head/tail to bound a generator or loop body
  - whitelisted limit-only args (-nN, -n N, -cN, -N, -q, -v, --lines[=N],
    --bytes[=N], --quiet, --verbose); rejects -f/-F/+N/filenames so
    `tail -f`, `tail -n +2` etc. stay intact
  - regex anchored at end of command; any downstream operator (`&&`,
    `||`, `;`, `&`, `>`, `|`, `` ` ``, `$(…)`, `)`) blocks the match, so
    `just build 2>&1 | tail -3 && just up && …` is untouched
  - refuses to reduce the command to an empty string
  - pipe boundary uses `[ \t]*`, not `\s*`, so a `|` on a continuation
    line cannot be swallowed

Consolidates the existing `timeoutClampNotice` and the new strip notice
into a single `pendingNotices: string[]` array threaded through every
execute branch (async, auto-background, ACP terminal, local exec).

New setting `bash.stripTrailingHeadTail` (default `true`).
This commit is contained in:
can1357
2026-05-15 00:50:00 +02:00
parent ba2928da6b
commit 9bbc7465ba
5 changed files with 269 additions and 10 deletions
@@ -1648,6 +1648,17 @@ export const SETTINGS_SCHEMA = {
},
"bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
"bash.stripTrailingHeadTail": {
type: "boolean",
default: true,
ui: {
tab: "editing",
label: "Strip Trailing head/tail",
description:
"Silently drop trailing `| head`/`| tail` pipes from single-line bash commands. Output is already truncated automatically.",
},
},
// Shell output minimizer
"shellMinimizer.enabled": {
type: "boolean",
@@ -0,0 +1,78 @@
/**
* Conservative transforms applied to a bash command before execution.
*
* Currently strips trailing `| head [args]` / `| tail [args]` pipelines that
* exist purely to limit output length: the harness already truncates bash
* output and exposes the full result via an artifact, so these pipes only
* hide content the agent wanted. We refuse to strip in any case where the
* pipe could carry real semantics (multi-line scripts, follow flags, file
* arguments, downstream commands, redirects, subshells, etc.).
*/
export interface BashFixupResult {
/** Possibly-rewritten command. */
command: string;
/** Original substring that was removed, if any (verbatim, including the leading `|`). */
stripped?: string;
}
/**
* Token shapes for `head`/`tail` that we recognize as pure "limit output" flags.
*
* We deliberately reject `-f`, `-F`, `--follow`, `+N` line offsets, filenames,
* and anything else that could change semantics when removed.
*
* -nN, -n N, -n=N, -cN, -c N, -c=N
* -N (BSD-style `head -5`)
* -q, -v, --quiet, --verbose
* --lines[=N| N], --bytes[=N| N]
* bare integer (the value half of `--lines 5` / `-n 5`)
*/
const SAFE_HEAD_TAIL_ARG = String.raw`(?:-[nc]=?\s*\d+|-\d+|-[qv]|--lines(?:=?\s*\d+)?|--bytes(?:=?\s*\d+)?|--quiet|--verbose|\d+)`;
/**
* Matches a trailing `| head|tail [safe-args]` segment anchored to the end of
* the command. The leading `\s*` is bounded to inline whitespace (no newline)
* so a `|` on its own line never gets swallowed.
*/
const TRAILING_HEAD_TAIL_RE = new RegExp(
String.raw`[ \t]*\|[ \t]*(?:head|tail)(?:[ \t]+${SAFE_HEAD_TAIL_ARG})*[ \t]*$`,
);
/**
* Strip a trailing `| head` / `| tail` from a single-line bash command.
*
* Bail-out conditions (all preserve the original verbatim):
* - command contains any newline (multi-line scripts may legitimately end a
* pipeline with `head`/`tail` to bound a generator);
* - the matched segment is not the entire command (we never reduce a command
* to an empty string);
* - the `head`/`tail` carries any flag we don't recognize (e.g. `-f`, `-F`,
* `+N`, filenames, redirects) — the regex simply won't match.
*/
export function stripTrailingHeadTail(command: string): BashFixupResult {
// Single-line guard. We check the raw string for any newline anywhere, not
// just at the boundary, because shell continuations, heredocs, function
// bodies, and `for ... done | head` blocks all live behind a newline.
if (command.includes("\n")) return { command };
const match = TRAILING_HEAD_TAIL_RE.exec(command);
if (!match || match.index === undefined) return { command };
const remainder = command.slice(0, match.index).replace(/[ \t]+$/, "");
// Never reduce the command to nothing — that would execute as a no-op and
// almost certainly indicates a false-positive match (e.g. the LLM wrote
// `head -5` standalone hoping to read its own stdin).
if (remainder === "") return { command };
return { command: remainder, stripped: match[0].trim() };
}
/**
* Human-readable notice for the stripped segment. Mirrors the shape of
* `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
*/
export function formatHeadTailStripNotice(stripped: string | undefined): string | undefined {
if (!stripped) return undefined;
return `Stripped trailing \`${stripped}\` — bash output is truncated automatically and the full result is available via \`artifact://<id>\`.`;
}
+27 -10
View File
@@ -17,6 +17,7 @@ import { renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import { getSixelLineMask } from "../utils/sixel";
import type { ToolSession } from ".";
import { formatHeadTailStripNotice, stripTrailingHeadTail } from "./bash-command-fixup";
import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
import { checkBashInterception } from "./bash-interceptor";
import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
@@ -352,7 +353,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
timeoutMs: number;
timeoutSec: number;
requestedTimeoutSec?: number;
timeoutClampNotice?: string;
notices?: readonly string[];
resolvedEnv?: Record<string, string>;
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
@@ -392,7 +393,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
});
const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
requestedTimeoutSec: options.requestedTimeoutSec,
notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: options.notices ?? [],
});
const finalText = this.#extractTextResult(finalResult);
latestText = finalText;
@@ -483,6 +484,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
let command = rawCommand;
const env = normalizeBashEnv(rawEnv);
// Drop trailing `| head|tail` pipes that exist purely to limit output —
// the harness already truncates bash output. Single-line only; the helper
// refuses anything that could change semantics.
let headTailStripped: string | undefined;
if (this.session.settings.get("bash.stripTrailingHeadTail")) {
const fixup = stripTrailingHeadTail(command);
if (fixup.stripped) {
command = fixup.command;
headTailStripped = fixup.stripped;
}
}
// Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
// Constrained to a single line so a `&&` that sits on a later line of a multiline
// script can't pull the entire script into the "cwd" capture.
@@ -558,7 +571,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const requestedTimeoutSec = rawTimeout;
const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
const timeoutMs = timeoutSec * 1000;
const pendingNotices: string[] = [];
const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
const headTailStripNotice = formatHeadTailStripNotice(headTailStripped);
if (headTailStripNotice) pendingNotices.push(headTailStripNotice);
if (asyncRequested) {
if (!AsyncJobManager.instance()) {
@@ -570,7 +587,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
timeoutMs,
timeoutSec,
requestedTimeoutSec,
timeoutClampNotice,
notices: pendingNotices,
resolvedEnv,
onUpdate,
@@ -578,7 +595,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
});
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
requestedTimeoutSec,
notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: pendingNotices,
});
}
@@ -592,7 +609,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
timeoutMs,
timeoutSec,
requestedTimeoutSec,
timeoutClampNotice,
notices: pendingNotices,
resolvedEnv,
onUpdate,
@@ -601,7 +618,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
if (startBackgrounded) {
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
requestedTimeoutSec,
notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: pendingNotices,
});
}
const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
@@ -621,7 +638,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
job.setBackgrounded(true);
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
requestedTimeoutSec,
notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: pendingNotices,
});
}
@@ -722,7 +739,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
};
return this.#buildCompletedResult(timedOutResult, timeoutSec, {
requestedTimeoutSec,
notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: pendingNotices,
terminalId: handle.terminalId,
});
}
@@ -778,7 +795,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
const bridgeNotices: string[] = [];
if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
for (const notice of pendingNotices) bridgeNotices.push(notice);
return this.#buildCompletedResult(bridgeResult, timeoutSec, {
requestedTimeoutSec,
@@ -833,7 +850,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
}
return this.#buildCompletedResult(result, timeoutSec, {
requestedTimeoutSec,
notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
notices: pendingNotices,
});
}
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from "bun:test";
import {
type BashFixupResult,
formatHeadTailStripNotice,
stripTrailingHeadTail,
} from "../../src/tools/bash-command-fixup";
function strip(command: string): BashFixupResult {
return stripTrailingHeadTail(command);
}
describe("stripTrailingHeadTail — strips harmless trailing limits", () => {
const cases: Array<[string, string, string]> = [
// [input, expected command, expected stripped suffix]
["ls | head", "ls", "| head"],
["ls | head -5", "ls", "| head -5"],
["ls | head -n 5", "ls", "| head -n 5"],
["ls | head -n5", "ls", "| head -n5"],
["ls | head -n=5", "ls", "| head -n=5"],
["ls | head -c 100", "ls", "| head -c 100"],
["ls | head --lines=20", "ls", "| head --lines=20"],
["ls | head --lines 20", "ls", "| head --lines 20"],
["ls | head --quiet -5", "ls", "| head --quiet -5"],
["ls | tail", "ls", "| tail"],
["ls | tail -5", "ls", "| tail -5"],
["ls | tail -n 5", "ls", "| tail -n 5"],
["ls | tail --bytes=200", "ls", "| tail --bytes=200"],
["ls|head", "ls", "|head"],
["ls | tail -20 ", "ls", "| tail -20"],
// cd/sub-pipeline preserved; only the trailing limit goes
["git log --oneline | head -20", "git log --oneline", "| head -20"],
["echo a | tr a b | head -3", "echo a | tr a b", "| head -3"],
// command with stderr redirect before the limit stays intact
["cargo build 2>&1 | head -50", "cargo build 2>&1", "| head -50"],
];
for (const [input, expectedCommand, expectedStripped] of cases) {
it(`strips: ${input}`, () => {
const out = strip(input);
expect(out.command).toBe(expectedCommand);
expect(out.stripped).toBe(expectedStripped);
});
}
});
describe("stripTrailingHeadTail — preserves semantics-bearing pipelines", () => {
const untouched: string[] = [
// follow-mode and file readers
"tail -f /var/log/system.log",
"tail -F file.log",
"ls | tail -f -",
// non-trailing head/tail
"ls | head -5 | sort",
"cat file | head -5 | wc -l",
// +N offset (skip-first semantics, not a limit)
"cat file | tail -n +2",
"cat file | tail +5",
// downstream commands / operators
"ls | head -5 && echo done",
"ls | head -5 || echo failed",
"ls | head -5 ; echo done",
"ls | head -5 &",
// redirects on head's output
"ls | head -5 > /tmp/out.txt",
"ls | head -5 2>/dev/null",
// inside a string / subshell — anchored end is `"` or `)`
'echo "ls | head -5"',
"echo $(ls | head -5)",
// no `|` at all
"head -5 file.txt",
"head /etc/hosts",
// would reduce to empty
"| head -5",
"head -5",
// multiline scripts: head bounds a loop body, must stay
"for f in *.txt; do\n echo $f\ndone | head -5",
"cat <<EOF | head -5\ncontent\nEOF",
"ls\nls | head -5",
];
for (const input of untouched) {
it(`leaves alone: ${JSON.stringify(input)}`, () => {
const out = strip(input);
expect(out.command).toBe(input);
expect(out.stripped).toBeUndefined();
});
}
});
describe("formatHeadTailStripNotice", () => {
it("returns undefined when nothing was stripped", () => {
expect(formatHeadTailStripNotice(undefined)).toBeUndefined();
});
it("embeds the stripped segment in the notice", () => {
const notice = formatHeadTailStripNotice("| head -5");
expect(notice).toContain("| head -5");
expect(notice).toContain("artifact://");
});
});
@@ -56,3 +56,56 @@ describe("BashTool interception", () => {
).rejects.toThrow("Use read instead");
});
});
describe("BashTool head/tail stripping", () => {
function createBashToolWithStrip(stripEnabled: boolean): BashTool {
const session = {
cwd: process.cwd(),
settings: {
get(key: string) {
if (key === "bashInterceptor.enabled") return false;
if (key === "async.enabled") return false;
if (key === "bash.autoBackground.enabled") return false;
if (key === "bash.autoBackground.thresholdMs") return 60_000;
if (key === "bash.stripTrailingHeadTail") return stripEnabled;
return undefined;
},
getBashInterceptorRules() {
return [];
},
},
} as unknown as ToolSession;
return new BashTool(session);
}
it("executes the stripped command and surfaces a notice", async () => {
const tool = createBashToolWithStrip(true);
// `seq 1 100 | head -3` would emit "1\n2\n3"; stripped, it emits 1..100.
// We assert on the tail of the output rather than head, so a successful
// strip is observable: line "100" only appears when head is gone.
const result = await tool.execute(
"tool-call",
{ command: "seq 1 100 | head -3" },
undefined,
undefined,
{ toolNames: ["bash"] } as AgentToolContext,
);
const text = result.content.find(b => b.type === "text")?.text ?? "";
expect(text).toContain("100");
expect(text).toContain("Stripped trailing `| head -3`");
});
it("does not strip when the setting is disabled", async () => {
const tool = createBashToolWithStrip(false);
const result = await tool.execute(
"tool-call",
{ command: "seq 1 100 | head -3" },
undefined,
undefined,
{ toolNames: ["bash"] } as AgentToolContext,
);
const text = result.content.find(b => b.type === "text")?.text ?? "";
expect(text).toContain("1\n2\n3");
expect(text).not.toContain("100");
});
});