Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/path-access-always-scope.md
Original file line number Diff line number Diff line change
@@ -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".
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions docs/defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/commands/settings-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
],
},
{
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -118,6 +128,7 @@ export interface ResolvedConfig {
pathAccess: {
mode: PathAccessMode;
allowedPaths: string[];
alwaysScope: PathAccessAlwaysScope;
};
permissionGate: {
patterns: DangerousPattern[];
Expand Down Expand Up @@ -226,6 +237,7 @@ const DEFAULT_CONFIG: ResolvedConfig = {
pathAccess: {
mode: "ask",
allowedPaths: [],
alwaysScope: "local",
},
policies: {
rules: [
Expand Down Expand Up @@ -386,6 +398,15 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
}
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;
},
},
Expand Down
261 changes: 261 additions & 0 deletions src/hooks/path-access.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null;
local: Record<string, unknown> | null;
memory: Record<string, unknown> | null;
};
saves: Array<{ scope: string; config: Record<string, unknown> }>;
} = {
config: null,
raw: { global: null, local: null, memory: null },
saves: [],
};

vi.mock("../config", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
configLoader: {
getConfig: () => mockState.config,
getRawConfig: (scope: "global" | "local" | "memory") =>
mockState.raw[scope],
save: vi.fn(async (scope: string, config: Record<string, unknown>) => {
mockState.saves.push({ scope, config });
}),
},
};
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeConfig(
pathAccess: Partial<ResolvedConfig["pathAccess"]> = {},
): 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",
]);
});
});
Loading