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:
@@ -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>\`.`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user