|
| 1 | +import * as fs from "fs/promises" |
| 2 | +import type { ClineSayTool } from "@roo-code/types" |
| 3 | +import { checkAutoApproval } from "../index" |
| 4 | +import { isFileMatchedByPatterns } from "../filePatterns" |
| 5 | +import { baseState, type State } from "./fixtures" |
| 6 | + |
| 7 | +vi.mock("fs/promises", async (importOriginal) => { |
| 8 | + const actual = await importOriginal<typeof import("fs/promises")>() |
| 9 | + return { ...actual, realpath: vi.fn() } |
| 10 | +}) |
| 11 | + |
| 12 | +const CWD = "/aliases/project" |
| 13 | +const CANONICAL_CWD = "/storage/project" |
| 14 | + |
| 15 | +function matches(filePath: string, patterns: string[]) { |
| 16 | + return isFileMatchedByPatterns({ filePath, cwd: CWD, canonicalCwd: CANONICAL_CWD, patterns, isWindows: false }) |
| 17 | +} |
| 18 | + |
| 19 | +function approval(tool: ClineSayTool, state: Partial<State>, cwd: string | undefined = CWD, isProtected = false) { |
| 20 | + return checkAutoApproval({ |
| 21 | + state: { ...baseState, ...state }, |
| 22 | + cwd, |
| 23 | + ask: "tool", |
| 24 | + text: JSON.stringify(tool), |
| 25 | + isProtected, |
| 26 | + }) |
| 27 | +} |
| 28 | + |
| 29 | +beforeEach(() => { |
| 30 | + vi.mocked(fs.realpath).mockReset().mockResolvedValue(CANONICAL_CWD) |
| 31 | +}) |
| 32 | + |
| 33 | +describe("read matcher workspace-root equivalence", () => { |
| 34 | + it.each([CWD, CANONICAL_CWD])("honors %s exclusions and later re-inclusion in one ordered matcher", (root) => { |
| 35 | + const candidate = `${CANONICAL_CWD}/docs/private.md` |
| 36 | + const exclusion = `!${root}/docs/private.md` |
| 37 | + |
| 38 | + expect(matches(candidate, ["docs/**", exclusion])).toBe(false) |
| 39 | + expect(matches(candidate, [exclusion, "docs/**"])).toBe(true) |
| 40 | + expect(matches(candidate, ["docs/**", exclusion, `${CWD}/docs/private.md`])).toBe(true) |
| 41 | + }) |
| 42 | + |
| 43 | + it("resolves escaping patterns against the lexical parent, not the canonical parent", () => { |
| 44 | + expect(matches("/aliases/shared/file.md", ["../shared/file.md"])).toBe(true) |
| 45 | + expect(matches("/storage/shared/file.md", ["../shared/file.md"])).toBe(false) |
| 46 | + expect(matches("/storage/shared/file.md", ["/storage/shared/file.md"])).toBe(true) |
| 47 | + expect(matches("/aliases/shared/file.md", ["/storage/shared/file.md"])).toBe(false) |
| 48 | + expect(matches(`${CANONICAL_CWD}/docs/file.md`, ["../project/docs/file.md"])).toBe(true) |
| 49 | + }) |
| 50 | + |
| 51 | + it("does not translate similarly prefixed siblings or permit relative rules to cover other roots", () => { |
| 52 | + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) |
| 53 | + expect(matches(`${CWD}-other/docs/file.md`, ["docs/**"])).toBe(false) |
| 54 | + expect(matches(`${CANONICAL_CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) |
| 55 | + expect(matches(`${CANONICAL_CWD}/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(false) |
| 56 | + expect(matches(`${CWD}-other/docs/file.md`, [`${CWD}-other/docs/**`])).toBe(true) |
| 57 | + }) |
| 58 | +}) |
| 59 | + |
| 60 | +describe("read-only root equivalence approval", () => { |
| 61 | + it("keeps read and write allowlist exclusions independent while writes still imply reads", async () => { |
| 62 | + const tool: ClineSayTool = { tool: "readFile", path: `${CANONICAL_CWD}/docs/private.md` } |
| 63 | + const excludedRead = ["docs/**", `!${CWD}/docs/private.md`] |
| 64 | + const excludedWrite = ["docs/**", `!${CANONICAL_CWD}/docs/private.md`] |
| 65 | + |
| 66 | + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: excludedWrite })).toEqual({ |
| 67 | + decision: "ask", |
| 68 | + }) |
| 69 | + expect(await approval(tool, { allowedReadFiles: excludedRead, allowedWriteFiles: ["docs/**"] })).toEqual({ |
| 70 | + decision: "approve", |
| 71 | + }) |
| 72 | + expect(await approval(tool, { allowedReadFiles: ["docs/**"], allowedWriteFiles: excludedWrite })).toEqual({ |
| 73 | + decision: "approve", |
| 74 | + }) |
| 75 | + }) |
| 76 | + |
| 77 | + it("does not extend write matching or protected-file permission when roots are equivalent", async () => { |
| 78 | + const canonicalTool: ClineSayTool = { tool: "newFileCreated", path: `${CANONICAL_CWD}/AGENTS.md` } |
| 79 | + expect(await approval(canonicalTool, { allowedReadFiles: ["*.md"], allowedWriteFiles: ["*.md"] })).toEqual({ |
| 80 | + decision: "ask", |
| 81 | + }) |
| 82 | + expect(await approval(canonicalTool, { allowedWriteFiles: [`${CANONICAL_CWD}/AGENTS.md`] }, CWD, true)).toEqual( |
| 83 | + { decision: "ask" }, |
| 84 | + ) |
| 85 | + expect( |
| 86 | + await approval({ tool: "newFileCreated", path: "AGENTS.md" }, { allowedWriteFiles: ["*.md"] }, CWD, true), |
| 87 | + ).toEqual({ decision: "ask" }) |
| 88 | + expect( |
| 89 | + await approval( |
| 90 | + { tool: "listFilesRecursive", path: `${CANONICAL_CWD}/docs` }, |
| 91 | + { allowedReadFiles: ["docs/**"] }, |
| 92 | + ), |
| 93 | + ).toEqual({ decision: "ask" }) |
| 94 | + expect(fs.realpath).not.toHaveBeenCalled() |
| 95 | + }) |
| 96 | + |
| 97 | + it("requires every named batch target to match and refuses unnamed targets", async () => { |
| 98 | + const state = { allowedReadFiles: ["docs/**", `!${CWD}/docs/private.md`] } |
| 99 | + const listed = { path: `${CANONICAL_CWD}/docs/notes.md`, lineSnippet: "", key: "notes" } |
| 100 | + const excluded = { path: `${CANONICAL_CWD}/docs/private.md`, lineSnippet: "", key: "private" } |
| 101 | + const outside = { path: "/elsewhere/docs/notes.md", lineSnippet: "", key: "outside" } |
| 102 | + |
| 103 | + expect( |
| 104 | + await approval( |
| 105 | + { |
| 106 | + tool: "readFile", |
| 107 | + batchFiles: [listed, { path: `${CWD}/docs/other.md`, lineSnippet: "", key: "other" }], |
| 108 | + }, |
| 109 | + state, |
| 110 | + ), |
| 111 | + ).toEqual({ decision: "approve" }) |
| 112 | + expect(await approval({ tool: "readFile", batchFiles: [listed, excluded] }, state)).toEqual({ decision: "ask" }) |
| 113 | + expect(await approval({ tool: "readFile", batchFiles: [listed, outside] }, state)).toEqual({ decision: "ask" }) |
| 114 | + expect(await approval({ tool: "readFile", batchFiles: [listed], additionalFileCount: 1 }, state)).toEqual({ |
| 115 | + decision: "ask", |
| 116 | + }) |
| 117 | + }) |
| 118 | + |
| 119 | + it.each(["ENOENT", "EACCES"])( |
| 120 | + "does not infer root equivalence when task root resolution fails with %s", |
| 121 | + async (code) => { |
| 122 | + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("root unavailable"), { code })) |
| 123 | + const tool: ClineSayTool = { |
| 124 | + tool: "readFile", |
| 125 | + path: `${CANONICAL_CWD}/docs/notes.md`, |
| 126 | + isOutsideWorkspace: true, |
| 127 | + } |
| 128 | + |
| 129 | + expect(await approval(tool, { allowedReadFiles: ["docs/**"] })).toEqual({ decision: "ask" }) |
| 130 | + expect(await approval(tool, { allowedReadFiles: [`${CWD}/docs/**`] })).toEqual({ decision: "ask" }) |
| 131 | + expect(await approval(tool, { allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] })).toEqual({ |
| 132 | + decision: "approve", |
| 133 | + }) |
| 134 | + }, |
| 135 | + ) |
| 136 | + |
| 137 | + it("does not resolve workspace-relative grants without a task root", async () => { |
| 138 | + const text = JSON.stringify({ |
| 139 | + tool: "readFile", |
| 140 | + path: `${CANONICAL_CWD}/docs/notes.md`, |
| 141 | + isOutsideWorkspace: true, |
| 142 | + }) |
| 143 | + expect( |
| 144 | + await checkAutoApproval({ state: { ...baseState, allowedReadFiles: ["docs/**"] }, ask: "tool", text }), |
| 145 | + ).toEqual({ decision: "ask" }) |
| 146 | + expect( |
| 147 | + await checkAutoApproval({ |
| 148 | + state: { ...baseState, allowedReadFiles: [`${CANONICAL_CWD}/docs/**`] }, |
| 149 | + ask: "tool", |
| 150 | + text, |
| 151 | + }), |
| 152 | + ).toEqual({ decision: "approve" }) |
| 153 | + expect(fs.realpath).not.toHaveBeenCalled() |
| 154 | + }) |
| 155 | +}) |
0 commit comments