Files
oh-my-pi/packages/coding-agent/test/tools/ssh-url-approval.test.ts
T
Tommaso Fontana f9ece90853 fix(omp): harden ssh:// URL handler per PR review
- buildSshTarget rejects destinations beginning with "-" (SSH argument-injection / local RCE guard)
- gate ssh:// read/search/write at the exec approval tier; substring scan covers search's pre-expansion delimited paths and write's hashline-wrapped paths
- validate the entire materialized buffer as UTF-8 instead of only the first 8 KiB prefix
- write peels read selectors (raw/conflicts) so it targets the same file read does, and rejects line-range/malformed selectors instead of silently stripping them
- write to a uniquely named remote temp; document symlink-replacement on write as a v1 limit
2026-06-26 20:41:35 +02:00

77 lines
3.3 KiB
TypeScript

import { beforeAll, describe, expect, it } from "bun:test";
import * as os from "node:os";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
// Minimal ToolSession stub (block-images.test.ts shape). Approval functions are
// pure over their args, and the write-execute selector reject throws before any
// session/SSH access, so no real cwd/fs is needed.
function createTestToolSession(cwd: string): ToolSession {
return {
cwd,
hasUI: false,
enableLsp: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
};
}
function callApproval(tool: { approval?: unknown }, args: unknown): string {
const approval = tool.approval;
if (typeof approval !== "function") throw new Error("expected a dynamic approval function");
return approval(args) as string;
}
describe("ssh:// tools require exec-tier approval", () => {
beforeAll(async () => {
await Settings.init({ inMemory: true });
});
it("read: ssh:// targets are exec, local paths stay read", () => {
const tool = new ReadTool(createTestToolSession(os.tmpdir()));
expect(callApproval(tool, { path: "ssh://icaro/etc/hostname" })).toBe("exec");
expect(callApproval(tool, { path: "/etc/hostname" })).toBe("read");
expect(callApproval(tool, { path: "local://notes" })).toBe("read");
expect(callApproval(tool, {})).toBe("read");
});
it("search: an ssh:// entry flattened into a delimited path still trips exec", () => {
const tool = new SearchTool(createTestToolSession(os.tmpdir()));
// The delimited string is one entry at approval time (expansion happens
// later), so an anchored check would miss it — the substring scan must not.
expect(callApproval(tool, { paths: "src,ssh://icaro/etc/hosts" })).toBe("exec");
expect(callApproval(tool, { paths: ["src", "ssh://icaro/etc/hosts"] })).toBe("exec");
expect(callApproval(tool, { paths: ["src", "lib"] })).toBe("read");
expect(callApproval(tool, { paths: "src" })).toBe("read");
expect(callApproval(tool, {})).toBe("read");
});
it("write: ssh:// is exec even when wrapped in a hashline header", () => {
const tool = new WriteTool(createTestToolSession(os.tmpdir()));
expect(callApproval(tool, { path: "ssh://icaro/tmp/x" })).toBe("exec");
// A pasted `[path#TAG]` wrapper must not let an ssh write dodge the exec tier.
expect(callApproval(tool, { path: "[ssh://icaro/tmp/x#ABCD]" })).toBe("exec");
expect(callApproval(tool, { path: "/tmp/local-file.txt" })).toBe("write");
});
});
describe("write rejects ssh:// line-range/malformed selectors before connecting", () => {
beforeAll(async () => {
await Settings.init({ inMemory: true });
});
it("execute throws on a line-range or malformed selector without any SSH op", async () => {
const tool = new WriteTool(createTestToolSession(os.tmpdir()));
await expect(tool.execute("w-range", { path: "ssh://icaro/tmp/f:1-20", content: "x" })).rejects.toThrow(
/whole file/,
);
await expect(tool.execute("w-malformed", { path: "ssh://icaro/tmp/f:-10", content: "x" })).rejects.toThrow(
/whole file/,
);
});
});