Files
oh-my-pi/packages/coding-agent/test/tools/bash-interceptor.test.ts
T
can1357 9bbc7465ba 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`).
2026-05-15 00:50:00 +02:00

112 lines
3.5 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import type { BashInterceptorRule } from "../../src/config/settings-schema";
import type { ToolSession } from "../../src/tools";
import { BashTool } from "../../src/tools/bash";
function createBashTool(rules: BashInterceptorRule[]): BashTool {
const session = {
settings: {
get(key: string) {
if (key === "bashInterceptor.enabled") return true;
if (key === "async.enabled") return false;
if (key === "bash.autoBackground.enabled") return false;
if (key === "bash.autoBackground.thresholdMs") return 60_000;
return undefined;
},
getBashInterceptorRules() {
return rules;
},
},
} as unknown as ToolSession;
return new BashTool(session);
}
describe("BashTool interception", () => {
it("checks the original command before leading cd normalization", async () => {
const tool = createBashTool([
{
pattern: "^\\s*cd\\s+",
tool: "bash",
message: "Do not hide directory changes in the command string.",
},
]);
await expect(
tool.execute("tool-call", { command: "cd packages/coding-agent && echo ok" }, undefined, undefined, {
toolNames: ["bash"],
} as AgentToolContext),
).rejects.toThrow("Do not hide directory changes");
});
it("checks the cwd-normalized command after leading cd normalization", async () => {
const tool = createBashTool([
{
pattern: "^\\s*cat\\s+",
tool: "read",
message: "Use read instead.",
},
]);
await expect(
tool.execute("tool-call", { command: "cd packages/coding-agent && cat package.json" }, undefined, undefined, {
toolNames: ["read"],
} as AgentToolContext),
).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");
});
});