From f0c44196aeb3d2751c163899adf0c9294486c536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Mon, 17 Aug 2026 04:03:07 +0200 Subject: [PATCH 1/4] feat: Add Read+Write allowlists. Fixes #1273 Enables giving Read or Write access to specific files, without giving full access on the workspace. This is the equivalent of the existing auto-approve-commands allowlist, but for files (and much safer, and easier to implement). Useful to restrict the agent e.g. to write notes that survive task switches and context condensation. Example use cases: * You could use this to tell the agent to keep a coarse log of what's being done in `./notes.md`, and you won't have to manually approve updates to that even if otherwise you review-and-approve every diff. This is expecially useful because many LLMs' context condensation is not very good: They will "forget" key commands upon condensation, thus starting to make mistakes or ask the user to help. With a reliable notes document where they can look up commands, this problem diappears. * Let Zoo sift through large amounts of data (larger than the context window) unattended in a mostly-read-only fashion but with the need to remember results reliably (unaffected by context compression). For example, if you have 1000 large text documents and want the LLM to record all occurrences of specific topics, without giving Write permissions to all files. * In the Zoo repo to allow automatic Auto-Approve only for `./webview-ui/src/i18n/**` to update translations. * Auto-approving writes to a specific file outside the workspace, such as `~/.gitconfig` when asking the agent to help you iterate on Git configuration. LLM: Done with Claude Opus 5 in Zoo Code, human review. --- packages/types/src/global-settings.ts | 14 + packages/types/src/vscode-extension-host.ts | 2 + .../__tests__/allowedReadFiles.spec.ts | 170 ++++++++++ .../__tests__/allowedWriteFiles.spec.ts | 144 +++++++++ .../__tests__/filePatterns.spec.ts | 175 +++++++++++ .../auto-approval/__tests__/negation.spec.ts | 115 +++++++ .../__tests__/noWorkspaceRoot.spec.ts | 77 +++++ src/core/auto-approval/filePatterns.ts | 297 ++++++++++++++++++ src/core/auto-approval/index.ts | 81 ++++- src/core/webview/ClineProvider.ts | 10 + .../webview/__tests__/ClineProvider.spec.ts | 70 +++++ .../__tests__/webviewMessageHandler.spec.ts | 80 +++++ src/core/webview/webviewMessageHandler.ts | 11 + .../settings/AutoApproveSettings.tsx | 40 +++ .../settings/FilePatternAllowlist.tsx | 66 ++++ .../src/components/settings/SettingsView.tsx | 6 + .../__tests__/AutoApproveSettings.spec.tsx | 65 ++++ .../src/context/ExtensionStateContext.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 14 + webview-ui/src/i18n/locales/de/settings.json | 14 + webview-ui/src/i18n/locales/en/settings.json | 14 + webview-ui/src/i18n/locales/es/settings.json | 14 + webview-ui/src/i18n/locales/fr/settings.json | 14 + webview-ui/src/i18n/locales/hi/settings.json | 14 + webview-ui/src/i18n/locales/id/settings.json | 14 + webview-ui/src/i18n/locales/it/settings.json | 14 + webview-ui/src/i18n/locales/ja/settings.json | 14 + webview-ui/src/i18n/locales/ko/settings.json | 14 + webview-ui/src/i18n/locales/nl/settings.json | 14 + webview-ui/src/i18n/locales/pl/settings.json | 14 + .../src/i18n/locales/pt-BR/settings.json | 14 + webview-ui/src/i18n/locales/ru/settings.json | 14 + webview-ui/src/i18n/locales/tr/settings.json | 14 + webview-ui/src/i18n/locales/vi/settings.json | 14 + .../src/i18n/locales/zh-CN/settings.json | 14 + .../src/i18n/locales/zh-TW/settings.json | 14 + webview-ui/src/utils/test-utils.tsx | 2 + 37 files changed, 1672 insertions(+), 7 deletions(-) create mode 100644 src/core/auto-approval/__tests__/allowedReadFiles.spec.ts create mode 100644 src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts create mode 100644 src/core/auto-approval/__tests__/filePatterns.spec.ts create mode 100644 src/core/auto-approval/__tests__/negation.spec.ts create mode 100644 src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts create mode 100644 src/core/auto-approval/filePatterns.ts create mode 100644 webview-ui/src/components/settings/FilePatternAllowlist.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index bd440512ce..95f246dbe7 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -123,9 +123,23 @@ export const globalSettingsSchema = z.object({ autoApprovalEnabled: z.boolean().optional(), alwaysAllowReadOnly: z.boolean().optional(), alwaysAllowReadOnlyOutsideWorkspace: z.boolean().optional(), + /** + * Gitignore-style patterns naming the files that may be read without + * approval, even when `alwaysAllowReadOnly` is off. Resolved relative to the + * workspace root; absolute patterns are also accepted. + */ + allowedReadFiles: z.array(z.string()).optional(), alwaysAllowWrite: z.boolean().optional(), alwaysAllowWriteOutsideWorkspace: z.boolean().optional(), alwaysAllowWriteProtected: z.boolean().optional(), + /** + * Gitignore-style path patterns, relative to the workspace root, whose files + * may be created/edited without approval even when `alwaysAllowWrite` is off. + * + * Lets a user grant a narrow, path-scoped write permission (for example a + * scratchpad file) without auto-approving writes to the whole workspace. + */ + allowedWriteFiles: z.array(z.string()).optional(), writeDelayMs: z.number().min(0).optional(), /** * Fuzzy matching threshold for the multi-search-replace diff strategy. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ea52c09599..6f3ae38ec9 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -270,9 +270,11 @@ export type ExtensionState = Pick< | "autoApprovalEnabled" | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" + | "allowedReadFiles" | "alwaysAllowWrite" | "alwaysAllowWriteOutsideWorkspace" | "alwaysAllowWriteProtected" + | "allowedWriteFiles" | "alwaysAllowMcp" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" diff --git a/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts b/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts new file mode 100644 index 0000000000..a998ffef43 --- /dev/null +++ b/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts @@ -0,0 +1,170 @@ +// npx vitest run core/auto-approval/__tests__/allowedReadFiles.spec.ts + +import type { ExtensionState } from "@roo-code/types" + +import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." + +const CWD = "/path/to/repo" + +type State = Pick + +const baseState: State = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + allowedWriteFiles: [], + cwd: CWD, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + destructiveCommandGuardEnabled: false, + allowedCommands: [], + deniedCommands: [], +} + +const askToRead = async ({ + state, + tool = "readFile", + ...payload +}: { + state: Partial + tool?: string + path?: string + batchFiles?: Array<{ path: string }> + isOutsideWorkspace?: boolean +}) => + checkAutoApproval({ + state: { ...baseState, ...state }, + ask: "tool", + text: JSON.stringify({ tool, ...payload }), + }) + +describe("allowedReadFiles auto-approval", () => { + it("asks when the file is not listed", async () => { + expect(await askToRead({ path: "src/index.ts", state: { allowedReadFiles: ["notes.md"] } })).toEqual({ + decision: "ask", + }) + }) + + it("approves a listed file even though alwaysAllowReadOnly is off", async () => { + expect(await askToRead({ path: "notes.md", state: { allowedReadFiles: ["notes.md"] } })).toEqual({ + decision: "approve", + }) + }) + + it("approves a listed file outside the workspace without the outside-workspace toggle", async () => { + expect( + await askToRead({ + path: "/tmp/notes.md", + isOutsideWorkspace: true, + state: { allowedReadFiles: ["/tmp/notes.md"] }, + }), + ).toEqual({ decision: "approve" }) + }) + + it("approves a file covered by a glob", async () => { + expect(await askToRead({ path: "docs/scratch/a.md", state: { allowedReadFiles: ["docs/scratch/**"] } })).toEqual( + { decision: "approve" }, + ) + }) + + // Write permission implies read permission. + it("approves a file listed only in the write allowlist", async () => { + expect(await askToRead({ path: "notes.md", state: { allowedWriteFiles: ["notes.md"] } })).toEqual({ + decision: "approve", + }) + }) + + it("does not grant write permission for a read-listed file", async () => { + expect( + await checkAutoApproval({ + state: { ...baseState, allowedReadFiles: ["notes.md"] }, + ask: "tool", + text: JSON.stringify({ tool: "newFileCreated", path: "notes.md" }), + }), + ).toEqual({ decision: "ask" }) + }) + + describe("batch reads", () => { + it("approves when every file in the batch is listed", async () => { + expect( + await askToRead({ + batchFiles: [{ path: "notes.md" }, { path: "todo.md" }], + state: { allowedReadFiles: ["*.md"] }, + }), + ).toEqual({ decision: "approve" }) + }) + + // One approval answers for the whole batch, so a single unlisted file + // must not be carried in by its listed siblings. + it("asks when only some files in the batch are listed", async () => { + expect( + await askToRead({ + batchFiles: [{ path: "notes.md" }, { path: "src/index.ts" }], + state: { allowedReadFiles: ["notes.md"] }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("draws on both allowlists across a batch", async () => { + expect( + await askToRead({ + batchFiles: [{ path: "notes.md" }, { path: "todo.md" }], + state: { allowedReadFiles: ["notes.md"], allowedWriteFiles: ["todo.md"] }, + }), + ).toEqual({ decision: "approve" }) + }) + }) + + // The allowlist names files, but these tools act on directories and report + // on files no pattern named, so a pattern must not approve them. + describe("tools that are not file reads", () => { + it.each(["listFiles", "listFilesTopLevel", "listFilesRecursive", "searchFiles", "codebaseSearch"])( + "asks for %s even when the path is listed", + async (tool) => { + expect( + await askToRead({ + tool, + path: "docs", + state: { allowedReadFiles: ["docs", "docs/**", "**"] }, + }), + ).toEqual({ decision: "ask" }) + }, + ) + + it("still approves those tools when alwaysAllowReadOnly is on", async () => { + expect(await askToRead({ tool: "listFiles", path: "docs", state: { alwaysAllowReadOnly: true } })).toEqual({ + decision: "approve", + }) + }) + }) + + it("asks when auto-approval is disabled entirely", async () => { + expect( + await askToRead({ + path: "notes.md", + state: { allowedReadFiles: ["notes.md"], autoApprovalEnabled: false }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("leaves the alwaysAllowReadOnly behaviour unchanged when nothing is listed", async () => { + expect(await askToRead({ path: "src/index.ts", state: { alwaysAllowReadOnly: true } })).toEqual({ + decision: "approve", + }) + + expect( + await askToRead({ + path: "/tmp/notes.md", + isOutsideWorkspace: true, + state: { alwaysAllowReadOnly: true }, + }), + ).toEqual({ decision: "ask" }) + }) +}) diff --git a/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts b/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts new file mode 100644 index 0000000000..ba06086304 --- /dev/null +++ b/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts @@ -0,0 +1,144 @@ +// npx vitest run core/auto-approval/__tests__/allowedWriteFiles.spec.ts + +import type { ExtensionState } from "@roo-code/types" + +import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." + +const CWD = "/path/to/repo" + +type State = Pick + +const baseState: State = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + allowedWriteFiles: [], + cwd: CWD, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + destructiveCommandGuardEnabled: false, + allowedCommands: [], + deniedCommands: [], +} + +const askToWrite = async ({ + path, + state, + tool = "newFileCreated", + isProtected, + isOutsideWorkspace, +}: { + path: string + state: Partial + tool?: string + isProtected?: boolean + isOutsideWorkspace?: boolean +}) => + checkAutoApproval({ + state: { ...baseState, ...state }, + ask: "tool", + text: JSON.stringify({ tool, path, isOutsideWorkspace, isProtected }), + isProtected, + }) + +describe("allowedWriteFiles auto-approval", () => { + it("asks when the file is not listed", async () => { + expect(await askToWrite({ path: "src/index.ts", state: { allowedWriteFiles: ["notes.md"] } })).toEqual({ + decision: "ask", + }) + }) + + it("approves a listed file even though alwaysAllowWrite is off", async () => { + expect(await askToWrite({ path: "notes.md", state: { allowedWriteFiles: ["notes.md"] } })).toEqual({ + decision: "approve", + }) + }) + + it("approves each write tool action for a listed file", async () => { + for (const tool of ["editedExistingFile", "appliedDiff", "newFileCreated", "generateImage"]) { + expect(await askToWrite({ path: "notes.md", state: { allowedWriteFiles: ["notes.md"] }, tool })).toEqual({ + decision: "approve", + }) + } + }) + + it("approves a listed file outside the workspace without the outside-workspace toggle", async () => { + expect( + await askToWrite({ + path: "/tmp/notes.md", + isOutsideWorkspace: true, + state: { allowedWriteFiles: ["/tmp/notes.md"] }, + }), + ).toEqual({ decision: "approve" }) + }) + + it("still asks for a protected file, even when listed", async () => { + expect( + await askToWrite({ + path: "AGENTS.md", + isProtected: true, + state: { allowedWriteFiles: ["*.md"] }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("approves a listed protected file once protected writes are allowed", async () => { + expect( + await askToWrite({ + path: "AGENTS.md", + isProtected: true, + state: { allowedWriteFiles: ["*.md"], alwaysAllowWriteProtected: true }, + }), + ).toEqual({ decision: "approve" }) + }) + + // Write permission implies read permission. + it("grants read permission for a listed file", async () => { + expect( + await checkAutoApproval({ + state: { ...baseState, allowedWriteFiles: ["notes.md"] }, + ask: "tool", + text: JSON.stringify({ tool: "readFile", path: "notes.md" }), + }), + ).toEqual({ decision: "approve" }) + }) + + it("does not grant read permission for an unlisted file", async () => { + expect( + await checkAutoApproval({ + state: { ...baseState, allowedWriteFiles: ["notes.md"] }, + ask: "tool", + text: JSON.stringify({ tool: "readFile", path: "src/index.ts" }), + }), + ).toEqual({ decision: "ask" }) + }) + + it("asks when auto-approval is disabled entirely", async () => { + expect( + await askToWrite({ + path: "notes.md", + state: { allowedWriteFiles: ["notes.md"], autoApprovalEnabled: false }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("leaves the alwaysAllowWrite behaviour unchanged when nothing is listed", async () => { + expect(await askToWrite({ path: "src/index.ts", state: { alwaysAllowWrite: true } })).toEqual({ + decision: "approve", + }) + + expect( + await askToWrite({ + path: "/tmp/notes.md", + isOutsideWorkspace: true, + state: { alwaysAllowWrite: true }, + }), + ).toEqual({ decision: "ask" }) + }) +}) diff --git a/src/core/auto-approval/__tests__/filePatterns.spec.ts b/src/core/auto-approval/__tests__/filePatterns.spec.ts new file mode 100644 index 0000000000..e7d1dee515 --- /dev/null +++ b/src/core/auto-approval/__tests__/filePatterns.spec.ts @@ -0,0 +1,175 @@ +// npx vitest run core/auto-approval/__tests__/filePatterns.spec.ts + +import os from "os" + +import { isFileMatchedByPatterns, toMatcherPattern } from "../filePatterns" + +const CWD = "/path/to/repo" + +const matches = (filePath: string, patterns: string[], cwd: string | undefined = CWD) => + isFileMatchedByPatterns({ filePath, cwd, patterns }) + +const homeFromRoot = os.homedir().replace(/\\/g, "/").slice(1) + +describe("toMatcherPattern", () => { + it("prefixes the workspace root and lets a bare filename match in any directory", () => { + expect(toMatcherPattern("notes.md", CWD)).toBe("/path/to/repo/**/notes.md") + }) + + it("anchors a pattern containing a slash to the workspace root", () => { + expect(toMatcherPattern("docs/notes.md", CWD)).toBe("/path/to/repo/docs/notes.md") + }) + + it("anchors an explicitly workspace-root-relative pattern", () => { + expect(toMatcherPattern("./notes.md", CWD)).toBe("/path/to/repo/notes.md") + }) + + it("keeps backslashes, which gitignore uses to escape rather than to separate", () => { + expect(toMatcherPattern("notes.md\\ ", CWD)).toBe("/path/to/repo/**/notes.md\\ ") + expect(toMatcherPattern("\\#hash.md", CWD)).toBe("/path/to/repo/**/\\#hash.md") + }) + + it("resolves a workspace-escaping pattern against the workspace root", () => { + expect(toMatcherPattern("../shared/notes.md", CWD)).toBe("/path/to/shared/notes.md") + }) + + it("expands a leading ~ to the home directory", () => { + expect(toMatcherPattern("~/notes.md", CWD)).toBe(`/${homeFromRoot}/notes.md`) + }) + + it("lowercases a Windows drive so drive letters compare case-insensitively", () => { + expect(toMatcherPattern("C:/tmp/notes.md", CWD)).toBe("/c:/tmp/notes.md") + }) + + it("anchors a negation exactly like the pattern it cancels", () => { + expect(toMatcherPattern("!notes.md", CWD)).toBe("!/path/to/repo/**/notes.md") + expect(toMatcherPattern("!/tmp/notes.md", CWD)).toBe("!/tmp/notes.md") + }) + + it.each([ + ["an empty pattern", ""], + ["a whitespace-only pattern", " "], + ["the workspace root itself", "."], + ["the home directory itself", "~"], + ["a directory pattern", "mydir/"], + ])("rejects %s", (_label, pattern) => { + expect(toMatcherPattern(pattern, CWD)).toBeUndefined() + }) + + it("preserves whitespace, which gitignore syntax treats as significant", () => { + expect(toMatcherPattern(" notes.md", CWD)).toBe("/path/to/repo/**/ notes.md") + expect(toMatcherPattern("my notes.md", CWD)).toBe("/path/to/repo/**/my notes.md") + }) + + // See noWorkspaceRoot.spec.ts for why this fails closed. + it("rejects a workspace-relative pattern when the workspace root is unknown", () => { + expect(toMatcherPattern("../shared/notes.md", undefined)).toBeUndefined() + expect(toMatcherPattern("notes.md", undefined)).toBeUndefined() + }) + + it("keeps an absolute pattern usable when the workspace root is unknown", () => { + expect(toMatcherPattern("/tmp/notes.md", undefined)).toBe("/tmp/notes.md") + }) +}) + +describe("isFileMatchedByPatterns", () => { + it("does not match when no patterns are configured", () => { + expect(matches("notes.md", [])).toBe(false) + expect(isFileMatchedByPatterns({ filePath: "notes.md", cwd: CWD })).toBe(false) + }) + + it("does not match when no path is given", () => { + expect(isFileMatchedByPatterns({ filePath: undefined, cwd: CWD, patterns: ["notes.md"] })).toBe(false) + }) + + it("matches an exact workspace-relative path", () => { + expect(matches("docs/notes.md", ["docs/notes.md"])).toBe(true) + }) + + it("does not match a different file", () => { + expect(matches("docs/other.md", ["docs/notes.md"])).toBe(false) + }) + + it("matches a bare filename in any directory", () => { + expect(matches("notes.md", ["notes.md"])).toBe(true) + expect(matches("deeply/nested/notes.md", ["notes.md"])).toBe(true) + }) + + it("restricts an anchored pattern to the workspace root", () => { + expect(matches("notes.md", ["./notes.md"])).toBe(true) + expect(matches("deeply/nested/notes.md", ["./notes.md"])).toBe(false) + }) + + it("matches everything under a directory glob", () => { + expect(matches("docs/scratch/a.md", ["docs/scratch/**"])).toBe(true) + expect(matches("docs/scratch/nested/b.md", ["docs/scratch/**"])).toBe(true) + expect(matches("docs/elsewhere/a.md", ["docs/scratch/**"])).toBe(false) + }) + + it("matches an extension glob", () => { + expect(matches("docs/notes.md", ["*.md"])).toBe(true) + expect(matches("docs/notes.txt", ["*.md"])).toBe(false) + }) + + it("matches an absolute path against a workspace-relative pattern", () => { + expect(matches(`${CWD}/docs/notes.md`, ["docs/notes.md"])).toBe(true) + }) + + it("matches a workspace-relative path against an absolute pattern", () => { + expect(matches("docs/notes.md", [`${CWD}/docs/notes.md`])).toBe(true) + }) + + it("matches a file outside the workspace via an absolute pattern", () => { + expect(matches("/tmp/notes.md", ["/tmp/notes.md"])).toBe(true) + }) + + it("matches a file outside the workspace via a workspace-escaping pattern", () => { + expect(matches("../shared/notes.md", ["../shared/notes.md"])).toBe(true) + expect(matches("/path/to/shared/notes.md", ["../shared/notes.md"])).toBe(true) + }) + + it("does not match a file outside the workspace via a workspace-relative pattern", () => { + // "notes.md" is scoped to the workspace, so an unrelated absolute path + // of the same name must not be approved by it. + expect(matches("/tmp/notes.md", ["notes.md"])).toBe(false) + }) + + it("keeps Windows drives apart", () => { + expect(matches("C:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(true) + expect(matches("D:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(false) + }) + + it("matches a path that uses Windows separators", () => { + expect(matches("docs\\notes.md", ["docs/notes.md"])).toBe(true) + }) + + it("honours an escaped glob character in a pattern", () => { + expect(matches("docs/a*b.md", ["docs/a\\*b.md"])).toBe(true) + expect(matches("docs/axb.md", ["docs/a\\*b.md"])).toBe(false) + }) + + it("still matches valid patterns when other entries are unusable", () => { + expect(matches("docs/notes.md", ["", "mydir/", "docs/notes.md"])).toBe(true) + }) + + it("matches filenames containing spaces", () => { + expect(matches("docs/my notes.md", ["docs/my notes.md"])).toBe(true) + expect(matches("docs/ notes.md", ["docs/ notes.md"])).toBe(true) + }) + + it("applies gitignore's trailing-whitespace rule", () => { + // An unescaped trailing space is dropped from the pattern, so it names + // the space-free file; escaping it keeps the space. + expect(matches("docs/notes.md", ["docs/notes.md "])).toBe(true) + expect(matches("docs/notes.md ", ["docs/notes.md\\ "])).toBe(true) + }) + + it("matches a workspace-relative path when the workspace root is unknown", () => { + expect(matches("docs/notes.md", ["docs/notes.md"], undefined)).toBe(true) + }) + + it("honours a negation that excludes a file from a broader pattern", () => { + expect(matches("docs/secret.md", ["docs/**", "!docs/secret.md"])).toBe(false) + expect(matches("docs/notes.md", ["docs/**", "!docs/secret.md"])).toBe(true) + }) +}) diff --git a/src/core/auto-approval/__tests__/negation.spec.ts b/src/core/auto-approval/__tests__/negation.spec.ts new file mode 100644 index 0000000000..cdfc668222 --- /dev/null +++ b/src/core/auto-approval/__tests__/negation.spec.ts @@ -0,0 +1,115 @@ +// npx vitest run core/auto-approval/__tests__/negation.spec.ts + +import type { ExtensionState } from "@roo-code/types" + +import { isFileMatchedByPatterns } from "../filePatterns" +import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." + +const CWD = "/path/to/repo" + +type State = Pick + +const matches = (filePath: string, patterns: string[]) => isFileMatchedByPatterns({ filePath, cwd: CWD, patterns }) + +const baseState: State = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + allowedWriteFiles: [], + cwd: CWD, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + destructiveCommandGuardEnabled: false, + allowedCommands: [], + deniedCommands: [], +} + +const readDecision = async (state: Partial, path = "docs/secret.md") => + checkAutoApproval({ + state: { ...baseState, ...state }, + ask: "tool", + text: JSON.stringify({ tool: "readFile", path }), + }) + +describe("pattern negation", () => { + it("excludes a workspace-relative file from a workspace-relative glob", () => { + expect(matches("docs/secret.md", ["docs/**", "!docs/secret.md"])).toBe(false) + expect(matches("docs/notes.md", ["docs/**", "!docs/secret.md"])).toBe(true) + }) + + // A negation is scoped by the path it names, not by the "!", so it lands in + // the same scope as the pattern it is meant to cancel. + it("excludes an absolute file from an absolute glob", () => { + expect(matches("/tmp/x/secret.md", ["/tmp/x/**", "!/tmp/x/secret.md"])).toBe(false) + expect(matches("/tmp/x/notes.md", ["/tmp/x/**", "!/tmp/x/secret.md"])).toBe(true) + }) + + // A deny pattern has to be effective whenever it names the file, whatever + // spelling was used for it or for the pattern it cancels. All patterns are + // therefore rewritten into one form and matched together. + it("cancels across the workspace-relative and absolute spellings", () => { + expect(matches("docs/secret.md", ["docs/**", `!${CWD}/docs/secret.md`])).toBe(false) + expect(matches("docs/secret.md", [`${CWD}/docs/**`, "!docs/secret.md"])).toBe(false) + }) + + it("cancels when both are written in the same form", () => { + expect(matches("docs/secret.md", [`${CWD}/docs/**`, `!${CWD}/docs/secret.md`])).toBe(false) + }) + + it("cancels a bare-filename pattern with an anchored negation", () => { + expect(matches("docs/secret.md", ["secret.md", "!docs/secret.md"])).toBe(false) + expect(matches("other/secret.md", ["secret.md", "!docs/secret.md"])).toBe(true) + }) + + it("excludes via a home-directory negation", () => { + expect(matches("~/notes.md".replace("~", process.env.HOME ?? "~"), ["~/**", "!~/notes.md"])).toBe(false) + }) + + it("grants nothing when only negations are configured", () => { + expect(matches("docs/secret.md", ["!docs/secret.md"])).toBe(false) + }) + + describe("across the two allowlists", () => { + // Each list is matched independently, so which box a pattern was typed + // into cannot change the outcome by reordering a concatenation. + it("does not let a write-list negation revoke read access", () => { + expect( + readDecision({ allowedReadFiles: ["docs/**"], allowedWriteFiles: ["!docs/secret.md"] }), + ).resolves.toEqual({ decision: "approve" }) + }) + + it("does not let a read-list negation revoke read access granted by the write list", async () => { + // Write permission implies read permission, so the write pattern + // still grants the read; a negation only narrows its own list. + expect( + await readDecision({ allowedReadFiles: ["!docs/secret.md"], allowedWriteFiles: ["docs/**"] }), + ).toEqual({ decision: "approve" }) + }) + + it("asks when each list's own negation excludes the file", async () => { + expect( + await readDecision({ + allowedReadFiles: ["docs/**", "!docs/secret.md"], + allowedWriteFiles: ["docs/**", "!docs/secret.md"], + }), + ).toEqual({ decision: "ask" }) + + expect( + await readDecision( + { + allowedReadFiles: ["docs/**", "!docs/secret.md"], + allowedWriteFiles: ["docs/**", "!docs/secret.md"], + }, + "docs/notes.md", + ), + ).toEqual({ decision: "approve" }) + }) + }) +}) diff --git a/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts b/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts new file mode 100644 index 0000000000..a9b91ac1a2 --- /dev/null +++ b/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts @@ -0,0 +1,77 @@ +// npx vitest run core/auto-approval/__tests__/noWorkspaceRoot.spec.ts + +import { isFileMatchedByPatterns, toMatcherPattern } from "../filePatterns" + +const matchesWithoutWorkspace = (filePath: string, patterns: string[]) => + isFileMatchedByPatterns({ filePath, cwd: undefined, patterns }) + +describe("patterns without a workspace root", () => { + // A bare gitignore pattern matches in any directory. Left unprefixed, it would + // reach the whole filesystem, so a workspace-relative pattern must not be + // usable at all while there is no workspace to confine it to. + describe("does not let a workspace-relative pattern reach outside a workspace", () => { + it.each([ + ["a bare filename", "passwd"], + ["a star", "*"], + ["a double star", "**"], + ["an extension glob", "*.conf"], + ["a workspace-root-anchored path", "./passwd"], + ["a nested path", "etc/passwd"], + ])("rejects %s", (_label, pattern) => { + expect(toMatcherPattern(pattern, undefined)).toBeUndefined() + }) + + it.each([ + ["passwd", "/etc/passwd"], + ["*", "/etc/passwd"], + ["**", "/etc/passwd"], + ["*.conf", "/etc/nginx/nginx.conf"], + ["etc/passwd", "/etc/passwd"], + ])("does not match %s against %s", (pattern, filePath) => { + expect(matchesWithoutWorkspace(filePath, [pattern])).toBe(false) + }) + + it("does not match a workspace-relative path either", () => { + expect(matchesWithoutWorkspace("notes.md", ["notes.md"])).toBe(false) + }) + }) + + // An absolute pattern names its location outright, so it needs no workspace. + describe("keeps absolute patterns usable", () => { + it("matches an absolute pattern against that absolute path", () => { + expect(matchesWithoutWorkspace("/tmp/notes.md", ["/tmp/notes.md"])).toBe(true) + }) + + it("does not match a different absolute path", () => { + expect(matchesWithoutWorkspace("/etc/passwd", ["/tmp/notes.md"])).toBe(false) + }) + + it("matches an absolute glob", () => { + expect(matchesWithoutWorkspace("/tmp/scratch/notes.md", ["/tmp/scratch/**"])).toBe(true) + }) + + it("honours an absolute negation", () => { + expect(matchesWithoutWorkspace("/tmp/scratch/secret.md", ["/tmp/scratch/**", "!/tmp/scratch/secret.md"])).toBe( + false, + ) + }) + }) + + // With a workspace root the same patterns are confined to it, which is the + // behaviour the rejection above preserves. + describe("for contrast, with a workspace root", () => { + it("confines a bare filename to the workspace", () => { + expect(isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["passwd"] })).toBe( + false, + ) + + expect( + isFileMatchedByPatterns({ filePath: "/path/to/repo/etc/passwd", cwd: "/path/to/repo", patterns: ["passwd"] }), + ).toBe(true) + }) + + it("confines a star to the workspace", () => { + expect(isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["*"] })).toBe(false) + }) + }) +}) diff --git a/src/core/auto-approval/filePatterns.ts b/src/core/auto-approval/filePatterns.ts new file mode 100644 index 0000000000..416b96e9c6 --- /dev/null +++ b/src/core/auto-approval/filePatterns.ts @@ -0,0 +1,297 @@ +import os from "os" +import path from "path" + +import ignore from "ignore" + +/** + * Matching of file paths against user-configured file patterns. + * + * This is used to grant an access permission for a few named files instead of + * for the whole workspace, for example `allowedWriteFiles`, which lists the + * files Zoo may create or edit without asking. + * + * The syntax is gitignore-inspired, but deliberately differs from it in how a + * path is anchored, because these patterns can name files anywhere on the + * filesystem rather than only inside one repository: + * + * | Pattern | Matches | + * | --------------------- | ------------------------------------------------ | + * | `notes.md` | that filename in any directory of the workspace | + * | `*.md` | any such file in any directory of the workspace | + * | `docs/notes.md` | that path relative to the workspace root | + * | `docs/scratch/**` | everything below that workspace directory | + * | `./notes.md` | that file in the workspace root only | + * | `../shared/notes.md` | that path next to the workspace | + * | `/tmp/notes.md` | that absolute path (`/` is the filesystem root) | + * | `C:/tmp/notes.md` | that absolute path on Windows | + * | `~/notes.md` | that path in the user's home directory | + * | `!docs/secret.md` | excludes a file matched by an earlier pattern | + * + * The differences from gitignore are all about reaching outside the workspace, + * which gitignore has no need to do. Of the three spellings below, gitignore + * accepts each without complaint and then matches nothing with it: + * - `/notes.md` is an absolute filesystem path, whereas gitignore would read it + * as the workspace root. Absolute paths have to be expressible, and `/` is the + * spelling users expect for them. + * - `./notes.md` anchors to the workspace root, the role gitignore gives to + * `/notes.md`. + * - `../shared/notes.md` is resolved against the workspace root, so it names a + * path outside the workspace. For gitignore, `..` is a literal segment that no + * path inside the repository can match. + * + * `/` is the only directory separator, also on Windows, since a backslash + * escapes the character after it. + * + * # Why patterns and paths are rewritten before matching + * + * The `ignore` library matches a path against patterns as `git` does against + * `.gitignore` entries, so both have to be relative to, and below, one base + * directory. It therefore cannot handle: + * - absolute paths (`/tmp/notes.md`, `C:/tmp/notes.md`), or + * - paths that climb out of the base directory (`../shared/notes.md`). + * Running `ignore`'s matching ()`ignores()`) against such a path throws + * `RangeError` ("path should be a `path.relative()`d string"). + * Passing such a *pattern* to `add()` throws nothing at all: + * It is accepted and then quietly never matches, which would + * turn a mistyped permission into a silent no-op. + * + * Both the configured patterns and the path being checked are therefore + * rewritten into a single form the library accepts: a path relative to the + * filesystem root (`/`), with any Windows drive as its first segment. + * + * An alternative implementation would be to remember for each pattern whether + * it's a workspace-scoped pattern or an absolute-filesystem pattern, and then + * run `ignore` twice, with different base dirs. We do not do that because it + * makes implementing negation patterns (`!`) harder, as `ignore` checks for + * negations only within the given set of patterns; if we had 2 such sets (one + * for workspace, one for absolute), then an absolute negation pattern like + * `!/path/to/my/repo/secret.txt` would surprisingly not deny a relative pattern + * such as `*.txt`, because those would be handled by different `ignore` + * instances. + */ + +/** Convert Windows path separators so patterns and paths share one syntax. */ +function pathsepsToPosix(value: string): string { + return value.replace(/\\/g, "/") +} + +function isAbsolutePosixPath(value: string): boolean { + // Posix ("/tmp/x") or Windows with a drive letter ("C:/tmp/x"). + return value.startsWith("/") || /^[a-zA-Z]:\//.test(value) +} + +function escapesWorkspace(posixPath: string): boolean { + return posixPath.split("/").includes("..") +} + +/** + * Rewrite an absolute path as a path relative to the filesystem root, since the + * `ignore` library rejects paths that start with `/`. + * + * The Windows drive becomes the first path segment (lowercased, since Windows + * treats drive letters case-insensitively), which keeps drives apart: a `c:/` + * pattern cannot match a `d:/` path. + * + * - `"/tmp/notes.md"` -> `"tmp/notes.md"` + * - `"C:/tmp/notes.md"` -> `"c:/tmp/notes.md"` + */ +function toRootRelativePath(absolutePosixPath: string): string { + const drive = absolutePosixPath.match(/^([a-zA-Z]):\//) + + if (drive) { + return `${drive[1].toLowerCase()}:/${absolutePosixPath.slice(drive[0].length)}` + } + + return absolutePosixPath.slice(1) +} + +/** + * Rewrite a user-supplied pattern so that it matches the root-relative paths + * produced by `toMatcherPath()`. + * + * Patterns that name a directory rather than a file are rejected (`undefined`). + * + * The workspace root (`cwd`) is normally known, but is absent when no folder is + * open in VS Code, and in contexts that build the extension state without one. + * Without it, a workspace-relative pattern is rejected rather than matched + * loosely: there is no workspace for it to be relative to, and a bare gitignore + * pattern matches in *any* directory, so `passwd` would otherwise match + * `/etc/passwd`, and a bare `*` would match every file on the machine. + * That would be quite a footgun. + * Only absolute patterns remain usable in that situation. + * + * When the root is known, a workspace-relative pattern has that root prefixed + * onto it, which puts it in the same form as an absolute one. gitignore anchors + * any pattern that has a separator at its beginning or middle, so prefixing turns + * a bare filename such as `notes.md`, which is meant to match in any directory, + * into a workspace-root-only match. Its reach is restored by inserting a `/**` + * segment (whose trailing slash cannot be written in this comment, as it would + * close the comment block) between the root and the filename: that wildcard stands + * for any number of directories including none, so the result matches + * `base/notes.md` as well as `base/a/b/notes.md`. + * + * In the below examples, `(star-star)` stands for the `**` wildcard, + * which cannot be written literally here because its trailing slash + * would close the JSDoc comment block. + * + * Examples (home directory `/home/me`). + * - For workspace root `cwd = "/path/to/repo"`: + * - `"notes.md"` -> `"/path/to/repo/(star-star)/notes.md"` + * - `"*.md"` -> `"/path/to/repo/(star-star)/*.md"` + * - `"docs/notes.md"` -> `"/path/to/repo/docs/notes.md"` + * - `"docs/scratch/**"` -> `"/path/to/repo/docs/scratch/**"` + * - `"./notes.md"` -> `"/path/to/repo/notes.md"` + * - `"../shared/notes.md"` -> `"/path/to/shared/notes.md"` + * resolved against the workspace root, so it lands outside the workspace + * - `"~/notes.md"` -> `"/home/me/notes.md"` + * - `"C:/tmp/notes.md"` -> `"/c:/tmp/notes.md"` + * - `"!docs/secret.md"` -> `"!/path/to/repo/docs/secret.md"` + * - `"mydir/"` -> `undefined` (because it names a dir) + * - `"~"` -> `undefined` (because it names a dir) + * - For workspace root `cwd = undefined`: + * - `"notes.md"` with no workspace root -> `undefined` + * (as is any workspace-relative pattern, see above) + * + * Whitespace and backslashes are left as typed, because the `ignore` library + * applies gitignore's own rules to them: leading whitespace is part of the + * filename, trailing whitespace is dropped unless escaped (`"notes.md\ "`), and + * a backslash escapes the character after it. Only patterns consisting solely of + * whitespace are rejected, since they name no file. + * + * @param pattern - Raw pattern as typed by the user. + * @param cwd - Workspace root, used to resolve workspace-relative patterns. + * @returns The rewritten pattern, or `undefined` when the pattern can never + * match a file (empty, a directory, or escaping an unknown workspace root). + */ +export function toMatcherPattern(pattern: string, cwd?: string): string | undefined { + if (typeof pattern !== "string") { + return undefined + } + + // Set gitignore's negation aside so the path is rewritten on its own merits, + // then restore it, so that a negation is anchored exactly like the pattern it + // is written to cancel. + const negation = pattern.startsWith("!") ? "!" : "" + let normalized = pattern.slice(negation.length) + + if (!normalized.trim() || normalized === "." || normalized === "~" || normalized.endsWith("/")) { + return undefined + } + + if (normalized.startsWith("~/")) { + normalized = pathsepsToPosix(path.join(os.homedir(), normalized.slice(2))) + } + + if (!isAbsolutePosixPath(normalized) && escapesWorkspace(normalized)) { + if (!cwd) { + return undefined + } + + normalized = pathsepsToPosix(path.resolve(cwd, normalized)) + } + + if (isAbsolutePosixPath(normalized)) { + return `${negation}/${toRootRelativePath(normalized)}` + } + + // "./notes.md" names the workspace root explicitly. + const isWorkspaceRootAnchored = normalized.startsWith("./") + + if (isWorkspaceRootAnchored) { + normalized = normalized.slice(2) + } + + if (!cwd) { + // No workspace to be relative to; see the note above on why this is not + // matched loosely instead. + return undefined + } + + const workspaceBase = toRootRelativePath(pathsepsToPosix(path.resolve(cwd))) + + // gitignore anchors a pattern to the base directory as soon as it has a + // separator "at the beginning or middle (or both)" (gitignore(5)), and only a + // pattern with no separator at all matches at any level below. Prefixing the + // workspace root necessarily adds separators, which would silently turn a bare + // filename into a root-only match; a double-star segment, standing for any + // number of directories including none, restores its reach. + const matchesInAnyDirectory = !isWorkspaceRootAnchored && !normalized.includes("/") + + return `${negation}/${workspaceBase}/${matchesInAnyDirectory ? "**/" : ""}${normalized}` +} + +/** + * Rewrite the path of the file being checked into the same root-relative form + * that `toMatcherPattern` produces. + * + * @returns The rewritten path, or `undefined` when it names no file, or when it + * is relative and there is no workspace root to resolve it against. + */ +function toMatcherPath(filePath: string, cwd?: string): string | undefined { + // Not trimmed: whitespace can be part of a filename. + const normalized = pathsepsToPosix(filePath) + + if (!normalized.trim() || normalized === ".") { + return undefined + } + + if (isAbsolutePosixPath(normalized)) { + return toRootRelativePath(normalized) + } + + if (!cwd) { + // A relative path cannot be placed on the filesystem without a root, and + // the only patterns that survive without one are absolute, which such a + // path could never match anyway. + return undefined + } + + return toRootRelativePath(pathsepsToPosix(path.resolve(cwd, normalized))) +} + +/** + * Check whether a file path is covered by any of the configured patterns. + * + * Patterns are applied in the order given and the last one to match decides, so + * a `!` pattern excludes files matched by the patterns before it. + * + * @param filePath - Path of the file, either absolute or relative to `cwd`. + * @param cwd - Workspace root. + * @param patterns - Raw patterns as configured by the user. + */ +export function isFileMatchedByPatterns({ + filePath, + cwd, + patterns, +}: { + filePath?: string + cwd?: string + patterns?: string[] +}): boolean { + if (!filePath || !Array.isArray(patterns) || !patterns.length) { + return false + } + + const candidate = toMatcherPath(filePath, cwd) + + if (!candidate) { + return false + } + + const matcherPatterns = patterns + .map((pattern) => toMatcherPattern(pattern, cwd)) + .filter((pattern): pattern is string => !!pattern) + + if (!matcherPatterns.length) { + return false + } + + try { + return ignore().add(matcherPatterns).ignores(candidate) + } catch (error) { + // A path the matcher rejects cannot be confirmed as matching, so treat it + // as unmatched. + console.error(`[auto-approval] Failed to match path ${filePath}:`, error) + return false + } +} diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 3d24c7497f..de36c79c8c 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -12,6 +12,7 @@ import { ClineAskResponse } from "../../shared/WebviewMessage" import { isWriteToolAction, isReadOnlyToolAction } from "./tools" import { isMcpToolAlwaysAllowed } from "./mcp" import { getCommandDecision } from "./commands" +import { isFileMatchedByPatterns } from "./filePatterns" // We have auto-approval actions for different categories. export type AutoApprovalState = @@ -27,14 +28,55 @@ export type AutoApprovalState = export type AutoApprovalStateOptions = | "autoApprovalEnabled" | "alwaysAllowReadOnlyOutsideWorkspace" // For `alwaysAllowReadOnly`. + | "allowedReadFiles" // Grants reads per file, without `alwaysAllowReadOnly`. | "alwaysAllowWriteOutsideWorkspace" // For `alwaysAllowWrite`. | "alwaysAllowWriteProtected" + | "allowedWriteFiles" // Grants writes per file, without `alwaysAllowWrite`. + | "cwd" // To resolve the allowlist patterns. | "followupAutoApproveTimeoutMs" // For `alwaysAllowFollowupQuestions`. | "mcpServers" // For `alwaysAllowMcp`. | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" | "destructiveCommandGuardEnabled" +/** + * Whether a read-only tool action is fully covered by the read allowlist patterns. + * + * The allowlist names individual files, so it only ever approves `read_file`: + * the other read-only actions (directory listings, searches, codebase queries) + * work on directories, not files. + * + * A `read_file` call can cover several files at once, in which case a single + * approval answers for all of them. So this function returns whether ALL of + * them are allowed by the patterns. + * + * Write permission implies read permission, so both lists are consulted, each + * matched on its own rather than concatenated: gitignore negation is + * order-sensitive ("the last matching pattern wins"), so concatenating would let + * a `!` typed into one list cancel a pattern typed into the other, with the + * outcome depending on which list that was. A negation therefore only ever + * narrows the list it appears in. This also means that negating a read is + * ineffective while a non-negated write pattern still matches the file. + */ +function isReadAllowedByPatterns( + tool: ClineSayTool, + state: Pick, +): boolean { + if (tool.tool !== "readFile") { + return false + } + + const matches = (filePath?: string) => + isFileMatchedByPatterns({ filePath, cwd: state.cwd, patterns: state.allowedReadFiles }) || + isFileMatchedByPatterns({ filePath, cwd: state.cwd, patterns: state.allowedWriteFiles }) + + if (tool.batchFiles?.length) { + return tool.batchFiles.every((file) => matches(file.path)) + } + + return matches(tool.path) +} + export type CheckAutoApprovalResult = | { decision: "approve" } | { decision: "deny" } @@ -177,16 +219,41 @@ export async function checkAutoApproval({ const isOutsideWorkspace = !!tool.isOutsideWorkspace if (isReadOnlyToolAction(tool)) { - return state.alwaysAllowReadOnly === true && - (!isOutsideWorkspace || state.alwaysAllowReadOnlyOutsideWorkspace === true) - ? { decision: "approve" } - : { decision: "ask" } + // A file listed in `allowedReadFiles` may be read without the blanket + // `alwaysAllowReadOnly` permission. Such a pattern names its + // location, including outside the workspace, so it also stands in for + // `alwaysAllowReadOnlyOutsideWorkspace`. + const isAllowedReadFile = isReadAllowedByPatterns(tool, state) + + const isReadAllowed = + isAllowedReadFile || + (state.alwaysAllowReadOnly === true && + (!isOutsideWorkspace || state.alwaysAllowReadOnlyOutsideWorkspace === true)) + + return isReadAllowed ? { decision: "approve" } : { decision: "ask" } } if (isWriteToolAction(tool)) { - return state.alwaysAllowWrite === true && - (!isOutsideWorkspace || state.alwaysAllowWriteOutsideWorkspace === true) && - (!isProtected || state.alwaysAllowWriteProtected === true) + // A file listed in `allowedWriteFiles` may be written without the + // blanket `alwaysAllowWrite` permission. Such a pattern names its + // location, including outside the workspace, so it also stands in for + // `alwaysAllowWriteOutsideWorkspace`. + // + // It deliberately does not stand in for `alwaysAllowWriteProtected`: + // a broad pattern such as `*.md` would otherwise silently cover + // protected files like `AGENTS.md`. + const isAllowedWriteFile = isFileMatchedByPatterns({ + filePath: tool.path, + cwd: state.cwd, + patterns: state.allowedWriteFiles, + }) + + const isWriteAllowed = + isAllowedWriteFile || + (state.alwaysAllowWrite === true && + (!isOutsideWorkspace || state.alwaysAllowWriteOutsideWorkspace === true)) + + return isWriteAllowed && (!isProtected || state.alwaysAllowWriteProtected === true) ? { decision: "approve" } : { decision: "ask" } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6f70a19946..ca5f82c916 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2486,9 +2486,11 @@ export class ClineProvider customInstructions, alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadFiles, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, + allowedWriteFiles, alwaysAllowExecute, destructiveCommandGuardEnabled, allowedCommands, @@ -2635,9 +2637,11 @@ export class ClineProvider customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: allowedReadFiles ?? [], alwaysAllowWrite: alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, + allowedWriteFiles: allowedWriteFiles ?? [], alwaysAllowExecute: alwaysAllowExecute ?? false, destructiveCommandGuardEnabled, alwaysAllowMcp: alwaysAllowMcp ?? false, @@ -2870,9 +2874,11 @@ export class ClineProvider apiModelId: stateValues.apiModelId, alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: stateValues.allowedReadFiles ?? [], alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: stateValues.allowedWriteFiles ?? [], alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, @@ -2923,6 +2929,10 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, + // Consumers such as auto-approval resolve workspace-relative paths + // against this, so it must be present here as well as in + // `getStateToPostToWebview`. + cwd: this.cwd, disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e336ac8fac..8773112f88 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1258,6 +1258,76 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(true) }) + test("getState returns the saved allowed read files", async () => { + await provider.contextProxy.setValue("allowedReadFiles", ["notes.md"]) + + const state = await provider.getState() + + expect(state.allowedReadFiles).toEqual(["notes.md"]) + }) + + test("getState defaults allowed read files to an empty list", async () => { + const state = await provider.getState() + + expect(state.allowedReadFiles).toEqual([]) + }) + + test("getStateToPostToWebview returns the saved allowed read files", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("allowedReadFiles", ["notes.md"]) + + const state = await provider.getStateToPostToWebview() + + expect(state.allowedReadFiles).toEqual(["notes.md"]) + }) + + test("getStateToPostToWebview defaults allowed read files to an empty list", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.allowedReadFiles).toEqual([]) + }) + + test("getState returns the saved allowed write files", async () => { + await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"]) + + const state = await provider.getState() + + expect(state.allowedWriteFiles).toEqual(["notes.md"]) + }) + + test("getState defaults allowed write files to an empty list", async () => { + const state = await provider.getState() + + expect(state.allowedWriteFiles).toEqual([]) + }) + + // Auto-approval resolves workspace-relative paths against `cwd`, and reads + // its state from `getState`, so the field has to be present there too. + test("getState returns the workspace path", async () => { + const state = await provider.getState() + + expect(state.cwd).toBe(provider.cwd) + }) + + test("getStateToPostToWebview returns the saved allowed write files", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"]) + + const state = await provider.getStateToPostToWebview() + + expect(state.allowedWriteFiles).toEqual(["notes.md"]) + }) + + test("getStateToPostToWebview defaults allowed write files to an empty list", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.allowedWriteFiles).toEqual([]) + }) + test("getStateToPostToWebview returns the saved destructive command guard setting", async () => { await provider.resolveWebviewView(mockWebviewView) await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index bc92522790..d137dd6424 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1227,6 +1227,86 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { }) }) +// Both allowlists are normalized by the same branch, so both are held to the +// same contract. +describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessageHandler - %s", (key) => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("persists the configured patterns", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { [key]: ["notes.md", "docs/scratch/**"] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith(key, ["notes.md", "docs/scratch/**"]) + }) + + it("drops entries that cannot name a file", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { [key]: ["notes.md", "", " ", 42 as unknown as string] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith(key, ["notes.md"]) + }) + + // Whitespace is significant in gitignore syntax, so it must survive saving. + it("keeps whitespace within a pattern", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { [key]: [" notes.md", "my notes.md", "notes.md\\ "] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith(key, [ + " notes.md", + "my notes.md", + "notes.md\\ ", + ]) + }) + + it("persists an empty list when the setting is cleared", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { [key]: [] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith(key, []) + }) + + // Unlike allowed/denied commands, these settings have no + // workspace-configuration counterpart, so nothing should be written to VS + // Code settings. + it("does not write to the VS Code workspace configuration", async () => { + const update = vi.fn() + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { [key]: ["notes.md"] }, + }) + + expect(update).not.toHaveBeenCalled() + }) +}) + +describe("webviewMessageHandler - allowlists together", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("persists both allowlists from one save", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { allowedReadFiles: ["read.md"], allowedWriteFiles: ["write.md"] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("allowedReadFiles", ["read.md"]) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("allowedWriteFiles", ["write.md"]) + }) +}) + describe("webviewMessageHandler - terminalProfile", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e88fd864cd..3d83ea0265 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -730,6 +730,17 @@ export const webviewMessageHandler = async ( await vscode.workspace .getConfiguration(Package.name) .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "allowedReadFiles" || key === "allowedWriteFiles") { + const patterns = value ?? [] + + // Blank lines, which the textarea editor produces freely, + // name no file and are dropped here. Patterns are + // otherwise not `.trim()`ed: leading whitespace is + // significant in gitignore syntax, and trailing + // whitespace has to be escaped by the user to be kept. + newValue = Array.isArray(patterns) + ? patterns.filter((pattern) => typeof pattern === "string" && pattern.trim().length > 0) + : [] } else if (key === "ttsEnabled") { newValue = value ?? true setTtsEnabled(newValue as boolean) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 29676f2299..14641b6a32 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -12,6 +12,7 @@ import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import { FilePatternAllowlist } from "./FilePatternAllowlist" import { AutoApproveToggle } from "./AutoApproveToggle" import { MaxLimitInputs } from "./MaxLimitInputs" import { useExtensionState } from "@/context/ExtensionStateContext" @@ -21,9 +22,11 @@ import { useAutoApprovalToggles } from "@/hooks/useAutoApprovalToggles" type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowReadOnly?: boolean alwaysAllowReadOnlyOutsideWorkspace?: boolean + allowedReadFiles?: string[] alwaysAllowWrite?: boolean alwaysAllowWriteOutsideWorkspace?: boolean alwaysAllowWriteProtected?: boolean + allowedWriteFiles?: string[] alwaysAllowMcp?: boolean alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean @@ -38,9 +41,11 @@ type AutoApproveSettingsProps = HTMLAttributes & { setCachedStateField: SetCachedStateField< | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" + | "allowedReadFiles" | "alwaysAllowWrite" | "alwaysAllowWriteOutsideWorkspace" | "alwaysAllowWriteProtected" + | "allowedWriteFiles" | "alwaysAllowMcp" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" @@ -58,9 +63,11 @@ type AutoApproveSettingsProps = HTMLAttributes & { export const AutoApproveSettings = ({ alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadFiles, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, + allowedWriteFiles, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -242,6 +249,39 @@ export const AutoApproveSettings = ({ )} + {/* + * Shown regardless of the category toggles above: listing a file + * here is what grants access to it, so it must be reachable + * without first granting that access to everything. + */} +
+
+ +
{t("settings:autoApprove.allowlists.label")}
+
+ + {/* The pattern syntax is shared by every allowlist below. */} +
+ {t("settings:autoApprove.allowlists.description")} +
+ + + + +
+ {alwaysAllowFollowupQuestions && (
diff --git a/webview-ui/src/components/settings/FilePatternAllowlist.tsx b/webview-ui/src/components/settings/FilePatternAllowlist.tsx new file mode 100644 index 0000000000..f164805fb1 --- /dev/null +++ b/webview-ui/src/components/settings/FilePatternAllowlist.tsx @@ -0,0 +1,66 @@ +import { VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" + +import { useAppTranslation } from "@/i18n/TranslationContext" + +import { SetCachedStateField } from "./types" +import { SearchableSetting } from "./SearchableSetting" + +type AllowlistField = "allowedReadFiles" | "allowedWriteFiles" + +interface FilePatternAllowlistProps { + /** Setting the patterns are buffered into. */ + field: AllowlistField + /** Key under `settings:autoApprove.allowlists` holding this list's strings. */ + translationKey: "readFiles" | "writeFiles" + /** Prefix for this list's `data-testid`s, so the two lists stay distinguishable. */ + testIdPrefix: string + patterns?: string[] + setCachedStateField: SetCachedStateField +} + +/** + * An editable list of gitignore-style file patterns granting one kind of + * auto-approved access. + * + * Edited as text, one pattern per line, because the order of the lines is + * meaningful: as in a `.gitignore` file, a later pattern overrides an earlier + * one, which a set of individually-added chips could not express. It also lets a + * list be pasted in or copied out in one go. + * + * Blank lines are kept while editing so that a line can be cleared without the + * cursor jumping; they are dropped when the settings are saved. + * + * The pattern syntax itself is explained once by the enclosing Allowlists + * section, so each list only carries what is specific to it. + */ +export const FilePatternAllowlist = ({ + field, + translationKey, + testIdPrefix, + patterns, + setCachedStateField, +}: FilePatternAllowlistProps) => { + const { t } = useAppTranslation() + + const label = t(`settings:autoApprove.allowlists.${translationKey}.label`) + + return ( + + +
+ {t(`settings:autoApprove.allowlists.${translationKey}.description`)} +
+ setCachedStateField(field, (e.target?.value ?? "").split("\n"))} + placeholder={t(`settings:autoApprove.allowlists.${translationKey}.placeholder`)} + className="w-full" + data-testid={`${testIdPrefix}-input`} + /> +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 952c5615af..b270ff342d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -156,6 +156,7 @@ const SettingsView = forwardRef(({ onDone, t const { alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, + allowedReadFiles, allowedCommands, deniedCommands, allowedMaxRequests, @@ -169,6 +170,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, + allowedWriteFiles, autoCondenseContext, autoCondenseContextPercent, enableCheckpoints, @@ -384,9 +386,11 @@ const SettingsView = forwardRef(({ onDone, t language, alwaysAllowReadOnly: alwaysAllowReadOnly ?? undefined, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? undefined, + allowedReadFiles: allowedReadFiles ?? [], alwaysAllowWrite: alwaysAllowWrite ?? undefined, alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, + allowedWriteFiles: allowedWriteFiles ?? [], alwaysAllowExecute: alwaysAllowExecute ?? undefined, destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, alwaysAllowMcp, @@ -809,9 +813,11 @@ const SettingsView = forwardRef(({ onDone, t { alwaysAllowExecute: true, // reveal the command list section allowedCommands: [] as string[], deniedCommands: [] as string[], + allowedReadFiles: [] as string[], + allowedWriteFiles: [] as string[], setCachedStateField, ...overrides, } @@ -118,6 +120,69 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expectNoImmediateUpdateSettings() }) + // Case 4: the allowlists, edited as one pattern per line so that their order, + // which decides which negation wins, stays under the user's control. + it.each([ + ["write", "allowed-write-file-input", "allowedWriteFiles"], + ["read", "allowed-read-file-input", "allowedReadFiles"], + ])("buffers an edited %s allowlist without persisting before Save", (_label, testId, field) => { + const { setCachedStateField } = renderSettings() + + fireEvent.input(screen.getByTestId(testId), { target: { value: "notes.md\ndocs/scratch/**" } }) + + expect(setCachedStateField).toHaveBeenCalledWith(field, ["notes.md", "docs/scratch/**"]) + expectNoImmediateUpdateSettings() + }) + + it("renders the existing patterns one per line", () => { + renderSettings({ allowedWriteFiles: ["notes.md", "todo.md"] }) + + expect(screen.getByTestId("allowed-write-file-input")).toHaveValue("notes.md\ntodo.md") + }) + + it("keeps a pattern's whitespace, which is significant in gitignore syntax", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.input(screen.getByTestId("allowed-write-file-input"), { target: { value: " notes.md" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedWriteFiles", [" notes.md"]) + }) + + // Blank lines are unavoidable while editing text, and are dropped when the + // settings are saved rather than while typing, so the cursor does not jump. + it("keeps blank lines while editing", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.input(screen.getByTestId("allowed-write-file-input"), { target: { value: "notes.md\n\n" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedWriteFiles", ["notes.md", "", ""]) + }) + + // Each list grants access on its own, so it must be reachable without the + // toggle it is meant to avoid having to enable. + it("shows both allowlists while the Read and Write toggles are off", () => { + renderSettings({ alwaysAllowWrite: false, alwaysAllowReadOnly: false }) + + expect(screen.getByTestId("allowed-write-file-input")).toBeInTheDocument() + expect(screen.getByTestId("allowed-read-file-input")).toBeInTheDocument() + }) + + // The two lists share one component, so they must not share state. + it("keeps the read and write lists independent", () => { + const { setCachedStateField } = renderSettings({ + allowedReadFiles: ["read.md"], + allowedWriteFiles: ["write.md"], + }) + + expect(screen.getByTestId("allowed-read-file-input")).toHaveValue("read.md") + expect(screen.getByTestId("allowed-write-file-input")).toHaveValue("write.md") + + fireEvent.input(screen.getByTestId("allowed-read-file-input"), { target: { value: "read.md\nmore-read.md" } }) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedReadFiles", ["read.md", "more-read.md"]) + expect(setCachedStateField).not.toHaveBeenCalledWith("allowedWriteFiles", expect.anything()) + }) + it("buffers the destructive command guard setting", () => { const { setCachedStateField } = renderSettings() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index b7fe5a9004..377c8eb721 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -204,6 +204,8 @@ const createInitialExtensionState = (): ExtensionState => ({ shouldShowAnnouncement: false, allowedCommands: [], deniedCommands: [], + allowedReadFiles: [], + allowedWriteFiles: [], soundEnabled: false, soundVolume: 0.5, ttsEnabled: false, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fa5cc11d65..51c78e842a 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -308,6 +308,20 @@ "description": "Permetre a Zoo crear i editar fitxers protegits (com .rooignore i fitxers de configuració .roo/) sense requerir aprovació." } }, + "allowlists": { + "label": "Llistes de permisos", + "description": "Concedeix accés a fitxers concrets, sense aprovar automàticament tota una categoria d'accions. Un patró per línia, amb sintaxi inspirada en .gitignore: \"notes.md\" coincideix amb aquest fitxer en qualsevol directori de l'espai de treball, \"docs/scratch/**\" amb tot el que hi ha sota aquell directori i \"*.md\" amb qualsevol fitxer Markdown. Un \"./\" inicial indica l'arrel de l'espai de treball, mentre que un \"/\" inicial indica l'arrel del sistema de fitxers, de manera que \"/tmp/notes.md\" i \"~/notes.md\" arriben fora de l'espai de treball. Posa \"!\" davant d'un patró per excloure el que ha coincidit en una línia anterior; com a .gitignore, guanya l'última línia que coincideix, així que l'ordre compta. Els patrons relatius a l'espai de treball s'ignoren mentre no hi hagi cap carpeta oberta.", + "readFiles": { + "label": "Patrons de la llista de permisos de lectura", + "description": "Fitxers que Zoo pot llegir sense aprovació, encara que \"Llegir\" estigui desactivat a dalt. Els fitxers de la llista de permisos d'escriptura de sota sempre es poden llegir també, així que no cal afegir-los dues vegades. Els llistats de directoris i les cerques sempre segueixen la configuració de \"Llegir\".", + "placeholder": "Un patró per línia, ex. notes.md" + }, + "writeFiles": { + "label": "Patrons de la llista de permisos d'escriptura", + "description": "Fitxers que Zoo pot crear i editar sense aprovació, encara que \"Escriure\" estigui desactivat a dalt. Els fitxers protegits continuen requerint aprovació tret que activis \"Incloure fitxers protegits\" (a la configuració de l'auto-aprovació d'\"Escriure\").", + "placeholder": "Un patró per línia, ex. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Habilitar l'aprovació automàtica d'eines MCP individuals a la vista de Servidors MCP (requereix tant aquesta configuració com la casella \"Permetre sempre\" de l'eina)" diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 2cb83f7893..13163fa991 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -308,6 +308,20 @@ "description": "Zoo erlauben, geschützte Dateien (wie .rooignore und .roo/ Konfigurationsdateien) ohne Genehmigung zu erstellen und zu bearbeiten." } }, + "allowlists": { + "label": "Zulassungslisten", + "description": "Erteile Zugriff auf einzelne Dateien, ohne eine ganze Kategorie von Aktionen automatisch zu genehmigen. Ein Muster pro Zeile, in .gitignore-inspirierter Syntax: \"notes.md\" passt auf diese Datei in jedem Verzeichnis des Workspace, \"docs/scratch/**\" auf alles unter diesem Verzeichnis und \"*.md\" auf jede Markdown-Datei. Ein führendes \"./\" meint das Workspace-Wurzelverzeichnis, ein führendes \"/\" dagegen die Wurzel des Dateisystems, sodass \"/tmp/notes.md\" und \"~/notes.md\" über den Workspace hinausreichen. Ein \"!\" vor einem Muster nimmt aus, was eine frühere Zeile erfasst hat; wie in .gitignore gewinnt die letzte passende Zeile, die Reihenfolge zählt also. Workspace-relative Muster werden ignoriert, solange kein Ordner geöffnet ist.", + "readFiles": { + "label": "Muster der Lese-Zulassungsliste", + "description": "Dateien, die Zoo ohne Genehmigung lesen darf, auch wenn \"Lesen\" oben aus ist. Dateien aus der Schreib-Zulassungsliste unten dürfen immer auch gelesen werden und müssen daher nicht zweimal aufgeführt werden. Verzeichnisauflistungen und Suchen folgen immer der \"Lesen\"-Einstellung.", + "placeholder": "Ein Muster pro Zeile, z.B. notes.md" + }, + "writeFiles": { + "label": "Muster der Schreib-Zulassungsliste", + "description": "Dateien, die Zoo ohne Genehmigung erstellen und bearbeiten darf, auch wenn \"Schreiben\" oben aus ist. Geschützte Dateien brauchen weiterhin eine Genehmigung, sofern \"Geschützte Dateien einbeziehen\" nicht aktiviert ist (in den Einstellungen der \"Schreiben\"-Auto-Genehmigung).", + "placeholder": "Ein Muster pro Zeile, z.B. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Automatische Genehmigung einzelner MCP-Tools in der MCP-Server-Ansicht aktivieren (erfordert sowohl diese Einstellung als auch das 'Immer erlauben'-Kontrollkästchen des Tools)" diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5967792a1..7f2a640f8f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -386,6 +386,20 @@ "description": "Allow Zoo to create and edit protected files (like .rooignore and .roo/ configuration files) without requiring approval." } }, + "allowlists": { + "label": "Allowlists", + "description": "Grant access to individual files, without auto-approving a whole category of actions. One pattern per line, in .gitignore-inspired syntax: \"notes.md\" matches that file in any directory of the workspace, \"docs/scratch/**\" everything below that directory, and \"*.md\" any Markdown file. A leading \"./\" means the workspace root, while a leading \"/\" means the filesystem root, so \"/tmp/notes.md\" and \"~/notes.md\" reach outside the workspace. Prefix a pattern with \"!\" to exclude what an earlier line matched; as in .gitignore, the last matching line wins, so order counts. Workspace-relative patterns are ignored while no folder is open.", + "readFiles": { + "label": "Read allowlist patterns", + "description": "Files Zoo may read without approval, even when \"Read\" above is off. Files in the write allowlist below can always be read too, so they do not need to be listed twice. Directory listings and searches always follow the \"Read\" setting.", + "placeholder": "One pattern per line, e.g. notes.md" + }, + "writeFiles": { + "label": "Write allowlist patterns", + "description": "Files Zoo may create and edit without approval, even when \"Write\" above is off. Protected files still require approval unless \"Include protected files\" is enabled (in the settings of the \"Write\" Auto-Approve).", + "placeholder": "One pattern per line, e.g. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting and the tool's individual \"Always allow\" checkbox)" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 305a8dd5d7..2054e00863 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -308,6 +308,20 @@ "description": "Permitir a Zoo crear y editar archivos protegidos (como .rooignore y archivos de configuración .roo/) sin requerir aprobación." } }, + "allowlists": { + "label": "Listas de permitidos", + "description": "Concede acceso a archivos concretos, sin aprobar automáticamente toda una categoría de acciones. Un patrón por línea, con sintaxis inspirada en .gitignore: \"notes.md\" coincide con ese archivo en cualquier directorio del espacio de trabajo, \"docs/scratch/**\" con todo lo que hay debajo de ese directorio y \"*.md\" con cualquier archivo Markdown. Un \"./\" inicial indica la raíz del espacio de trabajo, mientras que un \"/\" inicial indica la raíz del sistema de archivos, así que \"/tmp/notes.md\" y \"~/notes.md\" llegan fuera del espacio de trabajo. Pon \"!\" delante de un patrón para excluir lo que coincidió en una línea anterior; como en .gitignore, gana la última línea que coincide, así que el orden importa. Los patrones relativos al espacio de trabajo se ignoran mientras no haya ninguna carpeta abierta.", + "readFiles": { + "label": "Patrones de la lista de permitidos de lectura", + "description": "Archivos que Zoo puede leer sin aprobación, incluso si \"Lectura\" está desactivado arriba. Los archivos de la lista de permitidos de escritura de abajo siempre se pueden leer también, así que no hace falta añadirlos dos veces. Los listados de directorios y las búsquedas siempre siguen la configuración de \"Lectura\".", + "placeholder": "Un patrón por línea, ej. notes.md" + }, + "writeFiles": { + "label": "Patrones de la lista de permitidos de escritura", + "description": "Archivos que Zoo puede crear y editar sin aprobación, incluso si \"Escritura\" está desactivado arriba. Los archivos protegidos siguen requiriendo aprobación a menos que actives \"Incluir archivos protegidos\" (en los ajustes de la auto-aprobación de \"Escritura\").", + "placeholder": "Un patrón por línea, ej. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Habilitar la aprobación automática de herramientas MCP individuales en la vista de Servidores MCP (requiere tanto esta configuración como la casilla \"Permitir siempre\" de la herramienta)" diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d5728833dd..9ecbae3fcb 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -309,6 +309,20 @@ "description": "Permettre à Zoo de créer et modifier des fichiers protégés (comme .rooignore et les fichiers de configuration .roo/) sans nécessiter d'approbation." } }, + "allowlists": { + "label": "Listes d'autorisation", + "description": "Accorde l'accès à des fichiers précis, sans approuver automatiquement toute une catégorie d'actions. Un motif par ligne, dans une syntaxe inspirée de .gitignore : \"notes.md\" correspond à ce fichier dans n'importe quel répertoire de l'espace de travail, \"docs/scratch/**\" à tout ce qui se trouve sous ce répertoire et \"*.md\" à n'importe quel fichier Markdown. Un \"./\" initial désigne la racine de l'espace de travail, tandis qu'un \"/\" initial désigne la racine du système de fichiers, si bien que \"/tmp/notes.md\" et \"~/notes.md\" vont au-delà de l'espace de travail. Préfixe un motif par \"!\" pour exclure ce qu'une ligne précédente a fait correspondre ; comme dans .gitignore, la dernière ligne correspondante l'emporte, l'ordre compte donc. Les motifs relatifs à l'espace de travail sont ignorés tant qu'aucun dossier n'est ouvert.", + "readFiles": { + "label": "Motifs de la liste d'autorisation de lecture", + "description": "Fichiers que Zoo peut lire sans approbation, même si \"Lecture\" est désactivé ci-dessus. Les fichiers de la liste d'autorisation d'écriture ci-dessous peuvent toujours être lus aussi, il n'est donc pas nécessaire de les ajouter deux fois. Les listages de répertoires et les recherches suivent toujours le paramètre \"Lecture\".", + "placeholder": "Un motif par ligne, ex. notes.md" + }, + "writeFiles": { + "label": "Motifs de la liste d'autorisation d'écriture", + "description": "Fichiers que Zoo peut créer et modifier sans approbation, même si \"Écriture\" est désactivé ci-dessus. Les fichiers protégés nécessitent toujours une approbation, sauf si \"Inclure les fichiers protégés\" est activé (dans les paramètres de l'approbation automatique \"Écriture\").", + "placeholder": "Un motif par ligne, ex. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Activer l'approbation automatique des outils MCP individuels dans la vue des serveurs MCP (nécessite à la fois ce paramètre et la case à cocher \"Toujours autoriser\" de l'outil)" diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3fce97a378..b906fa4a5c 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -308,6 +308,20 @@ "description": "Zoo को अनुमोदन की आवश्यकता के बिना संरक्षित फाइलें (.rooignore और .roo/ कॉन्फ़िगरेशन फाइलें जैसी) बनाने और संपादित करने की अनुमति दें।" } }, + "allowlists": { + "label": "अनुमति सूचियाँ", + "description": "कार्रवाइयों की पूरी श्रेणी को स्वतः अनुमोदित किए बिना, अलग-अलग फाइलों तक पहुँच दें। प्रति पंक्ति एक पैटर्न, .gitignore से प्रेरित सिंटैक्स में: \"notes.md\" वर्कस्पेस की किसी भी डायरेक्टरी में उस फाइल से मेल खाता है, \"docs/scratch/**\" उस डायरेक्टरी के नीचे की हर चीज़ से, और \"*.md\" किसी भी Markdown फाइल से। शुरुआती \"./\" का अर्थ वर्कस्पेस रूट है, जबकि शुरुआती \"/\" का अर्थ फाइल सिस्टम रूट है, इसलिए \"/tmp/notes.md\" और \"~/notes.md\" वर्कस्पेस से बाहर पहुँचते हैं। किसी पैटर्न के आगे \"!\" लगाने पर वह पिछली पंक्ति के मेल को बाहर कर देता है; .gitignore की तरह, अंतिम मेल खाने वाली पंक्ति जीतती है, इसलिए क्रम मायने रखता है। जब तक कोई फोल्डर खुला न हो, वर्कस्पेस-सापेक्ष पैटर्न अनदेखे रहते हैं।", + "readFiles": { + "label": "पठन अनुमति सूची के पैटर्न", + "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना पढ़ सकता है, तब भी जब ऊपर \"पढ़ें\" बंद हो। नीचे दी गई लेखन अनुमति सूची की फाइलें हमेशा पढ़ी भी जा सकती हैं, इसलिए उन्हें दो बार जोड़ने की आवश्यकता नहीं है। डायरेक्टरी सूचियाँ और खोजें हमेशा \"पढ़ें\" सेटिंग का पालन करती हैं।", + "placeholder": "प्रति पंक्ति एक पैटर्न, उदा. notes.md" + }, + "writeFiles": { + "label": "लेखन अनुमति सूची के पैटर्न", + "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना बना और संपादित कर सकता है, तब भी जब ऊपर \"लिखें\" बंद हो। संरक्षित फाइलों के लिए अनुमोदन तब तक आवश्यक रहता है जब तक \"संरक्षित फाइलें शामिल करें\" सक्षम न हो (\"लिखें\" स्वतः-अनुमोदन की सेटिंग्स में)।", + "placeholder": "प्रति पंक्ति एक पैटर्न, उदा. notes.md" + } + }, "mcp": { "label": "MCP", "description": "MCP सर्वर व्यू में व्यक्तिगत MCP टूल्स के स्वतः अनुमोदन को सक्षम करें (इस सेटिंग और टूल के \"हमेशा अनुमति दें\" चेकबॉक्स दोनों की आवश्यकता है)" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bcdd0ae76d..86e6017624 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -308,6 +308,20 @@ "description": "Izinkan Zoo membuat dan mengedit file yang dilindungi (seperti .rooignore dan file konfigurasi .roo/) tanpa memerlukan persetujuan." } }, + "allowlists": { + "label": "Daftar izin", + "description": "Berikan akses ke file tertentu, tanpa menyetujui otomatis seluruh kategori tindakan. Satu pola per baris, dengan sintaks yang diilhami .gitignore: \"notes.md\" cocok dengan file itu di direktori mana pun dalam workspace, \"docs/scratch/**\" semua yang ada di bawah direktori itu, dan \"*.md\" file Markdown apa pun. Awalan \"./\" berarti root workspace, sedangkan awalan \"/\" berarti root sistem file, sehingga \"/tmp/notes.md\" dan \"~/notes.md\" menjangkau di luar workspace. Beri awalan \"!\" pada pola untuk mengecualikan apa yang dicocokkan baris sebelumnya; seperti pada .gitignore, baris terakhir yang cocok menang, jadi urutan penting. Pola relatif terhadap workspace diabaikan selama tidak ada folder yang terbuka.", + "readFiles": { + "label": "Pola daftar izin baca", + "description": "File yang boleh dibaca Zoo tanpa persetujuan, bahkan ketika \"Baca\" di atas nonaktif. File pada daftar izin tulis di bawah selalu boleh dibaca juga, jadi tidak perlu ditambahkan dua kali. Daftar direktori dan pencarian selalu mengikuti pengaturan \"Baca\".", + "placeholder": "Satu pola per baris, misalnya notes.md" + }, + "writeFiles": { + "label": "Pola daftar izin tulis", + "description": "File yang boleh dibuat dan diedit Zoo tanpa persetujuan, bahkan ketika \"Tulis\" di atas nonaktif. File yang dilindungi tetap memerlukan persetujuan kecuali \"Sertakan file yang dilindungi\" diaktifkan (di pengaturan persetujuan otomatis \"Tulis\").", + "placeholder": "Satu pola per baris, misalnya notes.md" + } + }, "mcp": { "label": "MCP", "description": "Aktifkan auto-approval tool MCP individual di tampilan Server MCP (memerlukan pengaturan ini dan checkbox \"Selalu izinkan\" tool tersebut)" diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b22fb4c652..f9b6b128a2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -308,6 +308,20 @@ "description": "Permetti a Zoo di creare e modificare file protetti (come .rooignore e file di configurazione .roo/) senza richiedere approvazione." } }, + "allowlists": { + "label": "Liste di autorizzazione", + "description": "Concedi l'accesso a singoli file, senza approvare automaticamente un'intera categoria di azioni. Un pattern per riga, con sintassi ispirata a .gitignore: \"notes.md\" corrisponde a quel file in qualsiasi directory del workspace, \"docs/scratch/**\" a tutto ciò che si trova sotto quella directory e \"*.md\" a qualsiasi file Markdown. Un \"./\" iniziale indica la radice del workspace, mentre un \"/\" iniziale indica la radice del filesystem, così \"/tmp/notes.md\" e \"~/notes.md\" arrivano fuori dal workspace. Metti \"!\" davanti a un pattern per escludere ciò che una riga precedente ha già trovato; come in .gitignore, vince l'ultima riga corrispondente, quindi l'ordine conta. I pattern relativi al workspace vengono ignorati finché non è aperta nessuna cartella.", + "readFiles": { + "label": "Pattern della lista di autorizzazione in lettura", + "description": "File che Zoo può leggere senza approvazione, anche quando \"Leggi\" sopra è disattivato. I file della lista di autorizzazione in scrittura qui sotto possono sempre essere letti, quindi non serve aggiungerli due volte. Gli elenchi di directory e le ricerche seguono sempre l'impostazione \"Leggi\".", + "placeholder": "Un pattern per riga, es. notes.md" + }, + "writeFiles": { + "label": "Pattern della lista di autorizzazione in scrittura", + "description": "File che Zoo può creare e modificare senza approvazione, anche quando \"Scrivi\" sopra è disattivato. I file protetti richiedono ancora l'approvazione, a meno che \"Includi file protetti\" non sia abilitato (nelle impostazioni dell'auto-approvazione \"Scrivi\").", + "placeholder": "Un pattern per riga, es. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Abilita l'approvazione automatica dei singoli strumenti MCP nella vista Server MCP (richiede sia questa impostazione che la casella \"Consenti sempre\" dello strumento)" diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bbdc5c8e8a..49a30c858d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -308,6 +308,20 @@ "description": "Zooが保護されたファイル(.rooignoreや.roo/設定ファイルなど)を承認なしで作成・編集することを許可します。" } }, + "allowlists": { + "label": "許可リスト", + "description": "操作のカテゴリ全体を自動承認せずに、個々のファイルへのアクセスを許可します。1行に1つのパターンを、.gitignore を参考にした構文で記述します。\"notes.md\" はワークスペース内の任意のディレクトリにあるそのファイルに一致し、\"docs/scratch/**\" はそのディレクトリ配下のすべて、\"*.md\" は任意の Markdown ファイルに一致します。先頭の \"./\" はワークスペースのルートを意味し、先頭の \"/\" はファイルシステムのルートを意味するため、\"/tmp/notes.md\" や \"~/notes.md\" はワークスペースの外に届きます。パターンの先頭に \"!\" を付けると、前の行で一致したものを除外します。.gitignore と同様に最後に一致した行が優先されるため、順序が重要です。フォルダーを開いていない間は、ワークスペース相対のパターンは無視されます。", + "readFiles": { + "label": "読み取り許可リストのパターン", + "description": "上の「読み取り」がオフでも、Zooが承認なしで読み取れるファイルです。下の書き込み許可リストのファイルは常に読み取りもできるため、二重に追加する必要はありません。ディレクトリの一覧表示と検索は常に「読み取り」設定に従います。", + "placeholder": "1行に1つのパターン(例:notes.md)" + }, + "writeFiles": { + "label": "書き込み許可リストのパターン", + "description": "上の「書き込み」がオフでも、Zooが承認なしで作成・編集できるファイルです。保護されたファイルは、「保護されたファイルを含める」を有効にしない限り(「書き込み」自動承認の設定内)、引き続き承認が必要です。", + "placeholder": "1行に1つのパターン(例:notes.md)" + } + }, "mcp": { "label": "MCP", "description": "MCPサーバービューで個々のMCPツールの自動承認を有効にします(この設定とツールの「常に許可」チェックボックスの両方が必要)" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c2062a5335..83bc49e1e1 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -308,6 +308,20 @@ "description": "Zoo가 보호된 파일(.rooignore 및 .roo/ 구성 파일 등)을 승인 없이 생성하고 편집할 수 있도록 허용합니다." } }, + "allowlists": { + "label": "허용 목록", + "description": "작업 범주 전체를 자동 승인하지 않고 개별 파일에 대한 접근을 허용합니다. 한 줄에 하나의 패턴을 .gitignore에서 착안한 구문으로 입력합니다. \"notes.md\"는 워크스페이스의 모든 디렉터리에서 해당 파일과 일치하고, \"docs/scratch/**\"는 해당 디렉터리 아래의 모든 것과, \"*.md\"는 모든 Markdown 파일과 일치합니다. 맨 앞의 \"./\"는 워크스페이스 루트를 뜻하고 맨 앞의 \"/\"는 파일 시스템 루트를 뜻하므로, \"/tmp/notes.md\"와 \"~/notes.md\"는 워크스페이스 밖까지 미칩니다. 패턴 앞에 \"!\"를 붙이면 앞선 줄이 일치시킨 것을 제외합니다. .gitignore와 마찬가지로 마지막에 일치한 줄이 우선하므로 순서가 중요합니다. 열린 폴더가 없는 동안에는 워크스페이스 기준 패턴이 무시됩니다.", + "readFiles": { + "label": "읽기 허용 목록 패턴", + "description": "위의 \"읽기\"가 꺼져 있어도 Zoo가 승인 없이 읽을 수 있는 파일입니다. 아래 쓰기 허용 목록의 파일은 항상 읽을 수도 있으므로 두 번 추가할 필요가 없습니다. 디렉터리 목록과 검색은 항상 \"읽기\" 설정을 따릅니다.", + "placeholder": "한 줄에 하나의 패턴(예: notes.md)" + }, + "writeFiles": { + "label": "쓰기 허용 목록 패턴", + "description": "위의 \"쓰기\"가 꺼져 있어도 Zoo가 승인 없이 생성하고 편집할 수 있는 파일입니다. 보호된 파일은 (\"쓰기\" 자동 승인 설정에서) \"보호된 파일 포함\"을 켜지 않는 한 계속 승인이 필요합니다.", + "placeholder": "한 줄에 하나의 패턴(예: notes.md)" + } + }, "mcp": { "label": "MCP", "description": "MCP 서버 보기에서 개별 MCP 도구의 자동 승인 활성화(이 설정과 도구의 \"항상 허용\" 체크박스 모두 필요)" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index cf148f5617..83fd51ef08 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -308,6 +308,20 @@ "description": "Sta Zoo toe om beschermde bestanden (zoals .rooignore en .roo/ configuratiebestanden) aan te maken en te bewerken zonder goedkeuring." } }, + "allowlists": { + "label": "Toelatingslijsten", + "description": "Geef toegang tot afzonderlijke bestanden, zonder een hele categorie acties automatisch goed te keuren. Eén patroon per regel, in door .gitignore geïnspireerde syntaxis: \"notes.md\" komt overeen met dat bestand in elke map van de workspace, \"docs/scratch/**\" met alles onder die map en \"*.md\" met elk Markdown-bestand. Een \"./\" aan het begin betekent de root van de workspace, terwijl een \"/\" aan het begin de root van het bestandssysteem betekent, zodat \"/tmp/notes.md\" en \"~/notes.md\" tot buiten de workspace reiken. Zet \"!\" voor een patroon om uit te sluiten wat een eerdere regel heeft gevonden; net als in .gitignore wint de laatste regel die overeenkomt, dus de volgorde telt. Patronen die relatief zijn aan de workspace worden genegeerd zolang er geen map is geopend.", + "readFiles": { + "label": "Patronen van de lees-toelatingslijst", + "description": "Bestanden die Zoo zonder goedkeuring mag lezen, ook als \"Lezen\" hierboven uit staat. Bestanden uit de schrijf-toelatingslijst hieronder mogen altijd ook gelezen worden, dus die hoef je niet twee keer toe te voegen. Mapoverzichten en zoekopdrachten volgen altijd de \"Lezen\"-instelling.", + "placeholder": "Eén patroon per regel, bijv. notes.md" + }, + "writeFiles": { + "label": "Patronen van de schrijf-toelatingslijst", + "description": "Bestanden die Zoo zonder goedkeuring mag aanmaken en bewerken, ook als \"Schrijven\" hierboven uit staat. Beschermde bestanden vereisen nog steeds goedkeuring, tenzij \"Inclusief beschermde bestanden\" is ingeschakeld (in de instellingen van de \"Schrijven\"-autogoedkeuring).", + "placeholder": "Eén patroon per regel, bijv. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Automatische goedkeuring van individuele MCP-tools in het MCP-serversoverzicht inschakelen (vereist zowel deze instelling als het selectievakje 'Altijd toestaan' bij de tool)" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ace780f529..1679eb88b8 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -308,6 +308,20 @@ "description": "Pozwól Zoo na tworzenie i edycję plików chronionych (takich jak .rooignore i pliki konfiguracyjne .roo/) bez konieczności zatwierdzania." } }, + "allowlists": { + "label": "Listy dozwolonych", + "description": "Przyznaj dostęp do pojedynczych plików, bez automatycznego zatwierdzania całej kategorii działań. Jeden wzorzec na wiersz, w składni inspirowanej .gitignore: \"notes.md\" pasuje do tego pliku w dowolnym katalogu obszaru roboczego, \"docs/scratch/**\" do wszystkiego poniżej tego katalogu, a \"*.md\" do dowolnego pliku Markdown. Początkowe \"./\" oznacza katalog główny obszaru roboczego, natomiast początkowe \"/\" oznacza katalog główny systemu plików, więc \"/tmp/notes.md\" i \"~/notes.md\" sięgają poza obszar roboczy. Poprzedź wzorzec znakiem \"!\", aby wykluczyć to, co dopasował wcześniejszy wiersz; tak jak w .gitignore wygrywa ostatni pasujący wiersz, więc kolejność ma znaczenie. Wzorce względne wobec obszaru roboczego są ignorowane, dopóki nie jest otwarty żaden folder.", + "readFiles": { + "label": "Wzorce listy dozwolonych do odczytu", + "description": "Pliki, które Zoo może czytać bez zatwierdzania, nawet gdy \"Odczyt\" powyżej jest wyłączony. Pliki z listy dozwolonych do zapisu poniżej zawsze można także czytać, więc nie trzeba ich dodawać dwukrotnie. Listowanie katalogów i wyszukiwanie zawsze podlegają ustawieniu \"Odczyt\".", + "placeholder": "Jeden wzorzec na wiersz, np. notes.md" + }, + "writeFiles": { + "label": "Wzorce listy dozwolonych do zapisu", + "description": "Pliki, które Zoo może tworzyć i edytować bez zatwierdzania, nawet gdy \"Zapis\" powyżej jest wyłączony. Pliki chronione nadal wymagają zatwierdzenia, chyba że włączysz \"Uwzględnij pliki chronione\" (w ustawieniach automatycznego zatwierdzania \"Zapis\").", + "placeholder": "Jeden wzorzec na wiersz, np. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Włącz automatyczne zatwierdzanie poszczególnych narzędzi MCP w widoku Serwerów MCP (wymaga zarówno tego ustawienia, jak i pola wyboru \"Zawsze zezwalaj\" narzędzia)" diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 446aa8ac02..b6cab5c825 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -308,6 +308,20 @@ "description": "Permitir que o Zoo crie e edite arquivos protegidos (como .rooignore e arquivos de configuração .roo/) sem exigir aprovação." } }, + "allowlists": { + "label": "Listas de permissão", + "description": "Conceda acesso a arquivos específicos, sem aprovar automaticamente toda uma categoria de ações. Um padrão por linha, em sintaxe inspirada no .gitignore: \"notes.md\" corresponde a esse arquivo em qualquer diretório do espaço de trabalho, \"docs/scratch/**\" a tudo abaixo desse diretório e \"*.md\" a qualquer arquivo Markdown. Um \"./\" inicial indica a raiz do espaço de trabalho, enquanto um \"/\" inicial indica a raiz do sistema de arquivos, de modo que \"/tmp/notes.md\" e \"~/notes.md\" alcançam fora do espaço de trabalho. Prefixe um padrão com \"!\" para excluir o que uma linha anterior correspondeu; como no .gitignore, a última linha correspondente vence, então a ordem importa. Padrões relativos ao espaço de trabalho são ignorados enquanto nenhuma pasta estiver aberta.", + "readFiles": { + "label": "Padrões da lista de permissão de leitura", + "description": "Arquivos que o Zoo pode ler sem aprovação, mesmo quando \"Leitura\" acima está desativado. Os arquivos da lista de permissão de escrita abaixo sempre podem ser lidos também, então não precisam ser adicionados duas vezes. Listagens de diretórios e buscas sempre seguem a configuração \"Leitura\".", + "placeholder": "Um padrão por linha, ex. notes.md" + }, + "writeFiles": { + "label": "Padrões da lista de permissão de escrita", + "description": "Arquivos que o Zoo pode criar e editar sem aprovação, mesmo quando \"Escrita\" acima está desativado. Arquivos protegidos continuam exigindo aprovação, a menos que \"Incluir arquivos protegidos\" esteja ativado (nas configurações da aprovação automática de \"Escrita\").", + "placeholder": "Um padrão por linha, ex. notes.md" + } + }, "mcp": { "label": "MCP", "description": "Ativar aprovação automática de ferramentas MCP individuais na visualização de Servidores MCP (requer tanto esta configuração quanto a caixa de seleção \"Permitir sempre\" da ferramenta)" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f2719ad06e..b04c237ed1 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -308,6 +308,20 @@ "description": "Разрешить Zoo создавать и редактировать защищенные файлы (такие как .rooignore и файлы конфигурации .roo/) без необходимости одобрения." } }, + "allowlists": { + "label": "Списки разрешений", + "description": "Предоставь доступ к отдельным файлам, не одобряя автоматически целую категорию действий. По одному шаблону в строке, в синтаксисе по мотивам .gitignore: \"notes.md\" совпадает с этим файлом в любом каталоге рабочей области, \"docs/scratch/**\" — со всем, что находится внутри этого каталога, а \"*.md\" — с любым файлом Markdown. Ведущее \"./\" означает корень рабочей области, а ведущее \"/\" — корень файловой системы, поэтому \"/tmp/notes.md\" и \"~/notes.md\" выходят за пределы рабочей области. Поставь \"!\" перед шаблоном, чтобы исключить то, что совпало в предыдущей строке; как и в .gitignore, побеждает последняя совпавшая строка, поэтому порядок важен. Пока не открыта ни одна папка, шаблоны относительно рабочей области игнорируются.", + "readFiles": { + "label": "Шаблоны списка разрешений на чтение", + "description": "Файлы, которые Zoo может читать без одобрения, даже когда \"Чтение\" выше выключено. Файлы из списка разрешений на запись ниже всегда можно и читать, поэтому добавлять их дважды не нужно. Просмотр каталогов и поиск всегда подчиняются настройке \"Чтение\".", + "placeholder": "По одному шаблону в строке, например notes.md" + }, + "writeFiles": { + "label": "Шаблоны списка разрешений на запись", + "description": "Файлы, которые Zoo может создавать и редактировать без одобрения, даже когда \"Запись\" выше выключена. Защищенные файлы по-прежнему требуют одобрения, если не включено \"Включить защищенные файлы\" (в настройках автоодобрения \"Запись\").", + "placeholder": "По одному шаблону в строке, например notes.md" + } + }, "mcp": { "label": "MCP", "description": "Включить автоодобрение отдельных инструментов MCP в представлении MCP Servers (требуется включить как этот параметр, так и индивидуальный чекбокс инструмента \"Всегда разрешать\")" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 08374b6d20..e29f57345d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -308,6 +308,20 @@ "description": "Zoo'nun korumalı dosyaları (.rooignore ve .roo/ yapılandırma dosyaları gibi) onay gerektirmeden oluşturmasına ve düzenlemesine izin ver." } }, + "allowlists": { + "label": "İzin listeleri", + "description": "Bütün bir işlem kategorisini otomatik onaylamadan, tek tek dosyalara erişim ver. Her satıra bir desen, .gitignore'dan esinlenen sözdizimiyle: \"notes.md\" çalışma alanındaki herhangi bir dizinde o dosyayla eşleşir, \"docs/scratch/**\" o dizinin altındaki her şeyle ve \"*.md\" herhangi bir Markdown dosyasıyla eşleşir. Baştaki \"./\" çalışma alanı kökünü, baştaki \"/\" ise dosya sisteminin kökünü belirtir; böylece \"/tmp/notes.md\" ve \"~/notes.md\" çalışma alanının dışına uzanır. Bir desenin önüne \"!\" koyarak önceki bir satırın eşleştirdiğini dışarıda bırak; .gitignore'da olduğu gibi eşleşen son satır kazanır, yani sıra önemlidir. Hiçbir klasör açık değilken çalışma alanına göreli desenler yok sayılır.", + "readFiles": { + "label": "Okuma izin listesi desenleri", + "description": "Yukarıdaki \"Okuma\" kapalı olsa bile Zoo'nun onay almadan okuyabileceği dosyalar. Aşağıdaki yazma izin listesindeki dosyalar her zaman okunabilir de, bu yüzden iki kez eklenmeleri gerekmez. Dizin listelemeleri ve aramalar her zaman \"Okuma\" ayarını izler.", + "placeholder": "Her satıra bir desen, örn. notes.md" + }, + "writeFiles": { + "label": "Yazma izin listesi desenleri", + "description": "Yukarıdaki \"Yazma\" kapalı olsa bile Zoo'nun onay almadan oluşturup düzenleyebileceği dosyalar. \"Korumalı dosyaları dahil et\" etkinleştirilmediği sürece (\"Yazma\" otomatik onayının ayarlarında) korumalı dosyalar yine onay gerektirir.", + "placeholder": "Her satıra bir desen, örn. notes.md" + } + }, "mcp": { "label": "MCP", "description": "MCP Sunucuları görünümünde bireysel MCP araçlarının otomatik onayını etkinleştir (hem bu ayar hem de aracın \"Her zaman izin ver\" onay kutusu gerekir)" diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a8611ca687..bb447a928c 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -308,6 +308,20 @@ "description": "Cho phép Zoo tạo và chỉnh sửa các tệp được bảo vệ (như .rooignore và các tệp cấu hình .roo/) mà không yêu cầu phê duyệt." } }, + "allowlists": { + "label": "Danh sách cho phép", + "description": "Cấp quyền truy cập vào từng tệp cụ thể, mà không tự động phê duyệt cả một nhóm hành động. Mỗi dòng một mẫu, theo cú pháp lấy cảm hứng từ .gitignore: \"notes.md\" khớp với tệp đó trong bất kỳ thư mục nào của workspace, \"docs/scratch/**\" khớp với mọi thứ bên dưới thư mục đó, và \"*.md\" khớp với mọi tệp Markdown. Dấu \"./\" ở đầu chỉ gốc workspace, còn dấu \"/\" ở đầu chỉ gốc hệ thống tệp, nên \"/tmp/notes.md\" và \"~/notes.md\" vươn ra ngoài workspace. Thêm \"!\" trước một mẫu để loại trừ những gì dòng trước đã khớp; như trong .gitignore, dòng khớp cuối cùng thắng, nên thứ tự có ý nghĩa. Khi chưa mở thư mục nào, các mẫu tương đối với workspace sẽ bị bỏ qua.", + "readFiles": { + "label": "Mẫu danh sách cho phép đọc", + "description": "Các tệp mà Zoo có thể đọc không cần phê duyệt, kể cả khi \"Đọc\" ở trên đang tắt. Các tệp trong danh sách cho phép ghi bên dưới cũng luôn đọc được, nên không cần thêm hai lần. Việc liệt kê thư mục và tìm kiếm luôn tuân theo cài đặt \"Đọc\".", + "placeholder": "Mỗi dòng một mẫu, ví dụ notes.md" + }, + "writeFiles": { + "label": "Mẫu danh sách cho phép ghi", + "description": "Các tệp mà Zoo có thể tạo và chỉnh sửa không cần phê duyệt, kể cả khi \"Ghi\" ở trên đang tắt. Các tệp được bảo vệ vẫn cần phê duyệt trừ khi bật \"Bao gồm các tệp được bảo vệ\" (trong cài đặt tự động phê duyệt \"Ghi\").", + "placeholder": "Mỗi dòng một mẫu, ví dụ notes.md" + } + }, "mcp": { "label": "MCP", "description": "Bật tự động phê duyệt các công cụ MCP riêng lẻ trong chế độ xem Máy chủ MCP (yêu cầu cả cài đặt này và hộp kiểm \"Luôn cho phép\" của công cụ)" diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9f3913e872..16df445c42 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -308,6 +308,20 @@ "description": "允许 Zoo 创建和编辑受保护的文件(如 .rooignore 和 .roo/ 配置文件),无需批准。" } }, + "allowlists": { + "label": "允许列表", + "description": "为单个文件授予访问权限,而不自动批准整类操作。每行一个模式,采用受 .gitignore 启发的语法:\"notes.md\" 匹配工作区中任意目录下的该文件,\"docs/scratch/**\" 匹配该目录下的所有内容,\"*.md\" 匹配任意 Markdown 文件。开头的 \"./\" 表示工作区根目录,而开头的 \"/\" 表示文件系统根目录,因此 \"/tmp/notes.md\" 和 \"~/notes.md\" 可以指向工作区之外。在模式前加 \"!\" 可排除前面某行已匹配的内容;与 .gitignore 一样,最后匹配的行生效,因此顺序很重要。未打开任何文件夹时,相对于工作区的模式将被忽略。", + "readFiles": { + "label": "读取允许列表模式", + "description": "即使上面的\"读取\"已关闭,Zoo 也可以无需批准即读取的文件。下面写入允许列表中的文件始终也可读取,因此无需重复添加。目录列表和搜索始终遵循\"读取\"设置。", + "placeholder": "每行一个模式,例如 notes.md" + }, + "writeFiles": { + "label": "写入允许列表模式", + "description": "即使上面的\"写入\"已关闭,Zoo 也可以无需批准即创建和编辑的文件。除非启用\"包含受保护的文件\"(在\"写入\"自动批准的设置中),受保护的文件仍需批准。", + "placeholder": "每行一个模式,例如 notes.md" + } + }, "mcp": { "label": "MCP", "description": "允许自动调用MCP服务而无需批准" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 80d0d18735..820b3ed43c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -333,6 +333,20 @@ "description": "允許 Zoo 建立與編輯受保護的檔案(如 .rooignore 和 .roo/ 設定檔)且無需核准。" } }, + "allowlists": { + "label": "允許清單", + "description": "為個別檔案授予存取權,而不自動核准整類操作。每行一個模式,採用受 .gitignore 啟發的語法:「notes.md」比對工作區中任何目錄下的該檔案,「docs/scratch/**」比對該目錄下的所有內容,「*.md」比對任何 Markdown 檔案。開頭的「./」表示工作區根目錄,而開頭的「/」表示檔案系統根目錄,因此「/tmp/notes.md」和「~/notes.md」可指向工作區之外。在模式前加上「!」可排除前面某一行已比對到的內容;與 .gitignore 相同,最後比對到的一行生效,因此順序很重要。未開啟任何資料夾時,相對於工作區的模式會被忽略。", + "readFiles": { + "label": "讀取允許清單模式", + "description": "即使上方的「讀取」已關閉,Zoo 仍可無需核准即讀取的檔案。下方寫入允許清單中的檔案一律也可讀取,因此不需重複加入。目錄列表與搜尋一律遵循「讀取」設定。", + "placeholder": "每行一個模式,例如 notes.md" + }, + "writeFiles": { + "label": "寫入允許清單模式", + "description": "即使上方的「寫入」已關閉,Zoo 仍可無需核准即建立與編輯的檔案。除非啟用「包含受保護的檔案」(在「寫入」自動核准的設定中),受保護的檔案仍需核准。", + "placeholder": "每行一個模式,例如 notes.md" + } + }, "mcp": { "label": "MCP", "description": "啟用 MCP 伺服器檢視中個別 MCP 工具的自動核准(需同時啟用此設定與該工具的「始終允許」核取方塊)" diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 22a47745bc..847c401f2c 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -29,6 +29,8 @@ export const makeExtensionState = (overrides: Partial = {}): Par shouldShowAnnouncement: false, allowedCommands: [], deniedCommands: [], + allowedReadFiles: [], + allowedWriteFiles: [], alwaysAllowExecute: false, cloudIsAuthenticated: false, telemetrySetting: "enabled", From 3fa4654db94ccf839cc02d97181ba59d5e1f1c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Wed, 19 Aug 2026 02:49:07 +0200 Subject: [PATCH 2/4] FIXUP: Claude review --- .../__tests__/allowedReadFiles.spec.ts | 104 ++++++++--- .../__tests__/allowedWriteFiles.spec.ts | 94 +++++++--- .../__tests__/filePatterns.spec.ts | 86 ++++++++- src/core/auto-approval/__tests__/fixtures.ts | 40 ++++ .../auto-approval/__tests__/negation.spec.ts | 36 +--- .../__tests__/noWorkspaceRoot.spec.ts | 22 ++- src/core/auto-approval/filePatterns.ts | 174 +++++++++++++----- src/core/auto-approval/index.ts | 104 +++++++++-- src/core/task/Task.ts | 6 +- .../task/__tests__/ask-allowlist-cwd.spec.ts | 101 ++++++++++ src/core/webview/ClineProvider.ts | 4 - .../webview/__tests__/ClineProvider.spec.ts | 8 - .../settings/AutoApproveSettings.tsx | 2 + .../settings/FilePatternAllowlist.tsx | 5 +- webview-ui/src/i18n/locales/ca/settings.json | 6 +- webview-ui/src/i18n/locales/de/settings.json | 6 +- webview-ui/src/i18n/locales/en/settings.json | 6 +- webview-ui/src/i18n/locales/es/settings.json | 6 +- webview-ui/src/i18n/locales/fr/settings.json | 6 +- webview-ui/src/i18n/locales/hi/settings.json | 6 +- webview-ui/src/i18n/locales/id/settings.json | 6 +- webview-ui/src/i18n/locales/it/settings.json | 6 +- webview-ui/src/i18n/locales/ja/settings.json | 6 +- webview-ui/src/i18n/locales/ko/settings.json | 6 +- webview-ui/src/i18n/locales/nl/settings.json | 6 +- webview-ui/src/i18n/locales/pl/settings.json | 6 +- .../src/i18n/locales/pt-BR/settings.json | 6 +- webview-ui/src/i18n/locales/ru/settings.json | 6 +- webview-ui/src/i18n/locales/tr/settings.json | 6 +- webview-ui/src/i18n/locales/vi/settings.json | 6 +- .../src/i18n/locales/zh-CN/settings.json | 6 +- .../src/i18n/locales/zh-TW/settings.json | 6 +- 32 files changed, 660 insertions(+), 234 deletions(-) create mode 100644 src/core/auto-approval/__tests__/fixtures.ts create mode 100644 src/core/task/__tests__/ask-allowlist-cwd.spec.ts diff --git a/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts b/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts index a998ffef43..de868136f0 100644 --- a/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts +++ b/src/core/auto-approval/__tests__/allowedReadFiles.spec.ts @@ -1,46 +1,26 @@ // npx vitest run core/auto-approval/__tests__/allowedReadFiles.spec.ts -import type { ExtensionState } from "@roo-code/types" - -import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." - -const CWD = "/path/to/repo" - -type State = Pick - -const baseState: State = { - autoApprovalEnabled: true, - alwaysAllowReadOnly: false, - alwaysAllowReadOnlyOutsideWorkspace: false, - allowedReadFiles: [], - alwaysAllowWrite: false, - alwaysAllowWriteOutsideWorkspace: false, - alwaysAllowWriteProtected: false, - allowedWriteFiles: [], - cwd: CWD, - alwaysAllowMcp: false, - alwaysAllowModeSwitch: false, - alwaysAllowSubtasks: false, - alwaysAllowExecute: false, - alwaysAllowFollowupQuestions: false, - destructiveCommandGuardEnabled: false, - allowedCommands: [], - deniedCommands: [], -} +import { checkAutoApproval } from ".." +import { CWD, baseState, type State } from "./fixtures" const askToRead = async ({ state, tool = "readFile", + cwd = CWD, ...payload }: { state: Partial tool?: string + cwd?: string path?: string batchFiles?: Array<{ path: string }> + batchDirs?: Array<{ path: string }> + additionalFileCount?: number isOutsideWorkspace?: boolean }) => checkAutoApproval({ state: { ...baseState, ...state }, + cwd, ask: "tool", text: JSON.stringify({ tool, ...payload }), }) @@ -69,9 +49,9 @@ describe("allowedReadFiles auto-approval", () => { }) it("approves a file covered by a glob", async () => { - expect(await askToRead({ path: "docs/scratch/a.md", state: { allowedReadFiles: ["docs/scratch/**"] } })).toEqual( - { decision: "approve" }, - ) + expect( + await askToRead({ path: "docs/scratch/a.md", state: { allowedReadFiles: ["docs/scratch/**"] } }), + ).toEqual({ decision: "approve" }) }) // Write permission implies read permission. @@ -85,12 +65,27 @@ describe("allowedReadFiles auto-approval", () => { expect( await checkAutoApproval({ state: { ...baseState, allowedReadFiles: ["notes.md"] }, + cwd: CWD, ask: "tool", text: JSON.stringify({ tool: "newFileCreated", path: "notes.md" }), }), ).toEqual({ decision: "ask" }) }) + // The patterns and the path have to be resolved against the same root, which is + // the one the task runs in rather than the one the window currently shows. + it("resolves the patterns against the cwd it is given", async () => { + const state = { allowedReadFiles: ["/path/to/repo/notes.md"] } + + expect(await askToRead({ path: "notes.md", cwd: "/path/to/repo", state })).toEqual({ + decision: "approve", + }) + + expect(await askToRead({ path: "notes.md", cwd: "/path/to/other-repo", state })).toEqual({ + decision: "ask", + }) + }) + describe("batch reads", () => { it("approves when every file in the batch is listed", async () => { expect( @@ -120,6 +115,42 @@ describe("allowedReadFiles auto-approval", () => { }), ).toEqual({ decision: "approve" }) }) + + it("does not let a listed batch carry an unlisted path", async () => { + expect( + await askToRead({ + path: "src/index.ts", + batchFiles: [{ path: "notes.md" }], + state: { allowedReadFiles: ["notes.md"] }, + }), + ).toEqual({ decision: "ask" }) + }) + + it("asks when the read names no file at all", async () => { + expect(await askToRead({ state: { allowedReadFiles: ["*", "**", "notes.md"] } })).toEqual({ + decision: "ask", + }) + }) + + // `additionalFileCount` reports files the message does not name, and the + // approval would cover them as well, so no pattern can vouch for them. + it("asks when the read carries files it does not name", async () => { + expect( + await askToRead({ + path: "notes.md", + additionalFileCount: 2, + state: { allowedReadFiles: ["notes.md"] }, + }), + ).toEqual({ decision: "ask" }) + + expect( + await askToRead({ + batchFiles: [{ path: "notes.md" }], + additionalFileCount: 1, + state: { allowedReadFiles: ["*.md"] }, + }), + ).toEqual({ decision: "ask" }) + }) }) // The allowlist names files, but these tools act on directories and report @@ -143,6 +174,19 @@ describe("allowedReadFiles auto-approval", () => { decision: "approve", }) }) + + // Listing tools report their directories in `batchDirs`. They are turned away + // by the tool name, but assert it here as well so the two reasons cannot both + // disappear unnoticed. + it("asks for a directory listing batch even when the directories are listed", async () => { + expect( + await askToRead({ + tool: "listFilesTopLevel", + batchDirs: [{ path: "docs" }, { path: "src" }], + state: { allowedReadFiles: ["docs", "src", "**"] }, + }), + ).toEqual({ decision: "ask" }) + }) }) it("asks when auto-approval is disabled entirely", async () => { diff --git a/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts b/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts index ba06086304..8013a3b715 100644 --- a/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts +++ b/src/core/auto-approval/__tests__/allowedWriteFiles.spec.ts @@ -1,31 +1,7 @@ // npx vitest run core/auto-approval/__tests__/allowedWriteFiles.spec.ts -import type { ExtensionState } from "@roo-code/types" - -import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." - -const CWD = "/path/to/repo" - -type State = Pick - -const baseState: State = { - autoApprovalEnabled: true, - alwaysAllowReadOnly: false, - alwaysAllowReadOnlyOutsideWorkspace: false, - alwaysAllowWrite: false, - alwaysAllowWriteOutsideWorkspace: false, - alwaysAllowWriteProtected: false, - allowedWriteFiles: [], - cwd: CWD, - alwaysAllowMcp: false, - alwaysAllowModeSwitch: false, - alwaysAllowSubtasks: false, - alwaysAllowExecute: false, - alwaysAllowFollowupQuestions: false, - destructiveCommandGuardEnabled: false, - allowedCommands: [], - deniedCommands: [], -} +import { checkAutoApproval } from ".." +import { CWD, baseState, type State } from "./fixtures" const askToWrite = async ({ path, @@ -33,17 +9,22 @@ const askToWrite = async ({ tool = "newFileCreated", isProtected, isOutsideWorkspace, + cwd = CWD, + batchDiffs, }: { - path: string + path?: string state: Partial tool?: string isProtected?: boolean isOutsideWorkspace?: boolean + cwd?: string + batchDiffs?: { path: string }[] }) => checkAutoApproval({ state: { ...baseState, ...state }, + cwd, ask: "tool", - text: JSON.stringify({ tool, path, isOutsideWorkspace, isProtected }), + text: JSON.stringify({ tool, path, isOutsideWorkspace, isProtected, batchDiffs }), isProtected, }) @@ -103,6 +84,7 @@ describe("allowedWriteFiles auto-approval", () => { expect( await checkAutoApproval({ state: { ...baseState, allowedWriteFiles: ["notes.md"] }, + cwd: CWD, ask: "tool", text: JSON.stringify({ tool: "readFile", path: "notes.md" }), }), @@ -113,6 +95,7 @@ describe("allowedWriteFiles auto-approval", () => { expect( await checkAutoApproval({ state: { ...baseState, allowedWriteFiles: ["notes.md"] }, + cwd: CWD, ask: "tool", text: JSON.stringify({ tool: "readFile", path: "src/index.ts" }), }), @@ -141,4 +124,59 @@ describe("allowedWriteFiles auto-approval", () => { }), ).toEqual({ decision: "ask" }) }) + + // A workspace-relative pattern and a workspace-relative path are only about the + // same file if both are resolved against the root the task actually runs in. A + // resumed or child task can run in a different one than the window shows, so the + // caller passes the task's own `cwd` rather than the provider's. + it("resolves the patterns against the cwd it is given", async () => { + const state = { allowedWriteFiles: ["/path/to/repo/notes.md"] } + + expect(await askToWrite({ path: "notes.md", cwd: "/path/to/repo", state })).toEqual({ + decision: "approve", + }) + + // The same relative path, in another workspace, is another file. + expect(await askToWrite({ path: "notes.md", cwd: "/path/to/other-repo", state })).toEqual({ + decision: "ask", + }) + }) + + describe("a write naming several files", () => { + // One approval covers the whole action, so a pattern has to cover all of it. + it("approves only when every file in the batch is listed", async () => { + expect( + await askToWrite({ + state: { allowedWriteFiles: ["docs/**"] }, + tool: "appliedDiff", + batchDiffs: [{ path: "docs/a.md" }, { path: "docs/b.md" }], + }), + ).toEqual({ decision: "approve" }) + + expect( + await askToWrite({ + state: { allowedWriteFiles: ["docs/**"] }, + tool: "appliedDiff", + batchDiffs: [{ path: "docs/a.md" }, { path: "src/index.ts" }], + }), + ).toEqual({ decision: "ask" }) + }) + + it("does not let a listed batch carry an unlisted path", async () => { + expect( + await askToWrite({ + path: "src/index.ts", + state: { allowedWriteFiles: ["docs/**"] }, + tool: "appliedDiff", + batchDiffs: [{ path: "docs/a.md" }], + }), + ).toEqual({ decision: "ask" }) + }) + + it("asks when the action names no file at all", async () => { + expect( + await askToWrite({ state: { allowedWriteFiles: ["docs/**", "*", "**"] }, tool: "appliedDiff" }), + ).toEqual({ decision: "ask" }) + }) + }) }) diff --git a/src/core/auto-approval/__tests__/filePatterns.spec.ts b/src/core/auto-approval/__tests__/filePatterns.spec.ts index e7d1dee515..2a00e0b8d6 100644 --- a/src/core/auto-approval/__tests__/filePatterns.spec.ts +++ b/src/core/auto-approval/__tests__/filePatterns.spec.ts @@ -6,8 +6,13 @@ import { isFileMatchedByPatterns, toMatcherPattern } from "../filePatterns" const CWD = "/path/to/repo" +// Both platforms' rules are exercised on whichever platform the tests run on, by +// passing `isWindows` explicitly rather than reading `process.platform`. const matches = (filePath: string, patterns: string[], cwd: string | undefined = CWD) => - isFileMatchedByPatterns({ filePath, cwd, patterns }) + isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: false }) + +const matchesOnWindows = (filePath: string, patterns: string[], cwd: string | undefined = CWD) => + isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: true }) const homeFromRoot = os.homedir().replace(/\\/g, "/").slice(1) @@ -37,8 +42,8 @@ describe("toMatcherPattern", () => { expect(toMatcherPattern("~/notes.md", CWD)).toBe(`/${homeFromRoot}/notes.md`) }) - it("lowercases a Windows drive so drive letters compare case-insensitively", () => { - expect(toMatcherPattern("C:/tmp/notes.md", CWD)).toBe("/c:/tmp/notes.md") + it("keeps a Windows drive as the first path segment", () => { + expect(toMatcherPattern("C:/tmp/notes.md", CWD)).toBe("/C:/tmp/notes.md") }) it("anchors a negation exactly like the pattern it cancels", () => { @@ -135,12 +140,19 @@ describe("isFileMatchedByPatterns", () => { }) it("keeps Windows drives apart", () => { - expect(matches("C:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(true) - expect(matches("D:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(false) + expect(matchesOnWindows("C:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(true) + expect(matchesOnWindows("D:/tmp/notes.md", ["c:/tmp/notes.md"])).toBe(false) + }) + + it("matches a path that uses Windows separators, on Windows", () => { + expect(matchesOnWindows("docs\\notes.md", ["docs/notes.md"])).toBe(true) }) - it("matches a path that uses Windows separators", () => { - expect(matches("docs\\notes.md", ["docs/notes.md"])).toBe(true) + // A backslash is a legal character in a filename on other platforms, where + // `my\file` is one file rather than `file` inside `my`, so it must not be read + // as a separator there. + it("treats a backslash in a path as part of the filename off Windows", () => { + expect(matches("docs\\notes.md", ["docs/notes.md"])).toBe(false) }) it("honours an escaped glob character in a pattern", () => { @@ -172,4 +184,64 @@ describe("isFileMatchedByPatterns", () => { expect(matches("docs/secret.md", ["docs/**", "!docs/secret.md"])).toBe(false) expect(matches("docs/notes.md", ["docs/**", "!docs/secret.md"])).toBe(true) }) + + // A pattern grants access to a named file, so it must not also grant the + // different file that differs only in case. + describe("case sensitivity", () => { + it("does not match a differently-cased name off Windows", () => { + expect(matches("NOTES.md", ["notes.md"])).toBe(false) + expect(matches("Notes.Md", ["notes.md"])).toBe(false) + expect(matches("DOCS/notes.md", ["docs/notes.md"])).toBe(false) + expect(matches("notes.md", ["notes.md"])).toBe(true) + }) + + it("ignores case on Windows, whose filesystem does too", () => { + expect(matchesOnWindows("NOTES.md", ["notes.md"])).toBe(true) + expect(matchesOnWindows("DOCS/notes.md", ["docs/notes.md"])).toBe(true) + }) + }) + + // A match on a directory must not decide the verdict of the files below it: + // see the note of the same name in filePatterns.ts. + describe("patterns that match a directory", () => { + it("does not grant a directory's contents", () => { + expect(matches("docs/notes.md", ["docs"])).toBe(false) + expect(matches("docs/nested/notes.md", ["docs"])).toBe(false) + expect(matches("elsewhere/docs/notes.md", ["docs"])).toBe(false) + }) + + it("still grants a file that has the pattern's name", () => { + expect(matches("docs", ["docs"])).toBe(true) + expect(matches("nested/docs", ["docs"])).toBe(true) + }) + + // A glob that matches a directory's *name* used to grant everything under + // it. It now grants only the files it matches itself, which for `*.d` are + // files ending in `.d` rather than the contents of a `build.d/` directory. + it("does not grant the contents of a directory whose name a glob matches", () => { + expect(matches("build.d/notes.md", ["*.d"])).toBe(false) + expect(matches("build.d", ["*.d"])).toBe(true) + }) + + // `*` names any file in any directory, exactly as `notes.md` and `*.md` do, + // so it does reach every file in the workspace. That is the documented + // meaning of a pattern without a slash, not the directory behaviour above: + // every one of those matches is against the file's own path. + it("keeps a bare wildcard matching files at any depth", () => { + expect(matches("notes.md", ["*"])).toBe(true) + expect(matches("nested/notes.md", ["*"])).toBe(true) + expect(matches("nested/notes.md", ["*.md"])).toBe(true) + }) + + it("keeps a directory glob granting everything below it", () => { + expect(matches("docs/notes.md", ["docs/**"])).toBe(true) + expect(matches("docs/nested/deeply/notes.md", ["docs/**"])).toBe(true) + }) + + it("honours a negation at any depth below a directory glob", () => { + expect(matches("docs/secret.md", ["docs/**", "!docs/secret.md"])).toBe(false) + expect(matches("docs/private/secret.md", ["docs/**", "!docs/private/secret.md"])).toBe(false) + expect(matches("docs/private/notes.md", ["docs/**", "!docs/private/secret.md"])).toBe(true) + }) + }) }) diff --git a/src/core/auto-approval/__tests__/fixtures.ts b/src/core/auto-approval/__tests__/fixtures.ts new file mode 100644 index 0000000000..f6017d91e0 --- /dev/null +++ b/src/core/auto-approval/__tests__/fixtures.ts @@ -0,0 +1,40 @@ +// Shared fixtures for the auto-approval allowlist specs. +// +// The three allowlist specs each need a fully-populated auto-approval state with +// every toggle off, so that a test's own overrides are the only thing that can +// approve anything. Keeping one copy of it prevents the drift that comes from +// maintaining near-identical literals side by side. + +import type { ExtensionState } from "@roo-code/types" + +import type { AutoApprovalState, AutoApprovalStateOptions } from ".." + +export type State = Pick + +/** Workspace root the specs resolve their patterns and paths against. */ +export const CWD = "/path/to/repo" + +/** + * Auto-approval enabled, every permission off, both allowlists empty. + * + * Every field is listed rather than relying on optionality, so that a test which + * approves something can only be doing so through what it overrides. + */ +export const baseState: State = { + autoApprovalEnabled: true, + alwaysAllowReadOnly: false, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + allowedWriteFiles: [], + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + destructiveCommandGuardEnabled: false, + allowedCommands: [], + deniedCommands: [], +} diff --git a/src/core/auto-approval/__tests__/negation.spec.ts b/src/core/auto-approval/__tests__/negation.spec.ts index cdfc668222..1cd59fee85 100644 --- a/src/core/auto-approval/__tests__/negation.spec.ts +++ b/src/core/auto-approval/__tests__/negation.spec.ts @@ -1,39 +1,15 @@ // npx vitest run core/auto-approval/__tests__/negation.spec.ts -import type { ExtensionState } from "@roo-code/types" - import { isFileMatchedByPatterns } from "../filePatterns" -import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".." - -const CWD = "/path/to/repo" - -type State = Pick +import { checkAutoApproval } from ".." +import { CWD, baseState, type State } from "./fixtures" const matches = (filePath: string, patterns: string[]) => isFileMatchedByPatterns({ filePath, cwd: CWD, patterns }) -const baseState: State = { - autoApprovalEnabled: true, - alwaysAllowReadOnly: false, - alwaysAllowReadOnlyOutsideWorkspace: false, - allowedReadFiles: [], - alwaysAllowWrite: false, - alwaysAllowWriteOutsideWorkspace: false, - alwaysAllowWriteProtected: false, - allowedWriteFiles: [], - cwd: CWD, - alwaysAllowMcp: false, - alwaysAllowModeSwitch: false, - alwaysAllowSubtasks: false, - alwaysAllowExecute: false, - alwaysAllowFollowupQuestions: false, - destructiveCommandGuardEnabled: false, - allowedCommands: [], - deniedCommands: [], -} - const readDecision = async (state: Partial, path = "docs/secret.md") => checkAutoApproval({ state: { ...baseState, ...state }, + cwd: CWD, ask: "tool", text: JSON.stringify({ tool: "readFile", path }), }) @@ -79,10 +55,10 @@ describe("pattern negation", () => { describe("across the two allowlists", () => { // Each list is matched independently, so which box a pattern was typed // into cannot change the outcome by reordering a concatenation. - it("does not let a write-list negation revoke read access", () => { + it("does not let a write-list negation revoke read access", async () => { expect( - readDecision({ allowedReadFiles: ["docs/**"], allowedWriteFiles: ["!docs/secret.md"] }), - ).resolves.toEqual({ decision: "approve" }) + await readDecision({ allowedReadFiles: ["docs/**"], allowedWriteFiles: ["!docs/secret.md"] }), + ).toEqual({ decision: "approve" }) }) it("does not let a read-list negation revoke read access granted by the write list", async () => { diff --git a/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts b/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts index a9b91ac1a2..fb56d7d563 100644 --- a/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts +++ b/src/core/auto-approval/__tests__/noWorkspaceRoot.spec.ts @@ -51,9 +51,9 @@ describe("patterns without a workspace root", () => { }) it("honours an absolute negation", () => { - expect(matchesWithoutWorkspace("/tmp/scratch/secret.md", ["/tmp/scratch/**", "!/tmp/scratch/secret.md"])).toBe( - false, - ) + expect( + matchesWithoutWorkspace("/tmp/scratch/secret.md", ["/tmp/scratch/**", "!/tmp/scratch/secret.md"]), + ).toBe(false) }) }) @@ -61,17 +61,23 @@ describe("patterns without a workspace root", () => { // behaviour the rejection above preserves. describe("for contrast, with a workspace root", () => { it("confines a bare filename to the workspace", () => { - expect(isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["passwd"] })).toBe( - false, - ) + expect( + isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["passwd"] }), + ).toBe(false) expect( - isFileMatchedByPatterns({ filePath: "/path/to/repo/etc/passwd", cwd: "/path/to/repo", patterns: ["passwd"] }), + isFileMatchedByPatterns({ + filePath: "/path/to/repo/etc/passwd", + cwd: "/path/to/repo", + patterns: ["passwd"], + }), ).toBe(true) }) it("confines a star to the workspace", () => { - expect(isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["*"] })).toBe(false) + expect(isFileMatchedByPatterns({ filePath: "/etc/passwd", cwd: "/path/to/repo", patterns: ["*"] })).toBe( + false, + ) }) }) }) diff --git a/src/core/auto-approval/filePatterns.ts b/src/core/auto-approval/filePatterns.ts index 416b96e9c6..0e1280d19a 100644 --- a/src/core/auto-approval/filePatterns.ts +++ b/src/core/auto-approval/filePatterns.ts @@ -42,6 +42,20 @@ import ignore from "ignore" * `/` is the only directory separator, also on Windows, since a backslash * escapes the character after it. * + * Matching is case-sensitive, except on Windows, so that a pattern is as + * case-sensitive as the filesystem whose files it names. + * Pattern `notes.md` must not hand out access to `NOTES.md`, + * which on Linux is a different file. + * This follows `git`, which compares case-sensitively unless `core.ignoreCase` + * is set (`git init` sets it when it detects a case-insensitive filesystem). + * It does *not* follow the `ignore` library, whose default is `ignorecase: true` + * regardless of platform, so the option has to be passed explicitly. + * + * A pattern always names files, never directories: `docs/` is rejected, and + * `docs` grants access to a *file* named `docs`, not to the directory's + * contents. Use `docs/**` to name everything below a directory. See + * "Why a match on a directory must not decide a file's verdict" below. + * * # Why patterns and paths are rewritten before matching * * The `ignore` library matches a path against patterns as `git` does against @@ -49,7 +63,7 @@ import ignore from "ignore" * directory. It therefore cannot handle: * - absolute paths (`/tmp/notes.md`, `C:/tmp/notes.md`), or * - paths that climb out of the base directory (`../shared/notes.md`). - * Running `ignore`'s matching ()`ignores()`) against such a path throws + * Running `ignore`'s matching (`ignores()`) against such a path throws * `RangeError` ("path should be a `path.relative()`d string"). * Passing such a *pattern* to `add()` throws nothing at all: * It is accepted and then quietly never matches, which would @@ -68,11 +82,75 @@ import ignore from "ignore" * `!/path/to/my/repo/secret.txt` would surprisingly not deny a relative pattern * such as `*.txt`, because those would be handled by different `ignore` * instances. + * + * # Why a match on a directory must not decide a file's verdict + * + * Git's `gitignore` decides a path by walking it from the top: it tests each ancestor + * directory first and, once one is excluded, stops and reports the file as + * excluded too, because + * > It is not possible to re-include a file if a parent directory of that file + * > is excluded. + * (`man 5 gitignore`). `ignore` reproduces this, testing each ancestor as a + * directory path (with a trailing slash) before the file path itself. + * + * Here a match grants a permission rather than withholding one, so that same + * sentence reads: it is not possible to deny a file if a parent directory of + * that file is granted. Which is exactly the objection: for a permission that + * is the wrong default, in two ways. + * - Any pattern that matches a *directory* would grant its whole subtree, + * which is not apparent from the pattern: `docs` would grant every file under + * every `docs/` directory, and a bare `*` the entire workspace, even though + * the table above documents patterns as naming files and `docs/` is rejected + * outright for naming a directory. + * - A negation below such a directory would be silently ineffective: with + * `docs/**` followed by `!docs/private/secret.md`, the walk grants the + * `docs/private/` directory and never gets to the negation for the file. + * + * (In the below, `(star-star)` stands for the `**` wildcard, + * which cannot be written literally here because its trailing slash + * would close the JSDoc comment block.) + + * The ancestor walk is therefore neutralised by appending one rule that matches + * directories only, and nothing else, using `!(star-star)/` + * Being last, it decides every ancestor probe, + * since those are the only paths carrying a trailing slash, and it leaves the + * file's own verdict to the user's patterns. A pattern can therefore still name + * a *file* called `docs`, and `docs/**` still grants everything below `docs/`, + * because those rules match the file path directly rather than an ancestor. + * + * Example: + * For workspace root `/path/to/repo`, the two patterns + * docs/(star-star) + * !docs/private/secret.md + * are handed to `ignore` as + * /path/to/repo/docs/(star-star) + * !/path/to/repo/docs/private/secret.md + * !(star-star)/ <- appended + * and `docs/private/secret.md` is checked as `path/to/repo/docs/private/secret.md`: + * the ancestor probes (`path/`, ..., `path/to/repo/docs/private/`) all end in a + * slash, so the appended rule has the last word and reports them as not granted; + * the file path is then matched by rule 1 (granted) and rule 2 (denied), and as + * the last match wins, the file is denied. Without the appended rule, the + * ancestor `path/to/repo/docs/private/` would match rule 1 and end the walk + * there, granting the file the negation was written to withhold. */ +const MATCH_DIRECTORIES_ONLY_PATTERN = "!**/" -/** Convert Windows path separators so patterns and paths share one syntax. */ -function pathsepsToPosix(value: string): string { - return value.replace(/\\/g, "/") +/** + * Whether we're on Windows. + */ +const runningOnWindows = () => process.platform === "win32" + +/** + * Convert Windows path separators so patterns and paths share one syntax. + * + * Only on Windows: everywhere else a backslash is an ordinary character in a + * filename (`touch 'my\file'` creates a single file, not a directory), so + * converting it would rewrite a path into a different one, and let the pattern + * `my/file` grant access to the unrelated file `my\file`. + */ +function pathsepsToPosix(value: string, isWindows: boolean): string { + return isWindows ? value.replace(/\\/g, "/") : value } function isAbsolutePosixPath(value: string): boolean { @@ -88,18 +166,20 @@ function escapesWorkspace(posixPath: string): boolean { * Rewrite an absolute path as a path relative to the filesystem root, since the * `ignore` library rejects paths that start with `/`. * - * The Windows drive becomes the first path segment (lowercased, since Windows - * treats drive letters case-insensitively), which keeps drives apart: a `c:/` - * pattern cannot match a `d:/` path. + * An absolute POSIX path loses its leading slash. + * An absolute Windows path starts with a drive letter, so it doesn't + * have a leading slash so we don't have to strip anything from it. + * + * The drive letter's case is left as typed, since a drive letter only occurs on + * Windows, where the matcher ignores case anyway (see the case-sensitivity note + * at the top), so `C:/x` and `c:/x` already name the same file. * * - `"/tmp/notes.md"` -> `"tmp/notes.md"` - * - `"C:/tmp/notes.md"` -> `"c:/tmp/notes.md"` + * - `"C:/tmp/notes.md"` -> `"C:/tmp/notes.md"` */ function toRootRelativePath(absolutePosixPath: string): string { - const drive = absolutePosixPath.match(/^([a-zA-Z]):\//) - - if (drive) { - return `${drive[1].toLowerCase()}:/${absolutePosixPath.slice(drive[0].length)}` + if (/^[a-zA-Z]:\//.test(absolutePosixPath)) { + return absolutePosixPath } return absolutePosixPath.slice(1) @@ -160,14 +240,11 @@ function toRootRelativePath(absolutePosixPath: string): string { * * @param pattern - Raw pattern as typed by the user. * @param cwd - Workspace root, used to resolve workspace-relative patterns. + * @param isWindows - Whether to read paths by Windows' rules; see `pathsepsToPosix`. * @returns The rewritten pattern, or `undefined` when the pattern can never * match a file (empty, a directory, or escaping an unknown workspace root). */ -export function toMatcherPattern(pattern: string, cwd?: string): string | undefined { - if (typeof pattern !== "string") { - return undefined - } - +export function toMatcherPattern(pattern: string, cwd?: string, isWindows = runningOnWindows()): string | undefined { // Set gitignore's negation aside so the path is rewritten on its own merits, // then restore it, so that a negation is anchored exactly like the pattern it // is written to cancel. @@ -179,7 +256,7 @@ export function toMatcherPattern(pattern: string, cwd?: string): string | undefi } if (normalized.startsWith("~/")) { - normalized = pathsepsToPosix(path.join(os.homedir(), normalized.slice(2))) + normalized = pathsepsToPosix(path.join(os.homedir(), normalized.slice(2)), isWindows) } if (!isAbsolutePosixPath(normalized) && escapesWorkspace(normalized)) { @@ -187,7 +264,7 @@ export function toMatcherPattern(pattern: string, cwd?: string): string | undefi return undefined } - normalized = pathsepsToPosix(path.resolve(cwd, normalized)) + normalized = pathsepsToPosix(path.resolve(cwd, normalized), isWindows) } if (isAbsolutePosixPath(normalized)) { @@ -207,7 +284,7 @@ export function toMatcherPattern(pattern: string, cwd?: string): string | undefi return undefined } - const workspaceBase = toRootRelativePath(pathsepsToPosix(path.resolve(cwd))) + const workspaceBase = toRootRelativePath(pathsepsToPosix(path.resolve(cwd), isWindows)) // gitignore anchors a pattern to the base directory as soon as it has a // separator "at the beginning or middle (or both)" (gitignore(5)), and only a @@ -227,59 +304,66 @@ export function toMatcherPattern(pattern: string, cwd?: string): string | undefi * @returns The rewritten path, or `undefined` when it names no file, or when it * is relative and there is no workspace root to resolve it against. */ -function toMatcherPath(filePath: string, cwd?: string): string | undefined { - // Not trimmed: whitespace can be part of a filename. - const normalized = pathsepsToPosix(filePath) - - if (!normalized.trim() || normalized === ".") { - return undefined - } - - if (isAbsolutePosixPath(normalized)) { - return toRootRelativePath(normalized) - } - - if (!cwd) { - // A relative path cannot be placed on the filesystem without a root, and - // the only patterns that survive without one are absolute, which such a - // path could never match anyway. - return undefined - } - - return toRootRelativePath(pathsepsToPosix(path.resolve(cwd, normalized))) +function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boolean): string | undefined { + // Not trimmed: whitespace can be part of a filename. + const normalized = pathsepsToPosix(filePath, isWindows) + + if (!normalized.trim() || normalized === ".") { + return undefined + } + + if (isAbsolutePosixPath(normalized)) { + return toRootRelativePath(normalized) + } + + if (!cwd) { + // A relative path cannot be placed on the filesystem without a root, and + // the only patterns that survive without one are absolute, which such a + // path could never match anyway. + return undefined + } + + return toRootRelativePath(pathsepsToPosix(path.resolve(cwd, normalized), isWindows)) } /** * Check whether a file path is covered by any of the configured patterns. * * Patterns are applied in the order given and the last one to match decides, so - * a `!` pattern excludes files matched by the patterns before it. + * a `!` pattern excludes files matched by the patterns before it. A pattern only + * ever decides the file it names: matching one of its parent directories grants + * nothing, see "Why a match on a directory must not decide a file's verdict". * * @param filePath - Path of the file, either absolute or relative to `cwd`. * @param cwd - Workspace root. * @param patterns - Raw patterns as configured by the user. + * @param isWindows - Whether to read paths by Windows' rules: `\` separates + * directories and case is ignored. Defaults to the platform in use; tests pass it + * explicitly to exercise either platform's rules. */ export function isFileMatchedByPatterns({ filePath, cwd, patterns, + isWindows = runningOnWindows(), }: { filePath?: string cwd?: string patterns?: string[] + isWindows?: boolean }): boolean { if (!filePath || !Array.isArray(patterns) || !patterns.length) { return false } - const candidate = toMatcherPath(filePath, cwd) + const candidate = toMatcherPath(filePath, cwd, isWindows) if (!candidate) { return false } const matcherPatterns = patterns - .map((pattern) => toMatcherPattern(pattern, cwd)) + .map((pattern) => toMatcherPattern(pattern, cwd, isWindows)) .filter((pattern): pattern is string => !!pattern) if (!matcherPatterns.length) { @@ -287,7 +371,9 @@ export function isFileMatchedByPatterns({ } try { - return ignore().add(matcherPatterns).ignores(candidate) + return ignore({ ignoreCase: isWindows }) + .add([...matcherPatterns, MATCH_DIRECTORIES_ONLY_PATTERN]) + .ignores(candidate) } catch (error) { // A path the matcher rejects cannot be confirmed as matching, so treat it // as unmatched. diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index de36c79c8c..751b5c0674 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -32,23 +32,65 @@ export type AutoApprovalStateOptions = | "alwaysAllowWriteOutsideWorkspace" // For `alwaysAllowWrite`. | "alwaysAllowWriteProtected" | "allowedWriteFiles" // Grants writes per file, without `alwaysAllowWrite`. - | "cwd" // To resolve the allowlist patterns. | "followupAutoApproveTimeoutMs" // For `alwaysAllowFollowupQuestions`. | "mcpServers" // For `alwaysAllowMcp`. | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" | "destructiveCommandGuardEnabled" +/** + * Every file a tool action names, as far as the allowlists are concerned. + * + * One approval answers for the whole action, so every file it touches has to be + * covered by the patterns. Rather than deciding which of these fields a given + * message is expected to use, all of them are collected and all have to match: + * that way no field can grant a permission by being overlooked, and a field + * added later can only ever make the check stricter. + * + * `additionalFileCount` counts files that the message does *not* name (the chat + * row renders it as "and N more"). They cannot be matched against a pattern, so + * the caller must refuse the whole action rather than approve the named ones. + */ +function namedFiles(tool: ClineSayTool): { paths: string[]; hasUnnamedFiles: boolean } { + const batched = [...(tool.batchFiles ?? []), ...(tool.batchDiffs ?? []), ...(tool.batchDirs ?? [])] + + return { + paths: [...(tool.path === undefined ? [] : [tool.path]), ...batched.map((file) => file.path)], + hasUnnamedFiles: !!tool.additionalFileCount, + } +} + +/** + * Whether every file named by a tool action is covered by `matchFun`. + * + * Returns `false` for an action naming no file at all, since patterns can only + * grant access to files they name, and for one that carries unnamed files. + */ +function areAllNamedFilesMatched(tool: ClineSayTool, matchFun: (filePath: string) => boolean): boolean { + const { paths, hasUnnamedFiles } = namedFiles(tool) + + if (hasUnnamedFiles) { + return false + } + + // Bail on `!paths.length` defensively in case new paths are introduced + // in the future that are forgotten to be added to `namedFiles()`. + if (!paths.length) { + return false + } + + return paths.every((filePath) => matchFun(filePath)) +} + /** * Whether a read-only tool action is fully covered by the read allowlist patterns. * * The allowlist names individual files, so it only ever approves `read_file`: * the other read-only actions (directory listings, searches, codebase queries) - * work on directories, not files. + * work on directories, not files, and are turned away by the `tool` check below. * * A `read_file` call can cover several files at once, in which case a single - * approval answers for all of them. So this function returns whether ALL of - * them are allowed by the patterns. + * approval answers for all of them, so ALL of them have to be allowed. * * Write permission implies read permission, so both lists are consulted, each * matched on its own rather than concatenated: gitignore negation is @@ -60,21 +102,35 @@ export type AutoApprovalStateOptions = */ function isReadAllowedByPatterns( tool: ClineSayTool, - state: Pick, + cwd: string | undefined, + state: Pick, ): boolean { if (tool.tool !== "readFile") { return false } - const matches = (filePath?: string) => - isFileMatchedByPatterns({ filePath, cwd: state.cwd, patterns: state.allowedReadFiles }) || - isFileMatchedByPatterns({ filePath, cwd: state.cwd, patterns: state.allowedWriteFiles }) - - if (tool.batchFiles?.length) { - return tool.batchFiles.every((file) => matches(file.path)) - } + return areAllNamedFilesMatched( + tool, + (filePath) => + isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedReadFiles }) || + isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedWriteFiles }), + ) +} - return matches(tool.path) +/** + * Whether a write tool action is fully covered by the write allowlist patterns. + * + * As for reads, one approval covers every file the action names, so every one of + * them has to be matched. + */ +function isWriteAllowedByPatterns( + tool: ClineSayTool, + cwd: string | undefined, + state: Pick, +): boolean { + return areAllNamedFilesMatched(tool, (filePath) => + isFileMatchedByPatterns({ filePath, cwd, patterns: state.allowedWriteFiles }), + ) } export type CheckAutoApprovalResult = @@ -89,11 +145,25 @@ export type CheckAutoApprovalResult = export async function checkAutoApproval({ state, + cwd, ask, text, isProtected, }: { state?: Pick + /** + * Workspace root the allowlist patterns and the checked path are resolved + * against. + * + * Must be the `cwd` of the task this ask belongs to, which is the root the + * path in `text` was made relative to. It is not read from `state`, because + * the provider's `cwd` follows the window (the focused editor in a multi-root + * workspace, or a `refreshWorkspace()` while the task runs) and a resumed or + * child task can run against another root entirely. Resolving against the + * wrong one would let a pattern written for one workspace approve a write + * landing in another. + */ + cwd?: string ask: ClineAsk text?: string isProtected?: boolean @@ -223,7 +293,7 @@ export async function checkAutoApproval({ // `alwaysAllowReadOnly` permission. Such a pattern names its // location, including outside the workspace, so it also stands in for // `alwaysAllowReadOnlyOutsideWorkspace`. - const isAllowedReadFile = isReadAllowedByPatterns(tool, state) + const isAllowedReadFile = isReadAllowedByPatterns(tool, cwd, state) const isReadAllowed = isAllowedReadFile || @@ -242,11 +312,7 @@ export async function checkAutoApproval({ // It deliberately does not stand in for `alwaysAllowWriteProtected`: // a broad pattern such as `*.md` would otherwise silently cover // protected files like `AGENTS.md`. - const isAllowedWriteFile = isFileMatchedByPatterns({ - filePath: tool.path, - cwd: state.cwd, - patterns: state.allowedWriteFiles, - }) + const isAllowedWriteFile = isWriteAllowedByPatterns(tool, cwd, state) const isWriteAllowed = isAllowedWriteFile || diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..27758a5e0e 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1182,7 +1182,11 @@ export class Task extends EventEmitter implements TaskLike { // rendered, leaving them stuck on-screen). const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined - const approval = await checkAutoApproval({ state, ask: type, text, isProtected }) + // `this.cwd`, not `provider.cwd`: + // The path inside `text` was made relative to this task's workspace, + // which for a resumed or child task need not be the one the provider + // currently reports. + const approval = await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected }) const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined diff --git a/src/core/task/__tests__/ask-allowlist-cwd.spec.ts b/src/core/task/__tests__/ask-allowlist-cwd.spec.ts new file mode 100644 index 0000000000..f4f0e88487 --- /dev/null +++ b/src/core/task/__tests__/ask-allowlist-cwd.spec.ts @@ -0,0 +1,101 @@ +// npx vitest run core/task/__tests__/ask-allowlist-cwd.spec.ts + +import type { ExtensionState } from "@roo-code/types" + +import { Task } from "../Task" + +// The allowlist patterns are resolved against a workspace root, and the path in +// the tool message was made relative by `getReadablePath(task.cwd, ...)`. So the +// root used for matching has to be the task's own `cwd`. +// +// The provider's `cwd` is a different value: it follows the window (the focused +// editor under multi-root workspaces, or a `refreshWorkspace()` mid-task), while a +// task resumed from history or created as a child keeps the workspace it belongs +// to. When the two diverge, resolving against the provider's would let a pattern +// written for one workspace approve a write landing in another. + +/** The parts of the provider that `Task.ask` reaches for. */ +type ProviderStub = { + getState: () => Promise> + postMessageToWebview: ReturnType + cwd: string +} + +function buildTask(provider: ProviderStub, taskCwd: string) { + const task = Object.create(Task.prototype) as Task + task["abort"] = false + task["clineMessages"] = [] + task["askResponse"] = undefined + task["askResponseText"] = undefined + task["askResponseImages"] = undefined + task["lastMessageTs"] = undefined + task["addToClineMessages"] = vi.fn(async () => {}) + task["saveClineMessages"] = vi.fn(async () => true) + task["updateClineMessage"] = vi.fn(async () => {}) + task["cancelAutoApprovalTimeout"] = vi.fn(() => {}) + task["checkpointSave"] = vi.fn(async () => {}) + task["emit"] = vi.fn() + task["providerRef"] = { deref: () => provider } as unknown as Task["providerRef"] + // `Task.cwd` reads `workspacePath`, which is `historyItem.workspace` for a + // resumed task and the parent's path for a child one. It is `readonly`, so the + // stub installs it the way the constructor would. + Object.defineProperty(task, "workspacePath", { value: taskCwd }) + + return task +} + +async function attachQueue(task: Task) { + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + Object.defineProperty(task, "messageQueueService", { value: new MessageQueueService() }) +} + +const TASK_CWD = "/path/to/task-workspace" +const PROVIDER_CWD = "/path/to/window-workspace" + +const buildProvider = (allowedWriteFiles: string[]): ProviderStub => ({ + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + cwd: PROVIDER_CWD, + getState: async () => ({ + autoApprovalEnabled: true, + alwaysAllowWrite: false, + alwaysAllowWriteProtected: false, + allowedReadFiles: [], + allowedWriteFiles, + }), +}) + +/** + * Ask to write a workspace-relative path and report whether it was auto-approved. + * + * An ask that is not auto-answered blocks until the user responds, so the outcome + * is read from the message instead of the returned promise: `Task.ask` stamps + * `autoApprovalDecision` on it when it resolves the ask itself. The ask is then + * answered so nothing is left pending. + */ +const askToWriteRelativePath = async (allowedWriteFiles: string[]) => { + const task = buildTask(buildProvider(allowedWriteFiles), TASK_CWD) + await attachQueue(task) + + // A relative path, as `getReadablePath(task.cwd, relPath)` produces for a file + // inside the task's own workspace. + const asked = task.ask("tool", JSON.stringify({ tool: "newFileCreated", path: "notes.md" }), false) + + const addToClineMessages = task["addToClineMessages"] as ReturnType + await vi.waitUntil(() => addToClineMessages.mock.calls.length > 0) + const message = addToClineMessages.mock.calls[0][0] + + task.approveAsk() + await asked + + return message.autoApprovalDecision ?? ("ask" as const) +} + +describe("Task.ask resolves allowlists against the task's workspace", () => { + it("approves a file the task's own workspace root makes match", async () => { + expect(await askToWriteRelativePath([`${TASK_CWD}/notes.md`])).toBe("approve") + }) + + it("does not approve a pattern that only matches under the provider's workspace root", async () => { + expect(await askToWriteRelativePath([`${PROVIDER_CWD}/notes.md`])).toBe("ask") + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ca5f82c916..093b8c05d6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2929,10 +2929,6 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - // Consumers such as auto-approval resolve workspace-relative paths - // against this, so it must be present here as well as in - // `getStateToPostToWebview`. - cwd: this.cwd, disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 8773112f88..ce43d3aa45 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1303,14 +1303,6 @@ describe("ClineProvider", () => { expect(state.allowedWriteFiles).toEqual([]) }) - // Auto-approval resolves workspace-relative paths against `cwd`, and reads - // its state from `getState`, so the field has to be present there too. - test("getState returns the workspace path", async () => { - const state = await provider.getState() - - expect(state.cwd).toBe(provider.cwd) - }) - test("getStateToPostToWebview returns the saved allowed write files", async () => { await provider.resolveWebviewView(mockWebviewView) await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"]) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 14641b6a32..6276ea6514 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -267,6 +267,7 @@ export const AutoApproveSettings = ({ + diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 51c78e842a..1dcf6e5e63 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Llistes de permisos", - "description": "Concedeix accés a fitxers concrets, sense aprovar automàticament tota una categoria d'accions. Un patró per línia, amb sintaxi inspirada en .gitignore: \"notes.md\" coincideix amb aquest fitxer en qualsevol directori de l'espai de treball, \"docs/scratch/**\" amb tot el que hi ha sota aquell directori i \"*.md\" amb qualsevol fitxer Markdown. Un \"./\" inicial indica l'arrel de l'espai de treball, mentre que un \"/\" inicial indica l'arrel del sistema de fitxers, de manera que \"/tmp/notes.md\" i \"~/notes.md\" arriben fora de l'espai de treball. Posa \"!\" davant d'un patró per excloure el que ha coincidit en una línia anterior; com a .gitignore, guanya l'última línia que coincideix, així que l'ordre compta. Els patrons relatius a l'espai de treball s'ignoren mentre no hi hagi cap carpeta oberta.", + "description": "Concedeix accés a fitxers concrets, sense aprovar automàticament tota una categoria d'accions. Un patró per línia, amb sintaxi inspirada en .gitignore: \"notes.md\" coincideix amb aquest fitxer en qualsevol directori de l'espai de treball, \"docs/scratch/**\" amb tot el que hi ha sota aquell directori i \"*.md\" amb qualsevol fitxer Markdown. Un patró sempre anomena fitxers, mai directoris: \"docs\" només concedeix un fitxer anomenat \"docs\"; escriu \"docs/**\" per al contingut d'un directori. Un \"./\" inicial indica l'arrel de l'espai de treball, mentre que un \"/\" inicial indica l'arrel del sistema de fitxers, de manera que \"/tmp/notes.md\" i \"~/notes.md\" arriben fora de l'espai de treball. Posa \"!\" davant d'un patró per excloure el que ha coincidit en una línia anterior; com a .gitignore, guanya l'última línia que coincideix, així que l'ordre compta. Els patrons relatius a l'espai de treball s'ignoren mentre no hi hagi cap carpeta oberta. Es distingeixen majúscules i minúscules, excepte a Windows. No s'aprova res automàticament si \"Auto-aprovació\" de dalt està desactivada.", "readFiles": { "label": "Patrons de la llista de permisos de lectura", - "description": "Fitxers que Zoo pot llegir sense aprovació, encara que \"Llegir\" estigui desactivat a dalt. Els fitxers de la llista de permisos d'escriptura de sota sempre es poden llegir també, així que no cal afegir-los dues vegades. Els llistats de directoris i les cerques sempre segueixen la configuració de \"Llegir\".", + "description": "Fitxers que Zoo pot llegir sense aprovació, encara que \"Llegir\" estigui desactivat a dalt. Els fitxers de la llista de permisos d'escriptura de sota sempre es poden llegir també, així que no cal afegir-los dues vegades. Els llistats de directoris i les cerques sempre segueixen la configuració de \"Llegir\". Els fitxers exclosos per .rooignore continuen sense poder-se llegir.", "placeholder": "Un patró per línia, ex. notes.md" }, "writeFiles": { "label": "Patrons de la llista de permisos d'escriptura", - "description": "Fitxers que Zoo pot crear i editar sense aprovació, encara que \"Escriure\" estigui desactivat a dalt. Els fitxers protegits continuen requerint aprovació tret que activis \"Incloure fitxers protegits\" (a la configuració de l'auto-aprovació d'\"Escriure\").", + "description": "Fitxers que Zoo pot crear i editar sense aprovació, encara que \"Escriure\" estigui desactivat a dalt. Els fitxers protegits continuen requerint aprovació tret que activis \"Incloure fitxers protegits\" (a la configuració de l'auto-aprovació d'\"Escriure\"). Els fitxers exclosos per .rooignore continuen sense poder-se escriure.", "placeholder": "Un patró per línia, ex. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 13163fa991..acdcd70e55 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Zulassungslisten", - "description": "Erteile Zugriff auf einzelne Dateien, ohne eine ganze Kategorie von Aktionen automatisch zu genehmigen. Ein Muster pro Zeile, in .gitignore-inspirierter Syntax: \"notes.md\" passt auf diese Datei in jedem Verzeichnis des Workspace, \"docs/scratch/**\" auf alles unter diesem Verzeichnis und \"*.md\" auf jede Markdown-Datei. Ein führendes \"./\" meint das Workspace-Wurzelverzeichnis, ein führendes \"/\" dagegen die Wurzel des Dateisystems, sodass \"/tmp/notes.md\" und \"~/notes.md\" über den Workspace hinausreichen. Ein \"!\" vor einem Muster nimmt aus, was eine frühere Zeile erfasst hat; wie in .gitignore gewinnt die letzte passende Zeile, die Reihenfolge zählt also. Workspace-relative Muster werden ignoriert, solange kein Ordner geöffnet ist.", + "description": "Erteile Zugriff auf einzelne Dateien, ohne eine ganze Kategorie von Aktionen automatisch zu genehmigen. Ein Muster pro Zeile, in .gitignore-inspirierter Syntax: \"notes.md\" passt auf diese Datei in jedem Verzeichnis des Workspace, \"docs/scratch/**\" auf alles unter diesem Verzeichnis und \"*.md\" auf jede Markdown-Datei. Ein Muster benennt immer Dateien, niemals Verzeichnisse: \"docs\" erlaubt nur eine Datei namens \"docs\"; für den Inhalt eines Verzeichnisses schreibe \"docs/**\". Ein führendes \"./\" meint das Workspace-Wurzelverzeichnis, ein führendes \"/\" dagegen die Wurzel des Dateisystems, sodass \"/tmp/notes.md\" und \"~/notes.md\" über den Workspace hinausreichen. Ein \"!\" vor einem Muster nimmt aus, was eine frühere Zeile erfasst hat; wie in .gitignore gewinnt die letzte passende Zeile, die Reihenfolge zählt also. Workspace-relative Muster werden ignoriert, solange kein Ordner geöffnet ist. Groß- und Kleinschreibung werden unterschieden, außer unter Windows. Solange \"Auto-Genehmigung\" oben aus ist, wird nichts automatisch genehmigt.", "readFiles": { "label": "Muster der Lese-Zulassungsliste", - "description": "Dateien, die Zoo ohne Genehmigung lesen darf, auch wenn \"Lesen\" oben aus ist. Dateien aus der Schreib-Zulassungsliste unten dürfen immer auch gelesen werden und müssen daher nicht zweimal aufgeführt werden. Verzeichnisauflistungen und Suchen folgen immer der \"Lesen\"-Einstellung.", + "description": "Dateien, die Zoo ohne Genehmigung lesen darf, auch wenn \"Lesen\" oben aus ist. Dateien aus der Schreib-Zulassungsliste unten dürfen immer auch gelesen werden und müssen daher nicht zweimal aufgeführt werden. Verzeichnisauflistungen und Suchen folgen immer der \"Lesen\"-Einstellung. Von .rooignore ausgeschlossene Dateien bleiben trotzdem nicht lesbar.", "placeholder": "Ein Muster pro Zeile, z.B. notes.md" }, "writeFiles": { "label": "Muster der Schreib-Zulassungsliste", - "description": "Dateien, die Zoo ohne Genehmigung erstellen und bearbeiten darf, auch wenn \"Schreiben\" oben aus ist. Geschützte Dateien brauchen weiterhin eine Genehmigung, sofern \"Geschützte Dateien einbeziehen\" nicht aktiviert ist (in den Einstellungen der \"Schreiben\"-Auto-Genehmigung).", + "description": "Dateien, die Zoo ohne Genehmigung erstellen und bearbeiten darf, auch wenn \"Schreiben\" oben aus ist. Geschützte Dateien brauchen weiterhin eine Genehmigung, sofern \"Geschützte Dateien einbeziehen\" nicht aktiviert ist (in den Einstellungen der \"Schreiben\"-Auto-Genehmigung). Von .rooignore ausgeschlossene Dateien bleiben trotzdem nicht beschreibbar.", "placeholder": "Ein Muster pro Zeile, z.B. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7f2a640f8f..ef638ae641 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -388,15 +388,15 @@ }, "allowlists": { "label": "Allowlists", - "description": "Grant access to individual files, without auto-approving a whole category of actions. One pattern per line, in .gitignore-inspired syntax: \"notes.md\" matches that file in any directory of the workspace, \"docs/scratch/**\" everything below that directory, and \"*.md\" any Markdown file. A leading \"./\" means the workspace root, while a leading \"/\" means the filesystem root, so \"/tmp/notes.md\" and \"~/notes.md\" reach outside the workspace. Prefix a pattern with \"!\" to exclude what an earlier line matched; as in .gitignore, the last matching line wins, so order counts. Workspace-relative patterns are ignored while no folder is open.", + "description": "Grant access to individual files, without auto-approving a whole category of actions. One pattern per line, in .gitignore-inspired syntax: \"notes.md\" matches that file in any directory of the workspace, \"docs/scratch/**\" everything below that directory, and \"*.md\" any Markdown file. A pattern always names files, never directories, so \"docs\" grants nothing but a file called \"docs\"; write \"docs/**\" for a directory's contents. A leading \"./\" means the workspace root, while a leading \"/\" means the filesystem root, so \"/tmp/notes.md\" and \"~/notes.md\" reach outside the workspace. Prefix a pattern with \"!\" to exclude what an earlier line matched; as in .gitignore, the last matching line wins, so order counts. Upper and lower case are distinguished, except on Windows. Workspace-relative patterns are ignored while no folder is open, and nothing is auto-approved unless \"Auto-Approve\" above is enabled.", "readFiles": { "label": "Read allowlist patterns", - "description": "Files Zoo may read without approval, even when \"Read\" above is off. Files in the write allowlist below can always be read too, so they do not need to be listed twice. Directory listings and searches always follow the \"Read\" setting.", + "description": "Files Zoo may read without approval, even when \"Read\" above is off. Files in the write allowlist below can always be read too, so they do not need to be listed twice. Directory listings and searches always follow the \"Read\" setting. Files excluded by .rooignore stay unreadable either way.", "placeholder": "One pattern per line, e.g. notes.md" }, "writeFiles": { "label": "Write allowlist patterns", - "description": "Files Zoo may create and edit without approval, even when \"Write\" above is off. Protected files still require approval unless \"Include protected files\" is enabled (in the settings of the \"Write\" Auto-Approve).", + "description": "Files Zoo may create and edit without approval, even when \"Write\" above is off. Protected files still require approval unless \"Include protected files\" is enabled (in the settings of the \"Write\" Auto-Approve). Files excluded by .rooignore stay unwritable either way.", "placeholder": "One pattern per line, e.g. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 2054e00863..1fd83806b9 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Listas de permitidos", - "description": "Concede acceso a archivos concretos, sin aprobar automáticamente toda una categoría de acciones. Un patrón por línea, con sintaxis inspirada en .gitignore: \"notes.md\" coincide con ese archivo en cualquier directorio del espacio de trabajo, \"docs/scratch/**\" con todo lo que hay debajo de ese directorio y \"*.md\" con cualquier archivo Markdown. Un \"./\" inicial indica la raíz del espacio de trabajo, mientras que un \"/\" inicial indica la raíz del sistema de archivos, así que \"/tmp/notes.md\" y \"~/notes.md\" llegan fuera del espacio de trabajo. Pon \"!\" delante de un patrón para excluir lo que coincidió en una línea anterior; como en .gitignore, gana la última línea que coincide, así que el orden importa. Los patrones relativos al espacio de trabajo se ignoran mientras no haya ninguna carpeta abierta.", + "description": "Concede acceso a archivos concretos, sin aprobar automáticamente toda una categoría de acciones. Un patrón por línea, con sintaxis inspirada en .gitignore: \"notes.md\" coincide con ese archivo en cualquier directorio del espacio de trabajo, \"docs/scratch/**\" con todo lo que hay debajo de ese directorio y \"*.md\" con cualquier archivo Markdown. Un patrón siempre nombra archivos, nunca directorios: \"docs\" solo concede un archivo llamado \"docs\"; escribe \"docs/**\" para el contenido de un directorio. Un \"./\" inicial indica la raíz del espacio de trabajo, mientras que un \"/\" inicial indica la raíz del sistema de archivos, así que \"/tmp/notes.md\" y \"~/notes.md\" llegan fuera del espacio de trabajo. Pon \"!\" delante de un patrón para excluir lo que coincidió en una línea anterior; como en .gitignore, gana la última línea que coincide, así que el orden importa. Los patrones relativos al espacio de trabajo se ignoran mientras no haya ninguna carpeta abierta. Se distinguen mayúsculas y minúsculas, salvo en Windows. No se aprueba nada automáticamente si \"Auto-aprobación\" de arriba está desactivada.", "readFiles": { "label": "Patrones de la lista de permitidos de lectura", - "description": "Archivos que Zoo puede leer sin aprobación, incluso si \"Lectura\" está desactivado arriba. Los archivos de la lista de permitidos de escritura de abajo siempre se pueden leer también, así que no hace falta añadirlos dos veces. Los listados de directorios y las búsquedas siempre siguen la configuración de \"Lectura\".", + "description": "Archivos que Zoo puede leer sin aprobación, incluso si \"Lectura\" está desactivado arriba. Los archivos de la lista de permitidos de escritura de abajo siempre se pueden leer también, así que no hace falta añadirlos dos veces. Los listados de directorios y las búsquedas siempre siguen la configuración de \"Lectura\". Los archivos excluidos por .rooignore siguen sin poder leerse.", "placeholder": "Un patrón por línea, ej. notes.md" }, "writeFiles": { "label": "Patrones de la lista de permitidos de escritura", - "description": "Archivos que Zoo puede crear y editar sin aprobación, incluso si \"Escritura\" está desactivado arriba. Los archivos protegidos siguen requiriendo aprobación a menos que actives \"Incluir archivos protegidos\" (en los ajustes de la auto-aprobación de \"Escritura\").", + "description": "Archivos que Zoo puede crear y editar sin aprobación, incluso si \"Escritura\" está desactivado arriba. Los archivos protegidos siguen requiriendo aprobación a menos que actives \"Incluir archivos protegidos\" (en los ajustes de la auto-aprobación de \"Escritura\"). Los archivos excluidos por .rooignore siguen sin poder escribirse.", "placeholder": "Un patrón por línea, ej. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 9ecbae3fcb..69dd7e2499 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -311,15 +311,15 @@ }, "allowlists": { "label": "Listes d'autorisation", - "description": "Accorde l'accès à des fichiers précis, sans approuver automatiquement toute une catégorie d'actions. Un motif par ligne, dans une syntaxe inspirée de .gitignore : \"notes.md\" correspond à ce fichier dans n'importe quel répertoire de l'espace de travail, \"docs/scratch/**\" à tout ce qui se trouve sous ce répertoire et \"*.md\" à n'importe quel fichier Markdown. Un \"./\" initial désigne la racine de l'espace de travail, tandis qu'un \"/\" initial désigne la racine du système de fichiers, si bien que \"/tmp/notes.md\" et \"~/notes.md\" vont au-delà de l'espace de travail. Préfixe un motif par \"!\" pour exclure ce qu'une ligne précédente a fait correspondre ; comme dans .gitignore, la dernière ligne correspondante l'emporte, l'ordre compte donc. Les motifs relatifs à l'espace de travail sont ignorés tant qu'aucun dossier n'est ouvert.", + "description": "Accorde l'accès à des fichiers précis, sans approuver automatiquement toute une catégorie d'actions. Un motif par ligne, dans une syntaxe inspirée de .gitignore : \"notes.md\" correspond à ce fichier dans n'importe quel répertoire de l'espace de travail, \"docs/scratch/**\" à tout ce qui se trouve sous ce répertoire et \"*.md\" à n'importe quel fichier Markdown. Un motif désigne toujours des fichiers, jamais des répertoires : \"docs\" n'accorde qu'un fichier nommé \"docs\" ; écris \"docs/**\" pour le contenu d'un répertoire. Un \"./\" initial désigne la racine de l'espace de travail, tandis qu'un \"/\" initial désigne la racine du système de fichiers, si bien que \"/tmp/notes.md\" et \"~/notes.md\" vont au-delà de l'espace de travail. Préfixe un motif par \"!\" pour exclure ce qu'une ligne précédente a fait correspondre ; comme dans .gitignore, la dernière ligne correspondante l'emporte, l'ordre compte donc. Les motifs relatifs à l'espace de travail sont ignorés tant qu'aucun dossier n'est ouvert. Les majuscules et les minuscules sont distinguées, sauf sous Windows. Rien n'est approuvé automatiquement tant que \"Approbation automatique\" ci-dessus est désactivée.", "readFiles": { "label": "Motifs de la liste d'autorisation de lecture", - "description": "Fichiers que Zoo peut lire sans approbation, même si \"Lecture\" est désactivé ci-dessus. Les fichiers de la liste d'autorisation d'écriture ci-dessous peuvent toujours être lus aussi, il n'est donc pas nécessaire de les ajouter deux fois. Les listages de répertoires et les recherches suivent toujours le paramètre \"Lecture\".", + "description": "Fichiers que Zoo peut lire sans approbation, même si \"Lecture\" est désactivé ci-dessus. Les fichiers de la liste d'autorisation d'écriture ci-dessous peuvent toujours être lus aussi, il n'est donc pas nécessaire de les ajouter deux fois. Les listages de répertoires et les recherches suivent toujours le paramètre \"Lecture\". Les fichiers exclus par .rooignore restent illisibles malgré tout.", "placeholder": "Un motif par ligne, ex. notes.md" }, "writeFiles": { "label": "Motifs de la liste d'autorisation d'écriture", - "description": "Fichiers que Zoo peut créer et modifier sans approbation, même si \"Écriture\" est désactivé ci-dessus. Les fichiers protégés nécessitent toujours une approbation, sauf si \"Inclure les fichiers protégés\" est activé (dans les paramètres de l'approbation automatique \"Écriture\").", + "description": "Fichiers que Zoo peut créer et modifier sans approbation, même si \"Écriture\" est désactivé ci-dessus. Les fichiers protégés nécessitent toujours une approbation, sauf si \"Inclure les fichiers protégés\" est activé (dans les paramètres de l'approbation automatique \"Écriture\"). Les fichiers exclus par .rooignore restent non modifiables malgré tout.", "placeholder": "Un motif par ligne, ex. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index b906fa4a5c..2bddea9da8 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "अनुमति सूचियाँ", - "description": "कार्रवाइयों की पूरी श्रेणी को स्वतः अनुमोदित किए बिना, अलग-अलग फाइलों तक पहुँच दें। प्रति पंक्ति एक पैटर्न, .gitignore से प्रेरित सिंटैक्स में: \"notes.md\" वर्कस्पेस की किसी भी डायरेक्टरी में उस फाइल से मेल खाता है, \"docs/scratch/**\" उस डायरेक्टरी के नीचे की हर चीज़ से, और \"*.md\" किसी भी Markdown फाइल से। शुरुआती \"./\" का अर्थ वर्कस्पेस रूट है, जबकि शुरुआती \"/\" का अर्थ फाइल सिस्टम रूट है, इसलिए \"/tmp/notes.md\" और \"~/notes.md\" वर्कस्पेस से बाहर पहुँचते हैं। किसी पैटर्न के आगे \"!\" लगाने पर वह पिछली पंक्ति के मेल को बाहर कर देता है; .gitignore की तरह, अंतिम मेल खाने वाली पंक्ति जीतती है, इसलिए क्रम मायने रखता है। जब तक कोई फोल्डर खुला न हो, वर्कस्पेस-सापेक्ष पैटर्न अनदेखे रहते हैं।", + "description": "कार्रवाइयों की पूरी श्रेणी को स्वतः अनुमोदित किए बिना, अलग-अलग फाइलों तक पहुँच दें। प्रति पंक्ति एक पैटर्न, .gitignore से प्रेरित सिंटैक्स में: \"notes.md\" वर्कस्पेस की किसी भी डायरेक्टरी में उस फाइल से मेल खाता है, \"docs/scratch/**\" उस डायरेक्टरी के नीचे की हर चीज़ से, और \"*.md\" किसी भी Markdown फाइल से। पैटर्न हमेशा फाइलों को नाम देता है, डायरेक्टरी को कभी नहीं: \"docs\" केवल \"docs\" नाम की फाइल देता है; डायरेक्टरी की सामग्री के लिए \"docs/**\" लिखें। शुरुआती \"./\" का अर्थ वर्कस्पेस रूट है, जबकि शुरुआती \"/\" का अर्थ फाइल सिस्टम रूट है, इसलिए \"/tmp/notes.md\" और \"~/notes.md\" वर्कस्पेस से बाहर पहुँचते हैं। किसी पैटर्न के आगे \"!\" लगाने पर वह पिछली पंक्ति के मेल को बाहर कर देता है; .gitignore की तरह, अंतिम मेल खाने वाली पंक्ति जीतती है, इसलिए क्रम मायने रखता है। जब तक कोई फोल्डर खुला न हो, वर्कस्पेस-सापेक्ष पैटर्न अनदेखे रहते हैं। बड़े और छोटे अक्षरों में अंतर होता है, Windows को छोड़कर। जब तक ऊपर \"स्वतः-अनुमोदन\" चालू न हो, कुछ भी स्वतः अनुमोदित नहीं होता।", "readFiles": { "label": "पठन अनुमति सूची के पैटर्न", - "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना पढ़ सकता है, तब भी जब ऊपर \"पढ़ें\" बंद हो। नीचे दी गई लेखन अनुमति सूची की फाइलें हमेशा पढ़ी भी जा सकती हैं, इसलिए उन्हें दो बार जोड़ने की आवश्यकता नहीं है। डायरेक्टरी सूचियाँ और खोजें हमेशा \"पढ़ें\" सेटिंग का पालन करती हैं।", + "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना पढ़ सकता है, तब भी जब ऊपर \"पढ़ें\" बंद हो। नीचे दी गई लेखन अनुमति सूची की फाइलें हमेशा पढ़ी भी जा सकती हैं, इसलिए उन्हें दो बार जोड़ने की आवश्यकता नहीं है। डायरेक्टरी सूचियाँ और खोजें हमेशा \"पढ़ें\" सेटिंग का पालन करती हैं। .rooignore से बाहर रखी फाइलें फिर भी नहीं पढ़ी जा सकतीं।", "placeholder": "प्रति पंक्ति एक पैटर्न, उदा. notes.md" }, "writeFiles": { "label": "लेखन अनुमति सूची के पैटर्न", - "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना बना और संपादित कर सकता है, तब भी जब ऊपर \"लिखें\" बंद हो। संरक्षित फाइलों के लिए अनुमोदन तब तक आवश्यक रहता है जब तक \"संरक्षित फाइलें शामिल करें\" सक्षम न हो (\"लिखें\" स्वतः-अनुमोदन की सेटिंग्स में)।", + "description": "वे फाइलें जिन्हें Zoo अनुमोदन के बिना बना और संपादित कर सकता है, तब भी जब ऊपर \"लिखें\" बंद हो। संरक्षित फाइलों के लिए अनुमोदन तब तक आवश्यक रहता है जब तक \"संरक्षित फाइलें शामिल करें\" सक्षम न हो (\"लिखें\" स्वतः-अनुमोदन की सेटिंग्स में)। .rooignore से बाहर रखी फाइलें फिर भी नहीं लिखी जा सकतीं।", "placeholder": "प्रति पंक्ति एक पैटर्न, उदा. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 86e6017624..289eceef65 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Daftar izin", - "description": "Berikan akses ke file tertentu, tanpa menyetujui otomatis seluruh kategori tindakan. Satu pola per baris, dengan sintaks yang diilhami .gitignore: \"notes.md\" cocok dengan file itu di direktori mana pun dalam workspace, \"docs/scratch/**\" semua yang ada di bawah direktori itu, dan \"*.md\" file Markdown apa pun. Awalan \"./\" berarti root workspace, sedangkan awalan \"/\" berarti root sistem file, sehingga \"/tmp/notes.md\" dan \"~/notes.md\" menjangkau di luar workspace. Beri awalan \"!\" pada pola untuk mengecualikan apa yang dicocokkan baris sebelumnya; seperti pada .gitignore, baris terakhir yang cocok menang, jadi urutan penting. Pola relatif terhadap workspace diabaikan selama tidak ada folder yang terbuka.", + "description": "Berikan akses ke file tertentu, tanpa menyetujui otomatis seluruh kategori tindakan. Satu pola per baris, dengan sintaks yang diilhami .gitignore: \"notes.md\" cocok dengan file itu di direktori mana pun dalam workspace, \"docs/scratch/**\" semua yang ada di bawah direktori itu, dan \"*.md\" file Markdown apa pun. Pola selalu menamai file, bukan direktori: \"docs\" hanya memberi akses ke file bernama \"docs\"; tulis \"docs/**\" untuk isi sebuah direktori. Awalan \"./\" berarti root workspace, sedangkan awalan \"/\" berarti root sistem file, sehingga \"/tmp/notes.md\" dan \"~/notes.md\" menjangkau di luar workspace. Beri awalan \"!\" pada pola untuk mengecualikan apa yang dicocokkan baris sebelumnya; seperti pada .gitignore, baris terakhir yang cocok menang, jadi urutan penting. Pola relatif terhadap workspace diabaikan selama tidak ada folder yang terbuka. Huruf besar dan kecil dibedakan, kecuali di Windows. Tidak ada yang disetujui otomatis selama \"Persetujuan Otomatis\" di atas nonaktif.", "readFiles": { "label": "Pola daftar izin baca", - "description": "File yang boleh dibaca Zoo tanpa persetujuan, bahkan ketika \"Baca\" di atas nonaktif. File pada daftar izin tulis di bawah selalu boleh dibaca juga, jadi tidak perlu ditambahkan dua kali. Daftar direktori dan pencarian selalu mengikuti pengaturan \"Baca\".", + "description": "File yang boleh dibaca Zoo tanpa persetujuan, bahkan ketika \"Baca\" di atas nonaktif. File pada daftar izin tulis di bawah selalu boleh dibaca juga, jadi tidak perlu ditambahkan dua kali. Daftar direktori dan pencarian selalu mengikuti pengaturan \"Baca\". File yang dikecualikan oleh .rooignore tetap tidak bisa dibaca.", "placeholder": "Satu pola per baris, misalnya notes.md" }, "writeFiles": { "label": "Pola daftar izin tulis", - "description": "File yang boleh dibuat dan diedit Zoo tanpa persetujuan, bahkan ketika \"Tulis\" di atas nonaktif. File yang dilindungi tetap memerlukan persetujuan kecuali \"Sertakan file yang dilindungi\" diaktifkan (di pengaturan persetujuan otomatis \"Tulis\").", + "description": "File yang boleh dibuat dan diedit Zoo tanpa persetujuan, bahkan ketika \"Tulis\" di atas nonaktif. File yang dilindungi tetap memerlukan persetujuan kecuali \"Sertakan file yang dilindungi\" diaktifkan (di pengaturan persetujuan otomatis \"Tulis\"). File yang dikecualikan oleh .rooignore tetap tidak bisa ditulis.", "placeholder": "Satu pola per baris, misalnya notes.md" } }, diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index f9b6b128a2..418e8e33d0 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Liste di autorizzazione", - "description": "Concedi l'accesso a singoli file, senza approvare automaticamente un'intera categoria di azioni. Un pattern per riga, con sintassi ispirata a .gitignore: \"notes.md\" corrisponde a quel file in qualsiasi directory del workspace, \"docs/scratch/**\" a tutto ciò che si trova sotto quella directory e \"*.md\" a qualsiasi file Markdown. Un \"./\" iniziale indica la radice del workspace, mentre un \"/\" iniziale indica la radice del filesystem, così \"/tmp/notes.md\" e \"~/notes.md\" arrivano fuori dal workspace. Metti \"!\" davanti a un pattern per escludere ciò che una riga precedente ha già trovato; come in .gitignore, vince l'ultima riga corrispondente, quindi l'ordine conta. I pattern relativi al workspace vengono ignorati finché non è aperta nessuna cartella.", + "description": "Concedi l'accesso a singoli file, senza approvare automaticamente un'intera categoria di azioni. Un pattern per riga, con sintassi ispirata a .gitignore: \"notes.md\" corrisponde a quel file in qualsiasi directory del workspace, \"docs/scratch/**\" a tutto ciò che si trova sotto quella directory e \"*.md\" a qualsiasi file Markdown. Un pattern nomina sempre file, mai directory: \"docs\" concede solo un file chiamato \"docs\"; per il contenuto di una directory scrivi \"docs/**\". Un \"./\" iniziale indica la radice del workspace, mentre un \"/\" iniziale indica la radice del filesystem, così \"/tmp/notes.md\" e \"~/notes.md\" arrivano fuori dal workspace. Metti \"!\" davanti a un pattern per escludere ciò che una riga precedente ha già trovato; come in .gitignore, vince l'ultima riga corrispondente, quindi l'ordine conta. I pattern relativi al workspace vengono ignorati finché non è aperta nessuna cartella. Maiuscole e minuscole vengono distinte, tranne su Windows. Niente viene approvato automaticamente se \"Auto-approvazione\" sopra è disattivata.", "readFiles": { "label": "Pattern della lista di autorizzazione in lettura", - "description": "File che Zoo può leggere senza approvazione, anche quando \"Leggi\" sopra è disattivato. I file della lista di autorizzazione in scrittura qui sotto possono sempre essere letti, quindi non serve aggiungerli due volte. Gli elenchi di directory e le ricerche seguono sempre l'impostazione \"Leggi\".", + "description": "File che Zoo può leggere senza approvazione, anche quando \"Leggi\" sopra è disattivato. I file della lista di autorizzazione in scrittura qui sotto possono sempre essere letti, quindi non serve aggiungerli due volte. Gli elenchi di directory e le ricerche seguono sempre l'impostazione \"Leggi\". I file esclusi da .rooignore restano comunque illeggibili.", "placeholder": "Un pattern per riga, es. notes.md" }, "writeFiles": { "label": "Pattern della lista di autorizzazione in scrittura", - "description": "File che Zoo può creare e modificare senza approvazione, anche quando \"Scrivi\" sopra è disattivato. I file protetti richiedono ancora l'approvazione, a meno che \"Includi file protetti\" non sia abilitato (nelle impostazioni dell'auto-approvazione \"Scrivi\").", + "description": "File che Zoo può creare e modificare senza approvazione, anche quando \"Scrivi\" sopra è disattivato. I file protetti richiedono ancora l'approvazione, a meno che \"Includi file protetti\" non sia abilitato (nelle impostazioni dell'auto-approvazione \"Scrivi\"). I file esclusi da .rooignore restano comunque non scrivibili.", "placeholder": "Un pattern per riga, es. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 49a30c858d..e42d7b3c2a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "許可リスト", - "description": "操作のカテゴリ全体を自動承認せずに、個々のファイルへのアクセスを許可します。1行に1つのパターンを、.gitignore を参考にした構文で記述します。\"notes.md\" はワークスペース内の任意のディレクトリにあるそのファイルに一致し、\"docs/scratch/**\" はそのディレクトリ配下のすべて、\"*.md\" は任意の Markdown ファイルに一致します。先頭の \"./\" はワークスペースのルートを意味し、先頭の \"/\" はファイルシステムのルートを意味するため、\"/tmp/notes.md\" や \"~/notes.md\" はワークスペースの外に届きます。パターンの先頭に \"!\" を付けると、前の行で一致したものを除外します。.gitignore と同様に最後に一致した行が優先されるため、順序が重要です。フォルダーを開いていない間は、ワークスペース相対のパターンは無視されます。", + "description": "操作のカテゴリ全体を自動承認せずに、個々のファイルへのアクセスを許可します。1行に1つのパターンを、.gitignore を参考にした構文で記述します。\"notes.md\" はワークスペース内の任意のディレクトリにあるそのファイルに一致し、\"docs/scratch/**\" はそのディレクトリ配下のすべて、\"*.md\" は任意の Markdown ファイルに一致します。パターンが指すのは常にファイルで、ディレクトリではありません。\"docs\" は \"docs\" という名前のファイルだけを許可します。ディレクトリの中身には \"docs/**\" と書きます。先頭の \"./\" はワークスペースのルートを意味し、先頭の \"/\" はファイルシステムのルートを意味するため、\"/tmp/notes.md\" や \"~/notes.md\" はワークスペースの外に届きます。パターンの先頭に \"!\" を付けると、前の行で一致したものを除外します。.gitignore と同様に最後に一致した行が優先されるため、順序が重要です。フォルダーを開いていない間は、ワークスペース相対のパターンは無視されます。大文字と小文字は区別されます(Windows を除く)。上の「自動承認」がオフの間は、何も自動承認されません。", "readFiles": { "label": "読み取り許可リストのパターン", - "description": "上の「読み取り」がオフでも、Zooが承認なしで読み取れるファイルです。下の書き込み許可リストのファイルは常に読み取りもできるため、二重に追加する必要はありません。ディレクトリの一覧表示と検索は常に「読み取り」設定に従います。", + "description": "上の「読み取り」がオフでも、Zooが承認なしで読み取れるファイルです。下の書き込み許可リストのファイルは常に読み取りもできるため、二重に追加する必要はありません。ディレクトリの一覧表示と検索は常に「読み取り」設定に従います。.rooignore で除外されたファイルは、いずれにせよ読み取れません。", "placeholder": "1行に1つのパターン(例:notes.md)" }, "writeFiles": { "label": "書き込み許可リストのパターン", - "description": "上の「書き込み」がオフでも、Zooが承認なしで作成・編集できるファイルです。保護されたファイルは、「保護されたファイルを含める」を有効にしない限り(「書き込み」自動承認の設定内)、引き続き承認が必要です。", + "description": "上の「書き込み」がオフでも、Zooが承認なしで作成・編集できるファイルです。保護されたファイルは、「保護されたファイルを含める」を有効にしない限り(「書き込み」自動承認の設定内)、引き続き承認が必要です。.rooignore で除外されたファイルは、いずれにせよ書き込めません。", "placeholder": "1行に1つのパターン(例:notes.md)" } }, diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 83bc49e1e1..fe7456a9c3 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "허용 목록", - "description": "작업 범주 전체를 자동 승인하지 않고 개별 파일에 대한 접근을 허용합니다. 한 줄에 하나의 패턴을 .gitignore에서 착안한 구문으로 입력합니다. \"notes.md\"는 워크스페이스의 모든 디렉터리에서 해당 파일과 일치하고, \"docs/scratch/**\"는 해당 디렉터리 아래의 모든 것과, \"*.md\"는 모든 Markdown 파일과 일치합니다. 맨 앞의 \"./\"는 워크스페이스 루트를 뜻하고 맨 앞의 \"/\"는 파일 시스템 루트를 뜻하므로, \"/tmp/notes.md\"와 \"~/notes.md\"는 워크스페이스 밖까지 미칩니다. 패턴 앞에 \"!\"를 붙이면 앞선 줄이 일치시킨 것을 제외합니다. .gitignore와 마찬가지로 마지막에 일치한 줄이 우선하므로 순서가 중요합니다. 열린 폴더가 없는 동안에는 워크스페이스 기준 패턴이 무시됩니다.", + "description": "작업 범주 전체를 자동 승인하지 않고 개별 파일에 대한 접근을 허용합니다. 한 줄에 하나의 패턴을 .gitignore에서 착안한 구문으로 입력합니다. \"notes.md\"는 워크스페이스의 모든 디렉터리에서 해당 파일과 일치하고, \"docs/scratch/**\"는 해당 디렉터리 아래의 모든 것과, \"*.md\"는 모든 Markdown 파일과 일치합니다. 패턴은 항상 파일을 가리키며 디렉터리는 가리키지 않습니다. \"docs\"는 \"docs\"라는 이름의 파일만 허용하고, 디렉터리의 내용에는 \"docs/**\"라고 씁니다. 맨 앞의 \"./\"는 워크스페이스 루트를 뜻하고 맨 앞의 \"/\"는 파일 시스템 루트를 뜻하므로, \"/tmp/notes.md\"와 \"~/notes.md\"는 워크스페이스 밖까지 미칩니다. 패턴 앞에 \"!\"를 붙이면 앞선 줄이 일치시킨 것을 제외합니다. .gitignore와 마찬가지로 마지막에 일치한 줄이 우선하므로 순서가 중요합니다. 열린 폴더가 없는 동안에는 워크스페이스 기준 패턴이 무시됩니다. 대소문자를 구분합니다(Windows는 예외). 위의 \"자동 승인\"이 꺼져 있는 동안에는 아무것도 자동 승인되지 않습니다.", "readFiles": { "label": "읽기 허용 목록 패턴", - "description": "위의 \"읽기\"가 꺼져 있어도 Zoo가 승인 없이 읽을 수 있는 파일입니다. 아래 쓰기 허용 목록의 파일은 항상 읽을 수도 있으므로 두 번 추가할 필요가 없습니다. 디렉터리 목록과 검색은 항상 \"읽기\" 설정을 따릅니다.", + "description": "위의 \"읽기\"가 꺼져 있어도 Zoo가 승인 없이 읽을 수 있는 파일입니다. 아래 쓰기 허용 목록의 파일은 항상 읽을 수도 있으므로 두 번 추가할 필요가 없습니다. 디렉터리 목록과 검색은 항상 \"읽기\" 설정을 따릅니다. .rooignore로 제외된 파일은 어느 쪽이든 읽을 수 없습니다.", "placeholder": "한 줄에 하나의 패턴(예: notes.md)" }, "writeFiles": { "label": "쓰기 허용 목록 패턴", - "description": "위의 \"쓰기\"가 꺼져 있어도 Zoo가 승인 없이 생성하고 편집할 수 있는 파일입니다. 보호된 파일은 (\"쓰기\" 자동 승인 설정에서) \"보호된 파일 포함\"을 켜지 않는 한 계속 승인이 필요합니다.", + "description": "위의 \"쓰기\"가 꺼져 있어도 Zoo가 승인 없이 생성하고 편집할 수 있는 파일입니다. 보호된 파일은 (\"쓰기\" 자동 승인 설정에서) \"보호된 파일 포함\"을 켜지 않는 한 계속 승인이 필요합니다. .rooignore로 제외된 파일은 어느 쪽이든 쓸 수 없습니다.", "placeholder": "한 줄에 하나의 패턴(예: notes.md)" } }, diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 83fd51ef08..55fc767658 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Toelatingslijsten", - "description": "Geef toegang tot afzonderlijke bestanden, zonder een hele categorie acties automatisch goed te keuren. Eén patroon per regel, in door .gitignore geïnspireerde syntaxis: \"notes.md\" komt overeen met dat bestand in elke map van de workspace, \"docs/scratch/**\" met alles onder die map en \"*.md\" met elk Markdown-bestand. Een \"./\" aan het begin betekent de root van de workspace, terwijl een \"/\" aan het begin de root van het bestandssysteem betekent, zodat \"/tmp/notes.md\" en \"~/notes.md\" tot buiten de workspace reiken. Zet \"!\" voor een patroon om uit te sluiten wat een eerdere regel heeft gevonden; net als in .gitignore wint de laatste regel die overeenkomt, dus de volgorde telt. Patronen die relatief zijn aan de workspace worden genegeerd zolang er geen map is geopend.", + "description": "Geef toegang tot afzonderlijke bestanden, zonder een hele categorie acties automatisch goed te keuren. Eén patroon per regel, in door .gitignore geïnspireerde syntaxis: \"notes.md\" komt overeen met dat bestand in elke map van de workspace, \"docs/scratch/**\" met alles onder die map en \"*.md\" met elk Markdown-bestand. Een patroon benoemt altijd bestanden, nooit mappen: \"docs\" geeft alleen toegang tot een bestand met de naam \"docs\"; schrijf \"docs/**\" voor de inhoud van een map. Een \"./\" aan het begin betekent de root van de workspace, terwijl een \"/\" aan het begin de root van het bestandssysteem betekent, zodat \"/tmp/notes.md\" en \"~/notes.md\" tot buiten de workspace reiken. Zet \"!\" voor een patroon om uit te sluiten wat een eerdere regel heeft gevonden; net als in .gitignore wint de laatste regel die overeenkomt, dus de volgorde telt. Patronen die relatief zijn aan de workspace worden genegeerd zolang er geen map is geopend. Hoofd- en kleine letters worden onderscheiden, behalve op Windows. Er wordt niets automatisch goedgekeurd zolang \"Automatisch goedkeuren\" hierboven uit staat.", "readFiles": { "label": "Patronen van de lees-toelatingslijst", - "description": "Bestanden die Zoo zonder goedkeuring mag lezen, ook als \"Lezen\" hierboven uit staat. Bestanden uit de schrijf-toelatingslijst hieronder mogen altijd ook gelezen worden, dus die hoef je niet twee keer toe te voegen. Mapoverzichten en zoekopdrachten volgen altijd de \"Lezen\"-instelling.", + "description": "Bestanden die Zoo zonder goedkeuring mag lezen, ook als \"Lezen\" hierboven uit staat. Bestanden uit de schrijf-toelatingslijst hieronder mogen altijd ook gelezen worden, dus die hoef je niet twee keer toe te voegen. Mapoverzichten en zoekopdrachten volgen altijd de \"Lezen\"-instelling. Bestanden die .rooignore uitsluit, blijven hoe dan ook onleesbaar.", "placeholder": "Eén patroon per regel, bijv. notes.md" }, "writeFiles": { "label": "Patronen van de schrijf-toelatingslijst", - "description": "Bestanden die Zoo zonder goedkeuring mag aanmaken en bewerken, ook als \"Schrijven\" hierboven uit staat. Beschermde bestanden vereisen nog steeds goedkeuring, tenzij \"Inclusief beschermde bestanden\" is ingeschakeld (in de instellingen van de \"Schrijven\"-autogoedkeuring).", + "description": "Bestanden die Zoo zonder goedkeuring mag aanmaken en bewerken, ook als \"Schrijven\" hierboven uit staat. Beschermde bestanden vereisen nog steeds goedkeuring, tenzij \"Inclusief beschermde bestanden\" is ingeschakeld (in de instellingen van de \"Schrijven\"-autogoedkeuring). Bestanden die .rooignore uitsluit, blijven hoe dan ook niet beschrijfbaar.", "placeholder": "Eén patroon per regel, bijv. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 1679eb88b8..24630f1a26 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Listy dozwolonych", - "description": "Przyznaj dostęp do pojedynczych plików, bez automatycznego zatwierdzania całej kategorii działań. Jeden wzorzec na wiersz, w składni inspirowanej .gitignore: \"notes.md\" pasuje do tego pliku w dowolnym katalogu obszaru roboczego, \"docs/scratch/**\" do wszystkiego poniżej tego katalogu, a \"*.md\" do dowolnego pliku Markdown. Początkowe \"./\" oznacza katalog główny obszaru roboczego, natomiast początkowe \"/\" oznacza katalog główny systemu plików, więc \"/tmp/notes.md\" i \"~/notes.md\" sięgają poza obszar roboczy. Poprzedź wzorzec znakiem \"!\", aby wykluczyć to, co dopasował wcześniejszy wiersz; tak jak w .gitignore wygrywa ostatni pasujący wiersz, więc kolejność ma znaczenie. Wzorce względne wobec obszaru roboczego są ignorowane, dopóki nie jest otwarty żaden folder.", + "description": "Przyznaj dostęp do pojedynczych plików, bez automatycznego zatwierdzania całej kategorii działań. Jeden wzorzec na wiersz, w składni inspirowanej .gitignore: \"notes.md\" pasuje do tego pliku w dowolnym katalogu obszaru roboczego, \"docs/scratch/**\" do wszystkiego poniżej tego katalogu, a \"*.md\" do dowolnego pliku Markdown. Wzorzec zawsze nazywa pliki, nigdy katalogi: \"docs\" przyznaje dostęp tylko do pliku o nazwie \"docs\"; zawartość katalogu zapisz jako \"docs/**\". Początkowe \"./\" oznacza katalog główny obszaru roboczego, natomiast początkowe \"/\" oznacza katalog główny systemu plików, więc \"/tmp/notes.md\" i \"~/notes.md\" sięgają poza obszar roboczy. Poprzedź wzorzec znakiem \"!\", aby wykluczyć to, co dopasował wcześniejszy wiersz; tak jak w .gitignore wygrywa ostatni pasujący wiersz, więc kolejność ma znaczenie. Wzorce względne wobec obszaru roboczego są ignorowane, dopóki nie jest otwarty żaden folder. Wielkość liter ma znaczenie, z wyjątkiem Windows. Dopóki \"Automatyczne zatwierdzanie\" powyżej jest wyłączone, nic nie jest zatwierdzane automatycznie.", "readFiles": { "label": "Wzorce listy dozwolonych do odczytu", - "description": "Pliki, które Zoo może czytać bez zatwierdzania, nawet gdy \"Odczyt\" powyżej jest wyłączony. Pliki z listy dozwolonych do zapisu poniżej zawsze można także czytać, więc nie trzeba ich dodawać dwukrotnie. Listowanie katalogów i wyszukiwanie zawsze podlegają ustawieniu \"Odczyt\".", + "description": "Pliki, które Zoo może czytać bez zatwierdzania, nawet gdy \"Odczyt\" powyżej jest wyłączony. Pliki z listy dozwolonych do zapisu poniżej zawsze można także czytać, więc nie trzeba ich dodawać dwukrotnie. Listowanie katalogów i wyszukiwanie zawsze podlegają ustawieniu \"Odczyt\". Pliki wykluczone przez .rooignore i tak pozostają nieczytelne.", "placeholder": "Jeden wzorzec na wiersz, np. notes.md" }, "writeFiles": { "label": "Wzorce listy dozwolonych do zapisu", - "description": "Pliki, które Zoo może tworzyć i edytować bez zatwierdzania, nawet gdy \"Zapis\" powyżej jest wyłączony. Pliki chronione nadal wymagają zatwierdzenia, chyba że włączysz \"Uwzględnij pliki chronione\" (w ustawieniach automatycznego zatwierdzania \"Zapis\").", + "description": "Pliki, które Zoo może tworzyć i edytować bez zatwierdzania, nawet gdy \"Zapis\" powyżej jest wyłączony. Pliki chronione nadal wymagają zatwierdzenia, chyba że włączysz \"Uwzględnij pliki chronione\" (w ustawieniach automatycznego zatwierdzania \"Zapis\"). Pliki wykluczone przez .rooignore i tak pozostają niezapisywalne.", "placeholder": "Jeden wzorzec na wiersz, np. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index b6cab5c825..4921e008e0 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Listas de permissão", - "description": "Conceda acesso a arquivos específicos, sem aprovar automaticamente toda uma categoria de ações. Um padrão por linha, em sintaxe inspirada no .gitignore: \"notes.md\" corresponde a esse arquivo em qualquer diretório do espaço de trabalho, \"docs/scratch/**\" a tudo abaixo desse diretório e \"*.md\" a qualquer arquivo Markdown. Um \"./\" inicial indica a raiz do espaço de trabalho, enquanto um \"/\" inicial indica a raiz do sistema de arquivos, de modo que \"/tmp/notes.md\" e \"~/notes.md\" alcançam fora do espaço de trabalho. Prefixe um padrão com \"!\" para excluir o que uma linha anterior correspondeu; como no .gitignore, a última linha correspondente vence, então a ordem importa. Padrões relativos ao espaço de trabalho são ignorados enquanto nenhuma pasta estiver aberta.", + "description": "Conceda acesso a arquivos específicos, sem aprovar automaticamente toda uma categoria de ações. Um padrão por linha, em sintaxe inspirada no .gitignore: \"notes.md\" corresponde a esse arquivo em qualquer diretório do espaço de trabalho, \"docs/scratch/**\" a tudo abaixo desse diretório e \"*.md\" a qualquer arquivo Markdown. Um padrão sempre nomeia arquivos, nunca diretórios: \"docs\" concede apenas um arquivo chamado \"docs\"; escreva \"docs/**\" para o conteúdo de um diretório. Um \"./\" inicial indica a raiz do espaço de trabalho, enquanto um \"/\" inicial indica a raiz do sistema de arquivos, de modo que \"/tmp/notes.md\" e \"~/notes.md\" alcançam fora do espaço de trabalho. Prefixe um padrão com \"!\" para excluir o que uma linha anterior correspondeu; como no .gitignore, a última linha correspondente vence, então a ordem importa. Padrões relativos ao espaço de trabalho são ignorados enquanto nenhuma pasta estiver aberta. Maiúsculas e minúsculas são diferenciadas, exceto no Windows. Nada é aprovado automaticamente enquanto \"Aprovação automática\" acima estiver desativada.", "readFiles": { "label": "Padrões da lista de permissão de leitura", - "description": "Arquivos que o Zoo pode ler sem aprovação, mesmo quando \"Leitura\" acima está desativado. Os arquivos da lista de permissão de escrita abaixo sempre podem ser lidos também, então não precisam ser adicionados duas vezes. Listagens de diretórios e buscas sempre seguem a configuração \"Leitura\".", + "description": "Arquivos que o Zoo pode ler sem aprovação, mesmo quando \"Leitura\" acima está desativado. Os arquivos da lista de permissão de escrita abaixo sempre podem ser lidos também, então não precisam ser adicionados duas vezes. Listagens de diretórios e buscas sempre seguem a configuração \"Leitura\". Arquivos excluídos pelo .rooignore continuam ilegíveis de qualquer forma.", "placeholder": "Um padrão por linha, ex. notes.md" }, "writeFiles": { "label": "Padrões da lista de permissão de escrita", - "description": "Arquivos que o Zoo pode criar e editar sem aprovação, mesmo quando \"Escrita\" acima está desativado. Arquivos protegidos continuam exigindo aprovação, a menos que \"Incluir arquivos protegidos\" esteja ativado (nas configurações da aprovação automática de \"Escrita\").", + "description": "Arquivos que o Zoo pode criar e editar sem aprovação, mesmo quando \"Escrita\" acima está desativado. Arquivos protegidos continuam exigindo aprovação, a menos que \"Incluir arquivos protegidos\" esteja ativado (nas configurações da aprovação automática de \"Escrita\"). Arquivos excluídos pelo .rooignore continuam não graváveis de qualquer forma.", "placeholder": "Um padrão por linha, ex. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b04c237ed1..8101af9500 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Списки разрешений", - "description": "Предоставь доступ к отдельным файлам, не одобряя автоматически целую категорию действий. По одному шаблону в строке, в синтаксисе по мотивам .gitignore: \"notes.md\" совпадает с этим файлом в любом каталоге рабочей области, \"docs/scratch/**\" — со всем, что находится внутри этого каталога, а \"*.md\" — с любым файлом Markdown. Ведущее \"./\" означает корень рабочей области, а ведущее \"/\" — корень файловой системы, поэтому \"/tmp/notes.md\" и \"~/notes.md\" выходят за пределы рабочей области. Поставь \"!\" перед шаблоном, чтобы исключить то, что совпало в предыдущей строке; как и в .gitignore, побеждает последняя совпавшая строка, поэтому порядок важен. Пока не открыта ни одна папка, шаблоны относительно рабочей области игнорируются.", + "description": "Предоставь доступ к отдельным файлам, не одобряя автоматически целую категорию действий. По одному шаблону в строке, в синтаксисе по мотивам .gitignore: \"notes.md\" совпадает с этим файлом в любом каталоге рабочей области, \"docs/scratch/**\" — со всем, что находится внутри этого каталога, а \"*.md\" — с любым файлом Markdown. Шаблон всегда называет файлы, а не каталоги: \"docs\" даёт доступ только к файлу с именем \"docs\"; для содержимого каталога напиши \"docs/**\". Ведущее \"./\" означает корень рабочей области, а ведущее \"/\" — корень файловой системы, поэтому \"/tmp/notes.md\" и \"~/notes.md\" выходят за пределы рабочей области. Поставь \"!\" перед шаблоном, чтобы исключить то, что совпало в предыдущей строке; как и в .gitignore, побеждает последняя совпавшая строка, поэтому порядок важен. Пока не открыта ни одна папка, шаблоны относительно рабочей области игнорируются. Регистр букв учитывается, кроме Windows. Пока \"Автоодобрение\" выше выключено, ничего не одобряется автоматически.", "readFiles": { "label": "Шаблоны списка разрешений на чтение", - "description": "Файлы, которые Zoo может читать без одобрения, даже когда \"Чтение\" выше выключено. Файлы из списка разрешений на запись ниже всегда можно и читать, поэтому добавлять их дважды не нужно. Просмотр каталогов и поиск всегда подчиняются настройке \"Чтение\".", + "description": "Файлы, которые Zoo может читать без одобрения, даже когда \"Чтение\" выше выключено. Файлы из списка разрешений на запись ниже всегда можно и читать, поэтому добавлять их дважды не нужно. Просмотр каталогов и поиск всегда подчиняются настройке \"Чтение\". Файлы, исключённые в .rooignore, всё равно остаются недоступными для чтения.", "placeholder": "По одному шаблону в строке, например notes.md" }, "writeFiles": { "label": "Шаблоны списка разрешений на запись", - "description": "Файлы, которые Zoo может создавать и редактировать без одобрения, даже когда \"Запись\" выше выключена. Защищенные файлы по-прежнему требуют одобрения, если не включено \"Включить защищенные файлы\" (в настройках автоодобрения \"Запись\").", + "description": "Файлы, которые Zoo может создавать и редактировать без одобрения, даже когда \"Запись\" выше выключена. Защищенные файлы по-прежнему требуют одобрения, если не включено \"Включить защищенные файлы\" (в настройках автоодобрения \"Запись\"). Файлы, исключённые в .rooignore, всё равно остаются недоступными для записи.", "placeholder": "По одному шаблону в строке, например notes.md" } }, diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index e29f57345d..4f0cf4656b 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "İzin listeleri", - "description": "Bütün bir işlem kategorisini otomatik onaylamadan, tek tek dosyalara erişim ver. Her satıra bir desen, .gitignore'dan esinlenen sözdizimiyle: \"notes.md\" çalışma alanındaki herhangi bir dizinde o dosyayla eşleşir, \"docs/scratch/**\" o dizinin altındaki her şeyle ve \"*.md\" herhangi bir Markdown dosyasıyla eşleşir. Baştaki \"./\" çalışma alanı kökünü, baştaki \"/\" ise dosya sisteminin kökünü belirtir; böylece \"/tmp/notes.md\" ve \"~/notes.md\" çalışma alanının dışına uzanır. Bir desenin önüne \"!\" koyarak önceki bir satırın eşleştirdiğini dışarıda bırak; .gitignore'da olduğu gibi eşleşen son satır kazanır, yani sıra önemlidir. Hiçbir klasör açık değilken çalışma alanına göreli desenler yok sayılır.", + "description": "Bütün bir işlem kategorisini otomatik onaylamadan, tek tek dosyalara erişim ver. Her satıra bir desen, .gitignore'dan esinlenen sözdizimiyle: \"notes.md\" çalışma alanındaki herhangi bir dizinde o dosyayla eşleşir, \"docs/scratch/**\" o dizinin altındaki her şeyle ve \"*.md\" herhangi bir Markdown dosyasıyla eşleşir. Bir desen her zaman dosyaları adlandırır, dizinleri asla: \"docs\" yalnızca \"docs\" adlı bir dosyaya izin verir; bir dizinin içeriği için \"docs/**\" yaz. Baştaki \"./\" çalışma alanı kökünü, baştaki \"/\" ise dosya sisteminin kökünü belirtir; böylece \"/tmp/notes.md\" ve \"~/notes.md\" çalışma alanının dışına uzanır. Bir desenin önüne \"!\" koyarak önceki bir satırın eşleştirdiğini dışarıda bırak; .gitignore'da olduğu gibi eşleşen son satır kazanır, yani sıra önemlidir. Hiçbir klasör açık değilken çalışma alanına göreli desenler yok sayılır. Büyük ve küçük harf ayırt edilir, Windows dışında. Yukarıdaki \"Otomatik Onay\" kapalıyken hiçbir şey otomatik onaylanmaz.", "readFiles": { "label": "Okuma izin listesi desenleri", - "description": "Yukarıdaki \"Okuma\" kapalı olsa bile Zoo'nun onay almadan okuyabileceği dosyalar. Aşağıdaki yazma izin listesindeki dosyalar her zaman okunabilir de, bu yüzden iki kez eklenmeleri gerekmez. Dizin listelemeleri ve aramalar her zaman \"Okuma\" ayarını izler.", + "description": "Yukarıdaki \"Okuma\" kapalı olsa bile Zoo'nun onay almadan okuyabileceği dosyalar. Aşağıdaki yazma izin listesindeki dosyalar her zaman okunabilir de, bu yüzden iki kez eklenmeleri gerekmez. Dizin listelemeleri ve aramalar her zaman \"Okuma\" ayarını izler. .rooignore ile dışlanan dosyalar yine de okunamaz.", "placeholder": "Her satıra bir desen, örn. notes.md" }, "writeFiles": { "label": "Yazma izin listesi desenleri", - "description": "Yukarıdaki \"Yazma\" kapalı olsa bile Zoo'nun onay almadan oluşturup düzenleyebileceği dosyalar. \"Korumalı dosyaları dahil et\" etkinleştirilmediği sürece (\"Yazma\" otomatik onayının ayarlarında) korumalı dosyalar yine onay gerektirir.", + "description": "Yukarıdaki \"Yazma\" kapalı olsa bile Zoo'nun onay almadan oluşturup düzenleyebileceği dosyalar. \"Korumalı dosyaları dahil et\" etkinleştirilmediği sürece (\"Yazma\" otomatik onayının ayarlarında) korumalı dosyalar yine onay gerektirir. .rooignore ile dışlanan dosyalar yine de yazılamaz.", "placeholder": "Her satıra bir desen, örn. notes.md" } }, diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index bb447a928c..d5ed4650e7 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "Danh sách cho phép", - "description": "Cấp quyền truy cập vào từng tệp cụ thể, mà không tự động phê duyệt cả một nhóm hành động. Mỗi dòng một mẫu, theo cú pháp lấy cảm hứng từ .gitignore: \"notes.md\" khớp với tệp đó trong bất kỳ thư mục nào của workspace, \"docs/scratch/**\" khớp với mọi thứ bên dưới thư mục đó, và \"*.md\" khớp với mọi tệp Markdown. Dấu \"./\" ở đầu chỉ gốc workspace, còn dấu \"/\" ở đầu chỉ gốc hệ thống tệp, nên \"/tmp/notes.md\" và \"~/notes.md\" vươn ra ngoài workspace. Thêm \"!\" trước một mẫu để loại trừ những gì dòng trước đã khớp; như trong .gitignore, dòng khớp cuối cùng thắng, nên thứ tự có ý nghĩa. Khi chưa mở thư mục nào, các mẫu tương đối với workspace sẽ bị bỏ qua.", + "description": "Cấp quyền truy cập vào từng tệp cụ thể, mà không tự động phê duyệt cả một nhóm hành động. Mỗi dòng một mẫu, theo cú pháp lấy cảm hứng từ .gitignore: \"notes.md\" khớp với tệp đó trong bất kỳ thư mục nào của workspace, \"docs/scratch/**\" khớp với mọi thứ bên dưới thư mục đó, và \"*.md\" khớp với mọi tệp Markdown. Một mẫu luôn chỉ tệp, không bao giờ chỉ thư mục: \"docs\" chỉ cấp một tệp tên \"docs\"; hãy viết \"docs/**\" cho nội dung của một thư mục. Dấu \"./\" ở đầu chỉ gốc workspace, còn dấu \"/\" ở đầu chỉ gốc hệ thống tệp, nên \"/tmp/notes.md\" và \"~/notes.md\" vươn ra ngoài workspace. Thêm \"!\" trước một mẫu để loại trừ những gì dòng trước đã khớp; như trong .gitignore, dòng khớp cuối cùng thắng, nên thứ tự có ý nghĩa. Khi chưa mở thư mục nào, các mẫu tương đối với workspace sẽ bị bỏ qua. Chữ hoa và chữ thường được phân biệt, trừ trên Windows. Không có gì được tự động phê duyệt khi \"Tự động phê duyệt\" ở trên đang tắt.", "readFiles": { "label": "Mẫu danh sách cho phép đọc", - "description": "Các tệp mà Zoo có thể đọc không cần phê duyệt, kể cả khi \"Đọc\" ở trên đang tắt. Các tệp trong danh sách cho phép ghi bên dưới cũng luôn đọc được, nên không cần thêm hai lần. Việc liệt kê thư mục và tìm kiếm luôn tuân theo cài đặt \"Đọc\".", + "description": "Các tệp mà Zoo có thể đọc không cần phê duyệt, kể cả khi \"Đọc\" ở trên đang tắt. Các tệp trong danh sách cho phép ghi bên dưới cũng luôn đọc được, nên không cần thêm hai lần. Việc liệt kê thư mục và tìm kiếm luôn tuân theo cài đặt \"Đọc\". Các tệp bị .rooignore loại trừ vẫn không đọc được.", "placeholder": "Mỗi dòng một mẫu, ví dụ notes.md" }, "writeFiles": { "label": "Mẫu danh sách cho phép ghi", - "description": "Các tệp mà Zoo có thể tạo và chỉnh sửa không cần phê duyệt, kể cả khi \"Ghi\" ở trên đang tắt. Các tệp được bảo vệ vẫn cần phê duyệt trừ khi bật \"Bao gồm các tệp được bảo vệ\" (trong cài đặt tự động phê duyệt \"Ghi\").", + "description": "Các tệp mà Zoo có thể tạo và chỉnh sửa không cần phê duyệt, kể cả khi \"Ghi\" ở trên đang tắt. Các tệp được bảo vệ vẫn cần phê duyệt trừ khi bật \"Bao gồm các tệp được bảo vệ\" (trong cài đặt tự động phê duyệt \"Ghi\"). Các tệp bị .rooignore loại trừ vẫn không ghi được.", "placeholder": "Mỗi dòng một mẫu, ví dụ notes.md" } }, diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 16df445c42..39e561d8cd 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -310,15 +310,15 @@ }, "allowlists": { "label": "允许列表", - "description": "为单个文件授予访问权限,而不自动批准整类操作。每行一个模式,采用受 .gitignore 启发的语法:\"notes.md\" 匹配工作区中任意目录下的该文件,\"docs/scratch/**\" 匹配该目录下的所有内容,\"*.md\" 匹配任意 Markdown 文件。开头的 \"./\" 表示工作区根目录,而开头的 \"/\" 表示文件系统根目录,因此 \"/tmp/notes.md\" 和 \"~/notes.md\" 可以指向工作区之外。在模式前加 \"!\" 可排除前面某行已匹配的内容;与 .gitignore 一样,最后匹配的行生效,因此顺序很重要。未打开任何文件夹时,相对于工作区的模式将被忽略。", + "description": "为单个文件授予访问权限,而不自动批准整类操作。每行一个模式,采用受 .gitignore 启发的语法:\"notes.md\" 匹配工作区中任意目录下的该文件,\"docs/scratch/**\" 匹配该目录下的所有内容,\"*.md\" 匹配任意 Markdown 文件。模式始终指向文件,而不是目录:\"docs\" 只授予名为 \"docs\" 的文件;若要包含某个目录下的内容,请写 \"docs/**\"。开头的 \"./\" 表示工作区根目录,而开头的 \"/\" 表示文件系统根目录,因此 \"/tmp/notes.md\" 和 \"~/notes.md\" 可以指向工作区之外。在模式前加 \"!\" 可排除前面某行已匹配的内容;与 .gitignore 一样,最后匹配的行生效,因此顺序很重要。未打开任何文件夹时,相对于工作区的模式将被忽略。区分大小写(Windows 除外)。上面的“自动批准”关闭时,任何操作都不会被自动批准。", "readFiles": { "label": "读取允许列表模式", - "description": "即使上面的\"读取\"已关闭,Zoo 也可以无需批准即读取的文件。下面写入允许列表中的文件始终也可读取,因此无需重复添加。目录列表和搜索始终遵循\"读取\"设置。", + "description": "即使上面的\"读取\"已关闭,Zoo 也可以无需批准即读取的文件。下面写入允许列表中的文件始终也可读取,因此无需重复添加。目录列表和搜索始终遵循\"读取\"设置。被 .rooignore 排除的文件无论如何都不可读取。", "placeholder": "每行一个模式,例如 notes.md" }, "writeFiles": { "label": "写入允许列表模式", - "description": "即使上面的\"写入\"已关闭,Zoo 也可以无需批准即创建和编辑的文件。除非启用\"包含受保护的文件\"(在\"写入\"自动批准的设置中),受保护的文件仍需批准。", + "description": "即使上面的\"写入\"已关闭,Zoo 也可以无需批准即创建和编辑的文件。除非启用\"包含受保护的文件\"(在\"写入\"自动批准的设置中),受保护的文件仍需批准。被 .rooignore 排除的文件无论如何都不可写入。", "placeholder": "每行一个模式,例如 notes.md" } }, diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 820b3ed43c..d735da5971 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -335,15 +335,15 @@ }, "allowlists": { "label": "允許清單", - "description": "為個別檔案授予存取權,而不自動核准整類操作。每行一個模式,採用受 .gitignore 啟發的語法:「notes.md」比對工作區中任何目錄下的該檔案,「docs/scratch/**」比對該目錄下的所有內容,「*.md」比對任何 Markdown 檔案。開頭的「./」表示工作區根目錄,而開頭的「/」表示檔案系統根目錄,因此「/tmp/notes.md」和「~/notes.md」可指向工作區之外。在模式前加上「!」可排除前面某一行已比對到的內容;與 .gitignore 相同,最後比對到的一行生效,因此順序很重要。未開啟任何資料夾時,相對於工作區的模式會被忽略。", + "description": "為個別檔案授予存取權,而不自動核准整類操作。每行一個模式,採用受 .gitignore 啟發的語法:「notes.md」比對工作區中任何目錄下的該檔案,「docs/scratch/**」比對該目錄下的所有內容,「*.md」比對任何 Markdown 檔案。模式一律指向檔案,而不是目錄:「docs」只授予名為「docs」的檔案;若要包含某個目錄下的內容,請寫「docs/**」。開頭的「./」表示工作區根目錄,而開頭的「/」表示檔案系統根目錄,因此「/tmp/notes.md」和「~/notes.md」可指向工作區之外。在模式前加上「!」可排除前面某一行已比對到的內容;與 .gitignore 相同,最後比對到的一行生效,因此順序很重要。未開啟任何資料夾時,相對於工作區的模式會被忽略。區分大小寫(Windows 除外)。上方的「自動核准」關閉時,任何操作都不會被自動核准。", "readFiles": { "label": "讀取允許清單模式", - "description": "即使上方的「讀取」已關閉,Zoo 仍可無需核准即讀取的檔案。下方寫入允許清單中的檔案一律也可讀取,因此不需重複加入。目錄列表與搜尋一律遵循「讀取」設定。", + "description": "即使上方的「讀取」已關閉,Zoo 仍可無需核准即讀取的檔案。下方寫入允許清單中的檔案一律也可讀取,因此不需重複加入。目錄列表與搜尋一律遵循「讀取」設定。被 .rooignore 排除的檔案無論如何都無法讀取。", "placeholder": "每行一個模式,例如 notes.md" }, "writeFiles": { "label": "寫入允許清單模式", - "description": "即使上方的「寫入」已關閉,Zoo 仍可無需核准即建立與編輯的檔案。除非啟用「包含受保護的檔案」(在「寫入」自動核准的設定中),受保護的檔案仍需核准。", + "description": "即使上方的「寫入」已關閉,Zoo 仍可無需核准即建立與編輯的檔案。除非啟用「包含受保護的檔案」(在「寫入」自動核准的設定中),受保護的檔案仍需核准。被 .rooignore 排除的檔案無論如何都無法寫入。", "placeholder": "每行一個模式,例如 notes.md" } }, From 40b429035f7b8c81579dd6ee79dc92e6d3fe8db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niklas=20Hamb=C3=BCchen?= Date: Wed, 19 Aug 2026 04:29:35 +0200 Subject: [PATCH 3/4] FIXUP: Address PR review comments --- .../__tests__/filePatterns.spec.ts | 90 ++++++++++----- src/core/auto-approval/__tests__/fixtures.ts | 10 ++ .../auto-approval/__tests__/negation.spec.ts | 12 +- .../__tests__/windowsPaths.spec.ts | 101 +++++++++++++++++ src/core/auto-approval/filePatterns.ts | 105 +++++++++++++----- .../__tests__/webviewMessageHandler.spec.ts | 5 + .../settings/FilePatternAllowlist.tsx | 27 ++++- 7 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 src/core/auto-approval/__tests__/windowsPaths.spec.ts diff --git a/src/core/auto-approval/__tests__/filePatterns.spec.ts b/src/core/auto-approval/__tests__/filePatterns.spec.ts index 2a00e0b8d6..067a543f48 100644 --- a/src/core/auto-approval/__tests__/filePatterns.spec.ts +++ b/src/core/auto-approval/__tests__/filePatterns.spec.ts @@ -1,54 +1,60 @@ // npx vitest run core/auto-approval/__tests__/filePatterns.spec.ts -import os from "os" - import { isFileMatchedByPatterns, toMatcherPattern } from "../filePatterns" const CWD = "/path/to/repo" // Both platforms' rules are exercised on whichever platform the tests run on, by -// passing `isWindows` explicitly rather than reading `process.platform`. +// passing `isWindows` and `homeDir` explicitly instead of reading the real +// platform. Otherwise these assertions would encode the CI runner's OS: on +// Windows a workspace lives on a drive, and `os.homedir()` starts with one. +const HOME = "/home/me" +const WINDOWS_CWD = "C:/path/to/repo" +const WINDOWS_HOME = "C:\\Users\\me" + +// `cwd` is spelled out at every call rather than defaulted, since `undefined` is +// itself a case under test (no folder open) and a default would silently replace +// it with a workspace root. const matches = (filePath: string, patterns: string[], cwd: string | undefined = CWD) => - isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: false }) + isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: false, homeDir: HOME }) + +const matchesOnWindows = (filePath: string, patterns: string[], cwd: string | undefined = WINDOWS_CWD) => + isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: true, homeDir: WINDOWS_HOME }) -const matchesOnWindows = (filePath: string, patterns: string[], cwd: string | undefined = CWD) => - isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: true }) +const toPattern = (pattern: string, cwd: string | undefined) => toMatcherPattern(pattern, cwd, false, HOME) -const homeFromRoot = os.homedir().replace(/\\/g, "/").slice(1) +const toWindowsPattern = (pattern: string, cwd: string | undefined = WINDOWS_CWD) => + toMatcherPattern(pattern, cwd, true, WINDOWS_HOME) describe("toMatcherPattern", () => { it("prefixes the workspace root and lets a bare filename match in any directory", () => { - expect(toMatcherPattern("notes.md", CWD)).toBe("/path/to/repo/**/notes.md") + expect(toPattern("notes.md", CWD)).toBe("/path/to/repo/**/notes.md") }) it("anchors a pattern containing a slash to the workspace root", () => { - expect(toMatcherPattern("docs/notes.md", CWD)).toBe("/path/to/repo/docs/notes.md") + expect(toPattern("docs/notes.md", CWD)).toBe("/path/to/repo/docs/notes.md") }) it("anchors an explicitly workspace-root-relative pattern", () => { - expect(toMatcherPattern("./notes.md", CWD)).toBe("/path/to/repo/notes.md") + expect(toPattern("./notes.md", CWD)).toBe("/path/to/repo/notes.md") }) it("keeps backslashes, which gitignore uses to escape rather than to separate", () => { - expect(toMatcherPattern("notes.md\\ ", CWD)).toBe("/path/to/repo/**/notes.md\\ ") - expect(toMatcherPattern("\\#hash.md", CWD)).toBe("/path/to/repo/**/\\#hash.md") + expect(toPattern("notes.md\\ ", CWD)).toBe("/path/to/repo/**/notes.md\\ ") + expect(toPattern("\\#hash.md", CWD)).toBe("/path/to/repo/**/\\#hash.md") }) it("resolves a workspace-escaping pattern against the workspace root", () => { - expect(toMatcherPattern("../shared/notes.md", CWD)).toBe("/path/to/shared/notes.md") + expect(toPattern("../shared/notes.md", CWD)).toBe("/path/to/shared/notes.md") }) it("expands a leading ~ to the home directory", () => { - expect(toMatcherPattern("~/notes.md", CWD)).toBe(`/${homeFromRoot}/notes.md`) - }) - - it("keeps a Windows drive as the first path segment", () => { - expect(toMatcherPattern("C:/tmp/notes.md", CWD)).toBe("/C:/tmp/notes.md") + expect(toPattern("~/notes.md", CWD)).toBe("/home/me/notes.md") }) it("anchors a negation exactly like the pattern it cancels", () => { - expect(toMatcherPattern("!notes.md", CWD)).toBe("!/path/to/repo/**/notes.md") - expect(toMatcherPattern("!/tmp/notes.md", CWD)).toBe("!/tmp/notes.md") + expect(toPattern("!notes.md", CWD)).toBe("!/path/to/repo/**/notes.md") + expect(toPattern("!/tmp/notes.md", CWD)).toBe("!/tmp/notes.md") }) it.each([ @@ -58,22 +64,54 @@ describe("toMatcherPattern", () => { ["the home directory itself", "~"], ["a directory pattern", "mydir/"], ])("rejects %s", (_label, pattern) => { - expect(toMatcherPattern(pattern, CWD)).toBeUndefined() + expect(toPattern(pattern, CWD)).toBeUndefined() }) it("preserves whitespace, which gitignore syntax treats as significant", () => { - expect(toMatcherPattern(" notes.md", CWD)).toBe("/path/to/repo/**/ notes.md") - expect(toMatcherPattern("my notes.md", CWD)).toBe("/path/to/repo/**/my notes.md") + expect(toPattern(" notes.md", CWD)).toBe("/path/to/repo/**/ notes.md") + expect(toPattern("my notes.md", CWD)).toBe("/path/to/repo/**/my notes.md") }) // See noWorkspaceRoot.spec.ts for why this fails closed. it("rejects a workspace-relative pattern when the workspace root is unknown", () => { - expect(toMatcherPattern("../shared/notes.md", undefined)).toBeUndefined() - expect(toMatcherPattern("notes.md", undefined)).toBeUndefined() + expect(toPattern("../shared/notes.md", undefined)).toBeUndefined() + expect(toPattern("notes.md", undefined)).toBeUndefined() }) it("keeps an absolute pattern usable when the workspace root is unknown", () => { - expect(toMatcherPattern("/tmp/notes.md", undefined)).toBe("/tmp/notes.md") + expect(toPattern("/tmp/notes.md", undefined)).toBe("/tmp/notes.md") + }) + + // A drive letter is a Windows concept. Elsewhere `C:` is an ordinary directory + // name, so `C:/tmp/notes.md` names a file inside it, relative to the workspace. + describe("on Windows", () => { + it("keeps the drive of an absolute pattern as its first path segment", () => { + expect(toWindowsPattern("D:/tmp/notes.md")).toBe("/D:/tmp/notes.md") + }) + + it("gives a drive-less absolute pattern the workspace's drive", () => { + // The OS reads `/tmp/notes.md` as being on the current drive, so a + // pattern and a path spelled that way have to end up on one drive; + // otherwise they could never match. + expect(toWindowsPattern("/tmp/notes.md")).toBe("/C:/tmp/notes.md") + }) + + it("prefixes the workspace root, drive included", () => { + expect(toWindowsPattern("notes.md")).toBe("/C:/path/to/repo/**/notes.md") + expect(toWindowsPattern("docs/notes.md")).toBe("/C:/path/to/repo/docs/notes.md") + }) + + it("expands ~ to a home directory that has a drive", () => { + expect(toWindowsPattern("~/notes.md")).toBe("/C:/Users/me/notes.md") + }) + + it("reads a backslash as a directory separator", () => { + expect(toWindowsPattern("docs\\notes.md")).toBe("/C:/path/to/repo/docs/notes.md") + }) + + it("treats a drive-looking pattern as a directory off Windows", () => { + expect(toPattern("C:/tmp/notes.md", CWD)).toBe("/path/to/repo/C:/tmp/notes.md") + }) }) }) diff --git a/src/core/auto-approval/__tests__/fixtures.ts b/src/core/auto-approval/__tests__/fixtures.ts index f6017d91e0..de19c95986 100644 --- a/src/core/auto-approval/__tests__/fixtures.ts +++ b/src/core/auto-approval/__tests__/fixtures.ts @@ -14,6 +14,16 @@ export type State = Pick isFileMatchedByPatterns({ filePath, cwd: CWD, patterns }) +// `isWindows` and `homeDir` are fixed rather than read from the platform, so the +// expectations mean the same thing on every CI runner. +const matches = (filePath: string, patterns: string[]) => + isFileMatchedByPatterns({ filePath, cwd: CWD, patterns, isWindows: false, homeDir: HOME }) const readDecision = async (state: Partial, path = "docs/secret.md") => checkAutoApproval({ @@ -44,8 +47,11 @@ describe("pattern negation", () => { expect(matches("other/secret.md", ["secret.md", "!docs/secret.md"])).toBe(true) }) + // Asserting only the exclusion would also pass if `~/**` matched nothing at + // all, so the sibling file has to come out granted. it("excludes via a home-directory negation", () => { - expect(matches("~/notes.md".replace("~", process.env.HOME ?? "~"), ["~/**", "!~/notes.md"])).toBe(false) + expect(matches(`${HOME}/notes.md`, ["~/**", "!~/notes.md"])).toBe(false) + expect(matches(`${HOME}/other.md`, ["~/**", "!~/notes.md"])).toBe(true) }) it("grants nothing when only negations are configured", () => { diff --git a/src/core/auto-approval/__tests__/windowsPaths.spec.ts b/src/core/auto-approval/__tests__/windowsPaths.spec.ts new file mode 100644 index 0000000000..90006ad2d3 --- /dev/null +++ b/src/core/auto-approval/__tests__/windowsPaths.spec.ts @@ -0,0 +1,101 @@ +// npx vitest run core/auto-approval/__tests__/windowsPaths.spec.ts + +import { isFileMatchedByPatterns } from "../filePatterns" + +// On Windows the workspace lives on a drive, so every path the matcher sees +// carries one. That used to break matching outright: patterns went through +// `path.resolve`, which stamps the *current* drive onto a drive-less path, while +// an absolute path reported by a tool kept whatever drive it already had. A +// pattern and a path could therefore end up on different drives and never match, +// which is what made the whole allowlist inert on Windows. +// +// These tests fix the platform explicitly, so they assert Windows behaviour on +// every CI runner rather than only on the Windows one. + +const WINDOWS_CWD = "C:\\path\\to\\repo" +const WINDOWS_HOME = "C:\\Users\\me" + +const matches = (filePath: string, patterns: string[], cwd: string | undefined = WINDOWS_CWD) => + isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows: true, homeDir: WINDOWS_HOME }) + +describe("matching on Windows", () => { + it("matches a workspace-relative path against a bare pattern", () => { + expect(matches("notes.md", ["notes.md"])).toBe(true) + expect(matches("docs\\notes.md", ["notes.md"])).toBe(true) + }) + + it("matches an absolute in-workspace path against a workspace-relative pattern", () => { + expect(matches("C:\\path\\to\\repo\\docs\\notes.md", ["docs/notes.md"])).toBe(true) + expect(matches("C:/path/to/repo/docs/notes.md", ["docs/notes.md"])).toBe(true) + }) + + it("does not match a file outside the workspace against a workspace-relative pattern", () => { + expect(matches("C:\\other\\notes.md", ["notes.md"])).toBe(false) + }) + + it("matches an absolute pattern that names the drive", () => { + expect(matches("C:\\tmp\\notes.md", ["C:/tmp/notes.md"])).toBe(true) + expect(matches("C:\\tmp\\notes.md", ["c:/tmp/notes.md"])).toBe(true) + }) + + // The OS reads a drive-less absolute path as being on the current drive, so + // the pattern and the path have to be brought onto one drive before matching. + it("matches a drive-less absolute pattern against a path on the workspace drive", () => { + expect(matches("C:\\tmp\\notes.md", ["/tmp/notes.md"])).toBe(true) + expect(matches("/tmp/notes.md", ["/tmp/notes.md"])).toBe(true) + expect(matches("/tmp/notes.md", ["C:/tmp/notes.md"])).toBe(true) + }) + + it("keeps drives apart", () => { + expect(matches("D:\\tmp\\notes.md", ["C:/tmp/notes.md"])).toBe(false) + expect(matches("D:\\tmp\\notes.md", ["/tmp/notes.md"])).toBe(false) + // A workspace on D: makes the drive-less pattern name D:, not C:. + expect(matches("D:\\tmp\\notes.md", ["/tmp/notes.md"], "D:\\repo")).toBe(true) + }) + + it("expands ~ to a home directory that carries a drive", () => { + expect(matches("C:\\Users\\me\\notes.md", ["~/notes.md"])).toBe(true) + expect(matches("C:\\Users\\other\\notes.md", ["~/notes.md"])).toBe(false) + }) + + it("ignores case, as the filesystem does", () => { + expect(matches("C:\\path\\to\\repo\\NOTES.md", ["notes.md"])).toBe(true) + expect(matches("c:\\path\\to\\repo\\notes.md", ["notes.md"])).toBe(true) + }) + + it("resolves a workspace-escaping pattern on the workspace drive", () => { + expect(matches("C:\\path\\to\\shared\\notes.md", ["../shared/notes.md"])).toBe(true) + }) + + it("still honours negations", () => { + expect(matches("C:\\path\\to\\repo\\docs\\secret.md", ["docs/**", "!docs/secret.md"])).toBe(false) + expect(matches("C:\\path\\to\\repo\\docs\\notes.md", ["docs/**", "!docs/secret.md"])).toBe(true) + }) + + // The tests above pass `isWindows` explicitly, which leaves the production + // default untested. These reproduce the cases the Windows CI run reported as + // failing, with nothing passed but a workspace on a drive, so that the defaults + // are what decides them. + describe("with the platform reported as Windows", () => { + const realPlatform = process.platform + + beforeAll(() => Object.defineProperty(process, "platform", { value: "win32" })) + afterAll(() => Object.defineProperty(process, "platform", { value: realPlatform })) + + const matchesByDefault = (filePath: string, patterns: string[]) => + isFileMatchedByPatterns({ filePath, cwd: WINDOWS_CWD, patterns }) + + it("matches a bare pattern against a workspace-relative path", () => { + expect(matchesByDefault("notes.md", ["notes.md"])).toBe(true) + }) + + it("matches a directory glob", () => { + expect(matchesByDefault("docs/scratch/a.md", ["docs/scratch/**"])).toBe(true) + }) + + it("confines a bare pattern to the workspace", () => { + expect(matchesByDefault("C:/path/to/repo/etc/passwd", ["passwd"])).toBe(true) + expect(matchesByDefault("C:/etc/passwd", ["passwd"])).toBe(false) + }) + }) +}) diff --git a/src/core/auto-approval/filePatterns.ts b/src/core/auto-approval/filePatterns.ts index 0e1280d19a..f882f07465 100644 --- a/src/core/auto-approval/filePatterns.ts +++ b/src/core/auto-approval/filePatterns.ts @@ -153,9 +153,31 @@ function pathsepsToPosix(value: string, isWindows: boolean): string { return isWindows ? value.replace(/\\/g, "/") : value } -function isAbsolutePosixPath(value: string): boolean { - // Posix ("/tmp/x") or Windows with a drive letter ("C:/tmp/x"). - return value.startsWith("/") || /^[a-zA-Z]:\//.test(value) +/** A Windows drive prefix, as the first thing in a path: `C:` in `C:/tmp/x`. */ +const DRIVE_PREFIX = /^([a-zA-Z]:)(\/.*)?$/ + +/** + * Split a leading Windows drive off a path, leaving a POSIX-absolute remainder. + * + * Only when reading paths as Windows does. Elsewhere `C:` is an ordinary + * directory name (`mkdir 'C:'` succeeds on Linux), so `C:/notes.md` is a + * *relative* path naming a file in it, and taking it for a drive would anchor it + * to the filesystem root instead of the workspace. + * + * - `"C:/tmp/x"` -> `{ drive: "C:", rest: "/tmp/x" }` + * - `"C:"` -> `{ drive: "C:", rest: "/" }` + * - `"/tmp/x"` -> `{ drive: undefined, rest: "/tmp/x" }` + * - `"C:/tmp/x"` off Windows -> `{ drive: undefined, rest: "C:/tmp/x" }` + */ +function splitDrive(value: string, isWindows: boolean): { drive?: string; rest: string } { + const drive = isWindows ? DRIVE_PREFIX.exec(value) : null + + return drive ? { drive: drive[1], rest: drive[2] || "/" } : { rest: value } +} + +function isAbsolutePosixPath(value: string, isWindows: boolean): boolean { + // Posix ("/tmp/x"), or on Windows also a drive letter ("C:/tmp/x"). + return value.startsWith("/") || splitDrive(value, isWindows).drive !== undefined } function escapesWorkspace(posixPath: string): boolean { @@ -163,26 +185,28 @@ function escapesWorkspace(posixPath: string): boolean { } /** - * Rewrite an absolute path as a path relative to the filesystem root, since the - * `ignore` library rejects paths that start with `/`. + * Rewrite a path into the single form the matcher works in: relative to the + * filesystem root, since the `ignore` library rejects paths starting with `/`. * - * An absolute POSIX path loses its leading slash. - * An absolute Windows path starts with a drive letter, so it doesn't - * have a leading slash so we don't have to strip anything from it. + * `absolutePath` is absolute in one of the two senses of `isAbsolutePosixPath`. + * A POSIX path loses its leading slash. A Windows path keeps its drive as the + * first path segment, which is what holds drives apart. * - * The drive letter's case is left as typed, since a drive letter only occurs on - * Windows, where the matcher ignores case anyway (see the case-sensitivity note - * at the top), so `C:/x` and `c:/x` already name the same file. + * On Windows a path naming no drive takes the workspace's, as the OS would read + * it, so that a `/tmp/notes.md` pattern and a `C:/tmp/notes.md` path still + * describe the same file. The drive's case is left as typed: a drive only occurs + * on Windows, where the matcher ignores case anyway (see the case-sensitivity + * note at the top), so `C:/x` and `c:/x` already agree. * * - `"/tmp/notes.md"` -> `"tmp/notes.md"` * - `"C:/tmp/notes.md"` -> `"C:/tmp/notes.md"` + * - `"/tmp/notes.md"` with `workspaceDrive` `"C:"` -> `"C:/tmp/notes.md"` */ -function toRootRelativePath(absolutePosixPath: string): string { - if (/^[a-zA-Z]:\//.test(absolutePosixPath)) { - return absolutePosixPath - } +function toRootRelativePath(absolutePath: string, isWindows: boolean, workspaceDrive?: string): string { + const { drive, rest } = splitDrive(absolutePath, isWindows) + const effectiveDrive = drive ?? workspaceDrive - return absolutePosixPath.slice(1) + return effectiveDrive ? `${effectiveDrive}${rest}` : rest.slice(1) } /** @@ -241,34 +265,48 @@ function toRootRelativePath(absolutePosixPath: string): string { * @param pattern - Raw pattern as typed by the user. * @param cwd - Workspace root, used to resolve workspace-relative patterns. * @param isWindows - Whether to read paths by Windows' rules; see `pathsepsToPosix`. + * @param homeDir - Directory `~` expands to. Defaults to the real one; tests pass + * a Windows-shaped path to exercise that platform's rules. * @returns The rewritten pattern, or `undefined` when the pattern can never * match a file (empty, a directory, or escaping an unknown workspace root). */ -export function toMatcherPattern(pattern: string, cwd?: string, isWindows = runningOnWindows()): string | undefined { +export function toMatcherPattern( + pattern: string, + cwd?: string, + isWindows = runningOnWindows(), + homeDir = os.homedir(), +): string | undefined { // Set gitignore's negation aside so the path is rewritten on its own merits, // then restore it, so that a negation is anchored exactly like the pattern it // is written to cancel. const negation = pattern.startsWith("!") ? "!" : "" - let normalized = pattern.slice(negation.length) + // On Windows a backslash the user typed separates directories, so it becomes a + // slash before anything else looks at the pattern. Elsewhere it is left alone, + // as gitignore's escape character and a legal filename character. + let normalized = pathsepsToPosix(pattern.slice(negation.length), isWindows) if (!normalized.trim() || normalized === "." || normalized === "~" || normalized.endsWith("/")) { return undefined } + const workspace = splitDrive(pathsepsToPosix(cwd ?? "", isWindows), isWindows) + const workspaceDrive = workspace.drive + if (normalized.startsWith("~/")) { - normalized = pathsepsToPosix(path.join(os.homedir(), normalized.slice(2)), isWindows) + const home = splitDrive(pathsepsToPosix(homeDir, isWindows), isWindows) + normalized = `${home.drive ?? ""}${path.posix.join(home.rest, normalized.slice(2))}` } - if (!isAbsolutePosixPath(normalized) && escapesWorkspace(normalized)) { + if (!isAbsolutePosixPath(normalized, isWindows) && escapesWorkspace(normalized)) { if (!cwd) { return undefined } - normalized = pathsepsToPosix(path.resolve(cwd, normalized), isWindows) + normalized = `${workspaceDrive ?? ""}${path.posix.resolve(workspace.rest, normalized)}` } - if (isAbsolutePosixPath(normalized)) { - return `${negation}/${toRootRelativePath(normalized)}` + if (isAbsolutePosixPath(normalized, isWindows)) { + return `${negation}/${toRootRelativePath(normalized, isWindows, workspaceDrive)}` } // "./notes.md" names the workspace root explicitly. @@ -284,7 +322,7 @@ export function toMatcherPattern(pattern: string, cwd?: string, isWindows = runn return undefined } - const workspaceBase = toRootRelativePath(pathsepsToPosix(path.resolve(cwd), isWindows)) + const workspaceBase = toRootRelativePath(path.posix.resolve(workspace.rest), isWindows, workspaceDrive) // gitignore anchors a pattern to the base directory as soon as it has a // separator "at the beginning or middle (or both)" (gitignore(5)), and only a @@ -312,8 +350,10 @@ function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boo return undefined } - if (isAbsolutePosixPath(normalized)) { - return toRootRelativePath(normalized) + const workspace = splitDrive(pathsepsToPosix(cwd ?? "", isWindows), isWindows) + + if (isAbsolutePosixPath(normalized, isWindows)) { + return toRootRelativePath(normalized, isWindows, workspace.drive) } if (!cwd) { @@ -323,7 +363,9 @@ function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boo return undefined } - return toRootRelativePath(pathsepsToPosix(path.resolve(cwd, normalized), isWindows)) + const { drive, rest } = splitDrive(normalized, isWindows) + + return toRootRelativePath(`${drive ?? ""}${path.posix.resolve(workspace.rest, rest)}`, isWindows, workspace.drive) } /** @@ -338,19 +380,22 @@ function toMatcherPath(filePath: string, cwd: string | undefined, isWindows: boo * @param cwd - Workspace root. * @param patterns - Raw patterns as configured by the user. * @param isWindows - Whether to read paths by Windows' rules: `\` separates - * directories and case is ignored. Defaults to the platform in use; tests pass it - * explicitly to exercise either platform's rules. + * directories, a leading `C:` is a drive, and case is ignored. Defaults to the + * platform in use; tests pass it explicitly to exercise either platform's rules. + * @param homeDir - Directory `~` expands to. Defaults to the real one. */ export function isFileMatchedByPatterns({ filePath, cwd, patterns, isWindows = runningOnWindows(), + homeDir = os.homedir(), }: { filePath?: string cwd?: string patterns?: string[] isWindows?: boolean + homeDir?: string }): boolean { if (!filePath || !Array.isArray(patterns) || !patterns.length) { return false @@ -363,7 +408,7 @@ export function isFileMatchedByPatterns({ } const matcherPatterns = patterns - .map((pattern) => toMatcherPattern(pattern, cwd, isWindows)) + .map((pattern) => toMatcherPattern(pattern, cwd, isWindows, homeDir)) .filter((pattern): pattern is string => !!pattern) if (!matcherPatterns.length) { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d137dd6424..a2b6a33b28 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1246,6 +1246,11 @@ describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessag it("drops entries that cannot name a file", async () => { await webviewMessageHandler(mockClineProvider, { type: "updateSettings", + // The double assertion stands in for an untyped payload: the message + // arrives as JSON from the webview, so a non-string can reach the + // handler even though the type says otherwise. That is what the + // handler's `typeof` filter is there to catch, so the test has to be + // able to express it. updatedSettings: { [key]: ["notes.md", "", " ", 42 as unknown as string] }, }) diff --git a/webview-ui/src/components/settings/FilePatternAllowlist.tsx b/webview-ui/src/components/settings/FilePatternAllowlist.tsx index 72e419023c..86774a79c0 100644 --- a/webview-ui/src/components/settings/FilePatternAllowlist.tsx +++ b/webview-ui/src/components/settings/FilePatternAllowlist.tsx @@ -1,3 +1,4 @@ +import type { FormEvent } from "react" import { VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" import { useAppTranslation } from "@/i18n/TranslationContext" @@ -35,6 +36,20 @@ interface FilePatternAllowlistProps { * The pattern syntax itself is explained once by the enclosing Allowlists * section, so each list only carries what is specific to it. */ +/** + * Read the current text out of a `VSCodeTextArea`'s input event. + * + * The toolkit component wraps a native `