diff --git a/.changeset/path-access-always-scope.md b/.changeset/path-access-always-scope.md new file mode 100644 index 0000000..fca90fa --- /dev/null +++ b/.changeset/path-access-always-scope.md @@ -0,0 +1,10 @@ +--- +"@aliou/pi-guardrails": minor +--- + +Add `pathAccess.alwaysScope` option to control where "Allow … always" grants +from the path-access prompt are persisted. Defaults to `"local"` (the project +config, current behavior). Set it to `"global"` to save grants to the user-wide +config so they apply in every project. + +Configurable via the settings UI under Path Access → "Always-grant scope". diff --git a/README.md b/README.md index 4e91721..fb93ca8 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,18 @@ Restrict tool access to the current working directory. When enabled, any tool ca "features": { "pathAccess": true }, "pathAccess": { "mode": "ask", - "allowedPaths": ["~/code/shared-libs/", "~/.config/myapp"] + "allowedPaths": ["~/code/shared-libs/", "~/.config/myapp"], + "alwaysScope": "local" } } ``` -Grants are stored in project config (always) or session memory (session). The `allowedPaths` array is merged across all config scopes. +Grants from the prompt are stored either in session memory ("this session" options) or in a persisted config ("always" options). The persisted scope is controlled by `pathAccess.alwaysScope`: + +- `"local"` (default): saved to the project config (`{project}/.pi/extensions/guardrails.json`). +- `"global"`: saved to the user-wide config (`~/.pi/agent/extensions/guardrails.json`), so the same grant applies in every project. + +The `allowedPaths` array is merged across all config scopes, so anything you put under the global config applies in every project regardless of `alwaysScope`. Limitations: - Symlinks are not resolved (lexical path comparison only). diff --git a/docs/defaults.md b/docs/defaults.md index 0f90137..62d2e58 100644 --- a/docs/defaults.md +++ b/docs/defaults.md @@ -108,6 +108,7 @@ Blocks access to GPG/GnuPG private keys, keyrings, and configuration. Disabled b | `features.pathAccess` | `false` | | `pathAccess.mode` | `"ask"` | | `pathAccess.allowedPaths` | `[]` | +| `pathAccess.alwaysScope` | `"local"` | Modes: - `allow` — no path restrictions @@ -119,6 +120,10 @@ Allowed paths use trailing-slash convention: - `/path/to/dir/` — directory and all descendants - Supports `~/` for home directory +Always-scope: +- `local` — "Allow … always" grants are saved to the project config (default; current behavior) +- `global` — "Allow … always" grants are saved to the user-wide config and apply in every project + Limitations: - Bash path extraction is best-effort (AST-based heuristics). Tokens like `application/json` may trigger false-positive prompts. - Symlinks are not resolved. Lexical path comparison only. diff --git a/src/commands/settings-command.ts b/src/commands/settings-command.ts index a8696e0..9ec10b8 100644 --- a/src/commands/settings-command.ts +++ b/src/commands/settings-command.ts @@ -1405,6 +1405,15 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void { "Allowed Paths", ), }, + { + id: "pathAccess.alwaysScope", + label: "Always-grant scope", + description: + "Where 'Allow … always' grants are saved. local: this project only, global: every project.", + currentValue: + scopedConfig.pathAccess?.alwaysScope ?? "(inherited)", + values: ["local", "global"], + }, ], }, { diff --git a/src/config.ts b/src/config.ts index e15ae45..221ee64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,9 +55,19 @@ export interface PolicyRule { export type PathAccessMode = "allow" | "ask" | "block"; +/** + * Where "Allow … always" grants from the prompt are persisted. + * - `local`: project config (`{project}/.pi/extensions/guardrails.json`). + * - `global`: user-wide config (`~/.pi/agent/extensions/guardrails.json`), + * so the same grant applies in every project. + */ +export type PathAccessAlwaysScope = "local" | "global"; + export interface PathAccessConfig { mode?: PathAccessMode; allowedPaths?: string[]; + /** Default scope used when the user picks "Allow … always" in the prompt. */ + alwaysScope?: PathAccessAlwaysScope; } export interface GuardrailsConfig { @@ -118,6 +128,7 @@ export interface ResolvedConfig { pathAccess: { mode: PathAccessMode; allowedPaths: string[]; + alwaysScope: PathAccessAlwaysScope; }; permissionGate: { patterns: DangerousPattern[]; @@ -226,6 +237,7 @@ const DEFAULT_CONFIG: ResolvedConfig = { pathAccess: { mode: "ask", allowedPaths: [], + alwaysScope: "local", }, policies: { rules: [ @@ -386,6 +398,15 @@ export const configLoader = new ConfigLoader( } resolved.pathAccess.allowedPaths = [...mergedPaths]; + // alwaysScope: highest-priority scope wins (memory > local > global). + const alwaysScope = + memory?.pathAccess?.alwaysScope ?? + local?.pathAccess?.alwaysScope ?? + global?.pathAccess?.alwaysScope; + if (alwaysScope === "global" || alwaysScope === "local") { + resolved.pathAccess.alwaysScope = alwaysScope; + } + return resolved; }, }, diff --git a/src/hooks/path-access.test.ts b/src/hooks/path-access.test.ts new file mode 100644 index 0000000..1cbccbe --- /dev/null +++ b/src/hooks/path-access.test.ts @@ -0,0 +1,261 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { createEventBus } from "@mariozechner/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEventContext } from "../../tests/utils/pi-context"; +import type { ResolvedConfig } from "../config"; +import { setupPathAccessHook } from "./path-access"; + +// --------------------------------------------------------------------------- +// configLoader mock — we control what `getConfig` returns and capture saves. +// --------------------------------------------------------------------------- + +const mockState: { + config: ResolvedConfig | null; + raw: { + global: Record | null; + local: Record | null; + memory: Record | null; + }; + saves: Array<{ scope: string; config: Record }>; +} = { + config: null, + raw: { global: null, local: null, memory: null }, + saves: [], +}; + +vi.mock("../config", async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + configLoader: { + getConfig: () => mockState.config, + getRawConfig: (scope: "global" | "local" | "memory") => + mockState.raw[scope], + save: vi.fn(async (scope: string, config: Record) => { + mockState.saves.push({ scope, config }); + }), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfig( + pathAccess: Partial = {}, +): ResolvedConfig { + return { + version: "1", + enabled: true, + applyBuiltinDefaults: true, + features: { policies: false, permissionGate: false, pathAccess: true }, + policies: { rules: [] }, + pathAccess: { + mode: "ask", + allowedPaths: [], + alwaysScope: "local", + ...pathAccess, + }, + permissionGate: { + patterns: [], + useBuiltinMatchers: true, + requireConfirmation: true, + allowedPatterns: [], + autoDenyPatterns: [], + explainCommands: false, + explainModel: null, + explainTimeout: 5000, + }, + }; +} + +type ToolCallHandler = ( + event: { type: "tool_call"; toolName: string; input: unknown }, + ctx: ExtensionContext, +) => Promise<{ block: true; reason: string } | undefined>; + +function createMockPi() { + const handlers: ToolCallHandler[] = []; + const eventBus = createEventBus(); + + const pi = { + on(event: string, handler: ToolCallHandler) { + if (event === "tool_call") handlers.push(handler); + }, + events: eventBus, + registerCommand: vi.fn(), + registerTool: vi.fn(), + emit: vi.fn(), + } as unknown as ExtensionAPI; + + return { + pi, + getHandler(): ToolCallHandler { + if (!handlers.length) throw new Error("No tool_call handler registered"); + return handlers[0]; + }, + }; +} + +function readEvent(absPath: string) { + return { + type: "tool_call" as const, + toolName: "read", + input: { file_path: absPath }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("path-access hook: alwaysScope persistence", () => { + let handler: ToolCallHandler; + + beforeEach(() => { + mockState.config = makeConfig(); + mockState.raw = { global: null, local: null, memory: null }; + mockState.saves = []; + const handle = createMockPi(); + setupPathAccessHook(handle.pi); + handler = handle.getHandler(); + }); + + it("persists 'allow file always' to local by default", async () => { + mockState.config = makeConfig({ alwaysScope: "local" }); + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-file-always", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + const result = await handler(readEvent("/etc/hosts"), ctx); + expect(result).toBeUndefined(); + expect(mockState.saves).toHaveLength(1); + expect(mockState.saves[0].scope).toBe("local"); + const saved = mockState.saves[0].config as { + pathAccess: { allowedPaths: string[] }; + }; + expect(saved.pathAccess.allowedPaths).toEqual(["/etc/hosts"]); + }); + + it("persists 'allow file always' to global when alwaysScope is global", async () => { + mockState.config = makeConfig({ alwaysScope: "global" }); + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-file-always", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + const result = await handler(readEvent("/etc/hosts"), ctx); + expect(result).toBeUndefined(); + expect(mockState.saves).toHaveLength(1); + expect(mockState.saves[0].scope).toBe("global"); + const saved = mockState.saves[0].config as { + pathAccess: { allowedPaths: string[] }; + }; + expect(saved.pathAccess.allowedPaths).toEqual(["/etc/hosts"]); + }); + + it("persists 'allow directory always' to global when alwaysScope is global", async () => { + mockState.config = makeConfig({ alwaysScope: "global" }); + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-dir-always", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + const result = await handler(readEvent("/etc/passwd"), ctx); + expect(result).toBeUndefined(); + expect(mockState.saves).toHaveLength(1); + expect(mockState.saves[0].scope).toBe("global"); + const saved = mockState.saves[0].config as { + pathAccess: { allowedPaths: string[] }; + }; + // The grant is for the parent directory with trailing slash + expect(saved.pathAccess.allowedPaths).toEqual(["/etc/"]); + }); + + it("session grants always go to memory regardless of alwaysScope", async () => { + mockState.config = makeConfig({ alwaysScope: "global" }); + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-file-session", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + const result = await handler(readEvent("/etc/hosts"), ctx); + expect(result).toBeUndefined(); + expect(mockState.saves).toHaveLength(1); + expect(mockState.saves[0].scope).toBe("memory"); + }); + + it("'allow once' does not persist anywhere", async () => { + mockState.config = makeConfig({ alwaysScope: "global" }); + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-file-once", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + const result = await handler(readEvent("/etc/hosts"), ctx); + expect(result).toBeUndefined(); + expect(mockState.saves).toHaveLength(0); + }); + + it("preserves existing raw config when saving (does not clobber other fields)", async () => { + mockState.config = makeConfig({ alwaysScope: "global" }); + mockState.raw.global = { + features: { pathAccess: true }, + pathAccess: { mode: "ask", allowedPaths: ["~/existing/"] }, + // Some unrelated field that must not be lost. + foo: "bar", + }; + const ctx = createEventContext({ + cwd: "/work/project", + hasUI: true, + ui: { + custom: vi.fn( + async () => "allow-file-always", + ) as ExtensionContext["ui"]["custom"], + }, + }); + + await handler(readEvent("/etc/hosts"), ctx); + expect(mockState.saves).toHaveLength(1); + const saved = mockState.saves[0].config as { + foo: string; + pathAccess: { mode: string; allowedPaths: string[] }; + }; + expect(saved.foo).toBe("bar"); + expect(saved.pathAccess.mode).toBe("ask"); + expect(saved.pathAccess.allowedPaths).toEqual([ + "~/existing/", + "/etc/hosts", + ]); + }); +}); diff --git a/src/hooks/path-access.ts b/src/hooks/path-access.ts index 5e28352..cabc201 100644 --- a/src/hooks/path-access.ts +++ b/src/hooks/path-access.ts @@ -33,7 +33,7 @@ type PromptResult = // Pending grant to be persisted after all targets pass interface PendingGrant { storagePath: string; // in storage form (~/..., trailing / for dirs) - scope: "memory" | "local"; + scope: "memory" | "local" | "global"; absolutePath: string; // for in-loop matching } @@ -240,7 +240,7 @@ function createPromptComponent( */ async function persistGrant( storagePath: string, - scope: "memory" | "local", + scope: "memory" | "local" | "global", ): Promise { const raw = (configLoader.getRawConfig(scope) ?? {}) as Record< string, @@ -340,9 +340,12 @@ export function setupPathAccessHook(pi: ExtensionAPI): void { continue; } + // "always" grants are persisted to the configured scope (default: local). + const alwaysScope = config.pathAccess.alwaysScope; + // Handle session/always grants if (result === "allow-file-session" || result === "allow-file-always") { - const scope = result === "allow-file-session" ? "memory" : "local"; + const scope = result === "allow-file-session" ? "memory" : alwaysScope; const storage = toStorageForm(absPath, false); pendingGrants.push({ storagePath: storage, @@ -353,7 +356,7 @@ export function setupPathAccessHook(pi: ExtensionAPI): void { } if (result === "allow-dir-session" || result === "allow-dir-always") { - const scope = result === "allow-dir-session" ? "memory" : "local"; + const scope = result === "allow-dir-session" ? "memory" : alwaysScope; const dirPath = isDirectoryTool ? absPath : parentDir; if (isGrantTooBroad(dirPath)) { diff --git a/src/hooks/permission-gate/index.test.ts b/src/hooks/permission-gate/index.test.ts index be0e739..4e5b537 100644 --- a/src/hooks/permission-gate/index.test.ts +++ b/src/hooks/permission-gate/index.test.ts @@ -49,7 +49,7 @@ function makeConfig( applyBuiltinDefaults: true, features: { policies: false, permissionGate: true, pathAccess: false }, policies: { rules: [] }, - pathAccess: { mode: "ask", allowedPaths: [] }, + pathAccess: { mode: "ask", allowedPaths: [], alwaysScope: "local" }, permissionGate: { patterns: [], useBuiltinMatchers: true,