Files
oh-my-pi/packages/coding-agent/test/tools/bash-interceptor.test.ts
T
can1357 5ff277349c refactor(coding-agent): consolidated tool surface onto xd:// devices and hub
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt.
- Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`.
- Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present.
- Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type.
- Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`.
- Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins.
- Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
2026-07-15 15:16:29 +02:00

155 lines
5.8 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
import {
type BashInterceptorRule,
DEFAULT_BASH_INTERCEPTOR_RULES,
} from "@oh-my-pi/pi-coding-agent/config/settings-schema";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool, type BashToolInput } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { checkBashInterception } from "@oh-my-pi/pi-coding-agent/tools/bash-interceptor";
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("default echo/printf redirect rule", () => {
const tools = ["write"];
it("blocks unquoted redirects to files", () => {
expect(checkBashInterception("echo hi > out.txt", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
expect(checkBashInterception("echo hi >> out.txt", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
expect(checkBashInterception('printf "%s" foo > /tmp/x', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
});
it("blocks clobber and variable-target redirects", () => {
expect(checkBashInterception("echo hi >| out.txt", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
expect(checkBashInterception("echo hi > $OUT", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
});
it("does not block /dev device sink redirects", () => {
expect(checkBashInterception("echo result > /dev/null", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception("echo done > /dev/null 2>&1", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(
false,
);
expect(checkBashInterception('echo "" > /dev/tty', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception("echo x > /dev/stdout", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception('echo "marker" > /dev/stderr', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(
false,
);
expect(checkBashInterception('echo x > "/dev/null"', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
});
it("still blocks real paths that resemble /dev sinks", () => {
expect(checkBashInterception("echo data > ./dev/null", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
expect(checkBashInterception("echo data > /devices/x", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(true);
});
it("keeps scanning after allowed /dev sink redirects", () => {
expect(
checkBashInterception("echo data > /dev/null > out.txt", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block,
).toBe(true);
expect(
checkBashInterception("printf x > /dev/stdout >> real.txt", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block,
).toBe(true);
});
it("does not block `>` inside quoted text or fd duplication", () => {
expect(checkBashInterception('echo "a -> b"', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception('echo "<p>hi</p>"', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception("printf 'use 2>&1'", tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
expect(checkBashInterception('echo "err" >&2', tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
});
});
describe("default hub start rules", () => {
const tools = ["hub"];
it.each([
"bun run dev",
"vite --host 0.0.0.0",
"lldb ./app",
"bun test --watch",
"nohup server",
"server &",
])("routes %s to hub start", command => {
const result = checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES);
expect(result.block).toBe(true);
expect(result.suggestedTool).toBe("hub");
});
it.each([
"git diff -w",
"docker compose up -d",
"bun test",
"printf 'server &'",
])("does not misclassify finite command %s", command => {
expect(checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
});
});
describe("BashTool argument validation", () => {
it("preserves async requests so disabled async mode returns the explicit error", async () => {
const tool = createBashTool([]);
const args = validateToolArguments(tool, {
type: "toolCall",
id: "tool-call",
name: tool.name,
arguments: { command: "echo should-not-run", async: true },
});
await expect(tool.execute("tool-call", args as unknown as BashToolInput)).rejects.toThrow(
"Async bash execution is disabled",
);
});
});