import { beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // Mock node:fs so realpathSync (used by canonicalizePath) is controllable for // the symlink-into-protected-directory case; identity by default. const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>((p) => p), ); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, realpathSync, default: { ...actual, realpathSync }, }; }); import { BashProgram } from "#src/access-intent/bash/program"; import type { ShellInvocation } from "#src/access-intent/tool-kind"; import { type GateDescriptor, type GateResult, isGateDescriptor, } from "#src/handlers/gates/descriptor"; import { describeProtectedWriteGate } from "#src/handlers/gates/protected-write"; import type { ProtectedWriteRule } from "#src/hard-guard-loader"; import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { makeTcc } from "#test/helpers/gate-fixtures"; const CWD = "/test/project"; beforeEach(() => { realpathSync.mockReset(); realpathSync.mockImplementation((p: string) => p); }); const PROTECTED_WRITES: ProtectedWriteRule[] = [ { pattern: "/vault/auth.json", label: "Pi 凭据" }, { pattern: "/protected/**", label: "受保护目录" }, ]; function normalizer(): PathNormalizer { return new PathNormalizer(pathFlavorForPlatform(process.platform), CWD); } async function describeBashCall( command: string, rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES, ) { const tcc = makeTcc({ toolName: "bash", input: { command } }); const bashProgram = command ? await BashProgram.parse(command, normalizer()) : null; const shell: ShellInvocation = { command, workdir: undefined }; return describeProtectedWriteGate( tcc, bashProgram, shell, normalizer(), rules, ); } function describeToolCall( toolName: string, input: unknown, rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES, ) { const tcc = makeTcc({ toolName, input }); return describeProtectedWriteGate(tcc, null, null, normalizer(), rules); } function asDescriptor(result: GateResult): GateDescriptor { expect(isGateDescriptor(result)).toBe(true); return result as GateDescriptor; } describe("describeProtectedWriteGate", () => { describe("write/edit tools", () => { it("prompts on a write tool targeting a protected file", () => { const desc = asDescriptor( describeToolCall("write", { path: "/vault/auth.json" }), ); expect(desc.humanOnly).toEqual({ path: "/vault/auth.json", pattern: "/vault/auth.json", label: "Pi 凭据", extraCount: 0, }); expect(desc.preCheck?.state).toBe("ask"); expect(desc.sessionApproval).toBeUndefined(); expect(desc.logContext.protectedWrite).toEqual({ path: "/vault/auth.json", pattern: "/vault/auth.json", label: "Pi 凭据", }); }); it("prompts on an edit tool targeting a protected file", () => { expect( isGateDescriptor( describeToolCall("edit", { path: "/vault/auth.json" }), ), ).toBe(true); }); it("does not gate a read of a protected path", () => { expect( describeToolCall("read", { path: "/vault/auth.json" }), ).toBeNull(); }); it("covers a whole subtree, not just the directory root", () => { expect( isGateDescriptor( describeToolCall("write", { path: "/protected/deep/nested/x.json" }), ), ).toBe(true); }); it("prompts on a symlink whose target is protected", () => { realpathSync.mockImplementation((p: string) => p === "/work/link" ? "/vault/auth.json" : p, ); const desc = asDescriptor( describeToolCall("write", { path: "/work/link" }), ); expect(desc.humanOnly?.pattern).toBe("/vault/auth.json"); }); }); describe("bash writes", () => { it("prompts on a redirect into a protected file", async () => { expect( isGateDescriptor(await describeBashCall("echo x > /vault/auth.json")), ).toBe(true); }); it("prompts on a redirect into a not-yet-existing protected path", async () => { expect( isGateDescriptor( await describeBashCall("echo 1 > /protected/new-file.json"), ), ).toBe(true); }); it("does not prompt on cat of a protected file", async () => { expect(await describeBashCall("cat /vault/auth.json")).toBeNull(); }); it("does not prompt when a protected read feeds an unprotected write", async () => { expect( await describeBashCall("cat /vault/auth.json > /tmp/x"), ).toBeNull(); }); it("does not prompt on a redirect-in read of a protected file", async () => { expect(await describeBashCall("cat < /vault/auth.json")).toBeNull(); }); it("prompts on tee of a protected file", async () => { expect( isGateDescriptor(await describeBashCall("tee /vault/auth.json")), ).toBe(true); }); it("prompts on cp into a protected file", async () => { expect( isGateDescriptor( await describeBashCall("cp /tmp/x /vault/auth.json"), ), ).toBe(true); }); it("prompts on sed -i of a protected file", async () => { expect( isGateDescriptor( await describeBashCall("sed -i s/a/b/ /vault/auth.json"), ), ).toBe(true); }); it("prompts on an opaque bash -c payload writing a protected file", async () => { expect( isGateDescriptor( await describeBashCall('bash -c "echo > /vault/auth.json"'), ), ).toBe(true); }); it("prompts on a relative redirect after cd into a protected directory", async () => { expect( isGateDescriptor( await describeBashCall("cd /protected && echo 1 > rel.json"), ), ).toBe(true); }); it("prompts on an unparseable command containing a protected path", async () => { const tcc = makeTcc({ toolName: "bash", input: { command: "echo broken > /vault/auth.json" }, }); // Parser failure is injected: the gate must scan the text directly. const shell: ShellInvocation = { command: "echo broken > /vault/auth.json", workdir: undefined, }; const result = describeProtectedWriteGate( tcc, null, shell, normalizer(), PROTECTED_WRITES, ); expect(isGateDescriptor(result)).toBe(true); }); it("reports the extra protected targets in the same call", async () => { const desc = asDescriptor( await describeBashCall( "echo x > /vault/auth.json && echo y > /protected/a.json", ), ); expect(desc.humanOnly?.extraCount).toBe(1); }); }); describe("non-local tools and empty rules", () => { it("does not gate an ssh_bash-style aliased shell tool", async () => { const tcc = makeTcc({ toolName: "ssh_bash", input: { command: "echo x > /vault/auth.json" }, }); const bashProgram = await BashProgram.parse( "echo x > /vault/auth.json", normalizer(), ); const shell: ShellInvocation = { command: "echo x > /vault/auth.json", workdir: undefined, }; expect( describeProtectedWriteGate( tcc, bashProgram, shell, normalizer(), PROTECTED_WRITES, ), ).toBeNull(); }); it("returns null when no protectedWrites are configured", async () => { expect( await describeBashCall("echo x > /vault/auth.json", []), ).toBeNull(); expect( describeToolCall("write", { path: "/vault/auth.json" }, []), ).toBeNull(); }); }); describe("bare token existence probe", () => { // `auth.json` is a bare token (no path shape), so it only becomes a // candidate when the resolver's existence probe finds it on disk; the // probe must carry the same role/program metadata as shaped tokens. it("does not prompt on a read of an existing bare-token file in a protected directory", async () => { const dir = mkdtempSync(join(tmpdir(), "pw-probe-")); try { writeFileSync(join(dir, "auth.json"), "x"); const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }]; expect( await describeBashCall(`cd ${dir} && cat auth.json`, rules), ).toBeNull(); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("prompts on rm of an existing bare-token file in a protected directory", async () => { const dir = mkdtempSync(join(tmpdir(), "pw-probe-")); try { writeFileSync(join(dir, "auth.json"), "x"); const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }]; expect( isGateDescriptor( await describeBashCall(`cd ${dir} && rm auth.json`, rules), ), ).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); });