@gotgenes/pi-permission-system 3.10.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
4
4
  "title": "PI Permission System Configuration",
5
- "description": "Unified config file combining runtime knobs and permission policy for pi-permission-system.",
6
- "markdownDescription": "Unified config file combining runtime knobs and permission policy for [pi-permission-system](https://github.com/gotgenes/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/config.json` (global) or `<project>/.pi/extensions/pi-permission-system/config.json` (project).",
5
+ "description": "Unified config file combining runtime knobs and flat permission policy for pi-permission-system.",
6
+ "markdownDescription": "Unified config file combining runtime knobs and flat permission policy for [pi-permission-system](https://github.com/gotgenes/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/config.json` (global) or `<project>/.pi/extensions/pi-permission-system/config.json` (project).",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
9
  "properties": {
@@ -29,111 +29,43 @@
29
29
  "type": "boolean",
30
30
  "default": false
31
31
  },
32
- "defaultPolicy": {
33
- "description": "Fallback permission state per category when no specific rule matches.",
34
- "markdownDescription": "Fallback permission state per category when no specific rule matches.\n\nAll fields default to `\"ask\"` when omitted.",
32
+ "permission": {
33
+ "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
34
+ "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
35
35
  "type": "object",
36
- "additionalProperties": false,
37
- "properties": {
38
- "tools": {
39
- "description": "Default permission for registered tools when no exact-name rule matches in the tools map.",
40
- "markdownDescription": "Default permission for registered tools when no exact-name rule matches in the `tools` map.",
41
- "$ref": "#/$defs/permissionState",
42
- "default": "ask"
43
- },
44
- "bash": {
45
- "description": "Default permission for bash commands when no pattern in the bash map matches.",
46
- "markdownDescription": "Default permission for bash commands when no pattern in the `bash` map matches.",
47
- "$ref": "#/$defs/permissionState",
48
- "default": "ask"
49
- },
50
- "mcp": {
51
- "description": "Default permission for MCP operations when no target pattern in the mcp map matches.",
52
- "markdownDescription": "Default permission for MCP operations when no target pattern in the `mcp` map matches.",
53
- "$ref": "#/$defs/permissionState",
54
- "default": "ask"
55
- },
56
- "skills": {
57
- "description": "Default permission for skill loading when no pattern in the skills map matches.",
58
- "markdownDescription": "Default permission for skill loading when no pattern in the `skills` map matches.",
59
- "$ref": "#/$defs/permissionState",
60
- "default": "ask"
61
- },
62
- "special": {
63
- "description": "Default permission for special checks (external_directory) when no explicit rule matches.",
64
- "markdownDescription": "Default permission for special checks (`external_directory`) when no explicit rule matches.",
65
- "$ref": "#/$defs/permissionState",
66
- "default": "ask"
67
- }
68
- }
69
- },
70
- "tools": {
71
- "description": "Exact-name permissions for registered tools. Use this map for the canonical Pi built-ins and any extension-provided or third-party tools.",
72
- "markdownDescription": "Exact-name permissions for registered tools. Use this map for the canonical Pi built-ins (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) and any extension-provided or third-party tools.\n\nNo wildcards — keys must match the registered tool name exactly.",
73
- "$ref": "#/$defs/permissionMap",
36
+ "propertyNames": {
37
+ "description": "A surface name or the universal fallback key '*'.",
38
+ "type": "string",
39
+ "minLength": 1
40
+ },
41
+ "additionalProperties": {
42
+ "oneOf": [
43
+ {
44
+ "$ref": "#/$defs/permissionState",
45
+ "description": "Catch-all shorthand: equivalent to { \"*\": action }."
46
+ },
47
+ {
48
+ "$ref": "#/$defs/permissionMap",
49
+ "description": "Pattern→action map for this surface."
50
+ }
51
+ ]
52
+ },
74
53
  "examples": [
75
54
  {
55
+ "*": "ask",
76
56
  "read": "allow",
77
57
  "write": "deny",
78
- "edit": "ask",
79
- "mcp": "allow"
58
+ "bash": {
59
+ "*": "ask",
60
+ "git status": "allow",
61
+ "git diff": "allow",
62
+ "git *": "ask"
63
+ },
64
+ "mcp": { "*": "ask", "mcp_status": "allow", "exa:*": "allow" },
65
+ "skill": { "*": "ask", "librarian": "allow" },
66
+ "external_directory": "ask"
80
67
  }
81
68
  ]
82
- },
83
- "bash": {
84
- "description": "Wildcard pattern permissions for bash commands. Patterns use * globs matched against the full command string. Last matching rule wins.",
85
- "markdownDescription": "Wildcard pattern permissions for bash commands. Patterns use `*` globs matched against the full command string.\n\n**Last matching rule wins** — put broad catch-all rules first and specific overrides after them.",
86
- "$ref": "#/$defs/permissionMap",
87
- "examples": [
88
- {
89
- "git status": "allow",
90
- "git diff": "allow",
91
- "git *": "ask",
92
- "rm -rf *": "deny"
93
- }
94
- ]
95
- },
96
- "mcp": {
97
- "description": "Pattern-based permissions for targets invoked through a registered mcp tool when available.",
98
- "markdownDescription": "Pattern-based permissions for targets invoked through a registered `mcp` tool when available.\n\nTargets include server names (`myServer`), qualified tool names (`myServer:search`, `myServer_search`), and baseline operations (`mcp_status`, `mcp_list`, `mcp_connect`, `mcp_describe`, `mcp_search`).",
99
- "$ref": "#/$defs/permissionMap",
100
- "examples": [
101
- {
102
- "mcp_status": "allow",
103
- "mcp_list": "allow",
104
- "myServer:*": "ask",
105
- "dangerousServer": "deny"
106
- }
107
- ]
108
- },
109
- "skills": {
110
- "description": "Wildcard pattern permissions for skill names. Controls which skills can be loaded or read from disk.",
111
- "markdownDescription": "Wildcard pattern permissions for skill names. Controls which skills can be loaded or read from disk.\n\nUse `\"*\": \"ask\"` to require confirmation for all skills, or `\"dangerous-*\": \"deny\"` to block a family of skills.",
112
- "$ref": "#/$defs/permissionMap",
113
- "examples": [
114
- {
115
- "*": "ask",
116
- "dangerous-*": "deny"
117
- }
118
- ]
119
- },
120
- "special": {
121
- "description": "Reserved permission checks for special runtime behaviors.",
122
- "type": "object",
123
- "additionalProperties": false,
124
- "properties": {
125
- "external_directory": {
126
- "description": "Enforces permission checks for path-bearing file tools (read, write, edit, find, grep, ls) when they target paths outside the active working directory.",
127
- "markdownDescription": "Enforces permission checks for path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory.\n\nEvaluated **before** the normal tool permission check — so `tools.read: \"allow\"` can permit ordinary reads while `external_directory: \"ask\"` still requires confirmation for paths outside `cwd`.",
128
- "$ref": "#/$defs/permissionState"
129
- },
130
- "tool_call_limit": {
131
- "description": "Deprecated and ignored. This key has no effect and should be removed from your config.",
132
- "markdownDescription": "⚠️ **Deprecated and ignored.** This key has no effect and should be removed from your config.",
133
- "deprecated": true,
134
- "$ref": "#/$defs/permissionState"
135
- }
136
- }
137
69
  }
138
70
  },
139
71
  "$defs": {
@@ -155,8 +87,8 @@
155
87
  ]
156
88
  },
157
89
  "permissionMap": {
158
- "description": "A map of name or wildcard patterns to permission states. Keys are matched against the relevant target (tool name, bash command, MCP target, or skill name).",
159
- "markdownDescription": "A map of name or wildcard patterns to permission states.\n\nKeys are matched against the relevant target. Use `*` for wildcard matching. When multiple patterns match, the **last matching rule wins**.",
90
+ "description": "A map of wildcard patterns to permission states. Last matching pattern wins.",
91
+ "markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.",
160
92
  "type": "object",
161
93
  "propertyNames": {
162
94
  "description": "A non-empty pattern string. Use * for wildcard matching.",
@@ -9,10 +9,10 @@ import {
9
9
  getLegacyProjectPolicyPath,
10
10
  getProjectConfigPath,
11
11
  } from "./config-paths";
12
- import type { PermissionDefaultPolicy, PermissionState } from "./types";
12
+ import type { FlatPermissionConfig } from "./types";
13
13
 
14
14
  /**
15
- * Unified config shape combining runtime knobs and policy in one object.
15
+ * Unified config shape combining runtime knobs and flat permission policy.
16
16
  * All fields are optional so partial configs (project-only, global-only) work.
17
17
  */
18
18
  export interface UnifiedPermissionConfig {
@@ -21,13 +21,8 @@ export interface UnifiedPermissionConfig {
21
21
  permissionReviewLog?: boolean;
22
22
  yoloMode?: boolean;
23
23
 
24
- // Policy
25
- defaultPolicy?: Partial<PermissionDefaultPolicy>;
26
- tools?: Record<string, PermissionState>;
27
- bash?: Record<string, PermissionState>;
28
- mcp?: Record<string, PermissionState>;
29
- skills?: Record<string, PermissionState>;
30
- special?: Record<string, PermissionState>;
24
+ // Flat permission policy
25
+ permission?: FlatPermissionConfig;
31
26
  }
32
27
 
33
28
  export interface UnifiedConfigLoadResult {
@@ -35,23 +30,6 @@ export interface UnifiedConfigLoadResult {
35
30
  issues: string[];
36
31
  }
37
32
 
38
- const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
39
- "doom_loop",
40
- "tool_call_limit",
41
- ]);
42
-
43
- const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
44
- "bash",
45
- "read",
46
- "write",
47
- "edit",
48
- "grep",
49
- "find",
50
- "ls",
51
- ]);
52
-
53
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
54
-
55
33
  export function stripJsonComments(input: string): string {
56
34
  let output = "";
57
35
  let inString = false;
@@ -124,51 +102,86 @@ export function stripJsonComments(input: string): string {
124
102
  return output;
125
103
  }
126
104
 
127
- function normalizePartialPolicy(
128
- value: unknown,
129
- ): Partial<PermissionDefaultPolicy> | undefined {
130
- const record = toRecord(value);
131
- const normalized: Partial<PermissionDefaultPolicy> = {};
132
- let hasAny = false;
133
-
134
- for (const key of ["tools", "bash", "mcp", "skills", "special"] as const) {
135
- if (isPermissionState(record[key])) {
136
- normalized[key] = record[key] as PermissionState;
137
- hasAny = true;
138
- }
105
+ function normalizeOptionalBoolean(value: unknown): boolean | undefined {
106
+ if (typeof value === "boolean") {
107
+ return value;
139
108
  }
140
-
141
- return hasAny ? normalized : undefined;
109
+ return undefined;
142
110
  }
143
111
 
144
- function normalizePermissionRecord(
112
+ /**
113
+ * Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
114
+ * Drops non-object top-level values, invalid PermissionState strings, and
115
+ * invalid action values inside object maps.
116
+ */
117
+ function normalizeFlatPermissionValue(
145
118
  value: unknown,
146
- ): Record<string, PermissionState> | undefined {
147
- const record = toRecord(value);
148
- const normalized: Record<string, PermissionState> = {};
119
+ ): FlatPermissionConfig | undefined {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
121
+ return undefined;
122
+ }
123
+ const record = value as Record<string, unknown>;
124
+ const normalized: FlatPermissionConfig = {};
149
125
  let hasAny = false;
150
126
 
151
- for (const [key, state] of Object.entries(record)) {
152
- if (isPermissionState(state)) {
153
- normalized[key] = state;
154
- hasAny = true;
127
+ for (const [key, val] of Object.entries(record)) {
128
+ if (typeof val === "string") {
129
+ if (isPermissionState(val)) {
130
+ normalized[key] = val;
131
+ hasAny = true;
132
+ }
133
+ } else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
134
+ const map: Record<string, import("./types").PermissionState> = {};
135
+ let mapHasAny = false;
136
+ for (const [pattern, action] of Object.entries(
137
+ val as Record<string, unknown>,
138
+ )) {
139
+ if (isPermissionState(action)) {
140
+ map[pattern] = action;
141
+ mapHasAny = true;
142
+ }
143
+ }
144
+ if (mapHasAny) {
145
+ normalized[key] = map;
146
+ hasAny = true;
147
+ }
155
148
  }
156
149
  }
157
150
 
158
151
  return hasAny ? normalized : undefined;
159
152
  }
160
153
 
161
- function normalizeOptionalBoolean(value: unknown): boolean | undefined {
162
- if (typeof value === "boolean") {
163
- return value;
154
+ /**
155
+ * Deep-shallow merge two flat permission configs.
156
+ * - Both objects for same key → shallow-merge the pattern maps.
157
+ * - Otherwise → override replaces base.
158
+ */
159
+ function mergeFlatPermissions(
160
+ base: FlatPermissionConfig,
161
+ override: FlatPermissionConfig,
162
+ ): FlatPermissionConfig {
163
+ const merged: FlatPermissionConfig = { ...base };
164
+ for (const [key, value] of Object.entries(override)) {
165
+ const baseVal = merged[key];
166
+ if (
167
+ typeof baseVal === "object" &&
168
+ baseVal !== null &&
169
+ typeof value === "object" &&
170
+ value !== null
171
+ ) {
172
+ merged[key] = {
173
+ ...(baseVal as Record<string, import("./types").PermissionState>),
174
+ ...(value as Record<string, import("./types").PermissionState>),
175
+ };
176
+ } else {
177
+ merged[key] = value;
178
+ }
164
179
  }
165
- return undefined;
180
+ return merged;
166
181
  }
167
182
 
168
183
  /**
169
184
  * Normalize raw parsed JSON into the unified config shape.
170
- * Handles top-level shorthand keys (e.g. `bash: "allow"` at root)
171
- * and deprecated special keys, collecting issues along the way.
172
185
  */
173
186
  export function normalizeUnifiedConfig(raw: unknown): {
174
187
  config: UnifiedPermissionConfig;
@@ -176,7 +189,6 @@ export function normalizeUnifiedConfig(raw: unknown): {
176
189
  } {
177
190
  const record = toRecord(raw);
178
191
  const issues: string[] = [];
179
-
180
192
  const config: UnifiedPermissionConfig = {};
181
193
 
182
194
  // Runtime knobs
@@ -192,60 +204,18 @@ export function normalizeUnifiedConfig(raw: unknown): {
192
204
  const yoloMode = normalizeOptionalBoolean(record.yoloMode);
193
205
  if (yoloMode !== undefined) config.yoloMode = yoloMode;
194
206
 
195
- // Policy
196
- const defaultPolicy = normalizePartialPolicy(record.defaultPolicy);
197
- if (defaultPolicy) config.defaultPolicy = defaultPolicy;
198
-
199
- const tools = normalizePermissionRecord(record.tools);
200
- if (tools) config.tools = tools;
201
-
202
- const bash = normalizePermissionRecord(record.bash);
203
- if (bash) config.bash = bash;
204
-
205
- const mcp = normalizePermissionRecord(record.mcp);
206
- if (mcp) config.mcp = mcp;
207
-
208
- const skills = normalizePermissionRecord(record.skills);
209
- if (skills) config.skills = skills;
210
-
211
- const special = normalizePermissionRecord(record.special);
212
- if (special) config.special = special;
213
-
214
- // Detect deprecated special keys
215
- const rawSpecial = toRecord(record.special);
216
- for (const key of DEPRECATED_SPECIAL_KEYS) {
217
- if (key in rawSpecial) {
218
- issues.push(
219
- `special.${key} is deprecated and ignored — remove it from your config file.`,
220
- );
221
- if (config.special) {
222
- delete config.special[key];
223
- if (Object.keys(config.special).length === 0) {
224
- delete config.special;
225
- }
226
- }
227
- }
228
- }
229
-
230
- // Handle top-level shorthand keys (e.g. `bash: "allow"` at root level)
231
- for (const [key, value] of Object.entries(record)) {
232
- if (!isPermissionState(value)) continue;
233
-
234
- if (BUILT_IN_TOOL_PERMISSION_NAMES.has(key)) {
235
- config.tools = { ...(config.tools || {}), [key]: value };
236
- } else if (SPECIAL_PERMISSION_KEYS.has(key)) {
237
- config.special = { ...(config.special || {}), [key]: value };
238
- }
239
- }
207
+ // Flat permission policy
208
+ const permission = normalizeFlatPermissionValue(record.permission);
209
+ if (permission !== undefined) config.permission = permission;
240
210
 
241
211
  return { config, issues };
242
212
  }
243
213
 
244
214
  /**
245
- * Merge two unified configs. Object-shaped fields (defaultPolicy, tools, bash,
246
- * mcp, skills, special) are shallow-merged (override wins per-key). Scalar
247
- * fields (debugLog, permissionReviewLog, yoloMode) are replaced when present
248
- * in the override.
215
+ * Merge two unified configs.
216
+ * - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
217
+ * - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
218
+ * present in the override.
249
219
  */
250
220
  export function mergeUnifiedConfigs(
251
221
  base: UnifiedPermissionConfig,
@@ -261,20 +231,15 @@ export function mergeUnifiedConfigs(
261
231
  }
262
232
  }
263
233
 
264
- // Object fields: shallow spread merge
265
- for (const key of [
266
- "defaultPolicy",
267
- "tools",
268
- "bash",
269
- "mcp",
270
- "skills",
271
- "special",
272
- ] as const) {
273
- const baseVal = base[key];
274
- const overrideVal = override[key];
275
- if (baseVal || overrideVal) {
276
- merged[key] = { ...(baseVal || {}), ...(overrideVal || {}) } as never;
277
- }
234
+ // Permission: deep-shallow merge
235
+ const basePerm = base.permission;
236
+ const overridePerm = override.permission;
237
+ if (basePerm && overridePerm) {
238
+ merged.permission = mergeFlatPermissions(basePerm, overridePerm);
239
+ } else if (basePerm) {
240
+ merged.permission = basePerm;
241
+ } else if (overridePerm) {
242
+ merged.permission = overridePerm;
278
243
  }
279
244
 
280
245
  return merged;
@@ -297,6 +262,10 @@ export interface MergedConfigResult {
297
262
  * 3. New global config
298
263
  * 4. Legacy project policy (if present)
299
264
  * 5. New project config — highest precedence
265
+ *
266
+ * Legacy files are detected and warned about. Their content is parsed with the
267
+ * flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
268
+ * are not translated and contribute no permission rules.
300
269
  */
301
270
  export function loadAndMergeConfigs(
302
271
  agentDir: string,
package/src/defaults.ts CHANGED
@@ -1,60 +1,10 @@
1
- import type { PermissionDefaultPolicy, PermissionState } from "./types";
2
-
3
- /** Hardcoded fallback — every surface defaults to "ask" (least privilege). */
4
- export const DEFAULT_POLICY: PermissionDefaultPolicy = {
5
- tools: "ask",
6
- bash: "ask",
7
- mcp: "ask",
8
- skills: "ask",
9
- special: "ask",
10
- };
11
-
12
- /**
13
- * Map a surface name used in evaluate() to the corresponding
14
- * defaultPolicy key. Surfaces not listed here fall through to
15
- * either "tools" or "special" via getSurfaceDefault().
16
- */
17
- const SURFACE_TO_DEFAULT_KEY: Record<string, keyof PermissionDefaultPolicy> = {
18
- bash: "bash",
19
- mcp: "mcp",
20
- skill: "skills",
21
- };
22
-
23
1
  /**
24
- * Resolve the default action for a surface, consulting merged defaults.
2
+ * @deprecated This module has been removed as part of #66 (flat permission
3
+ * config format). It is kept as an empty stub to avoid breaking any lingering
4
+ * references during the migration; delete it in a follow-up cleanup.
25
5
  *
26
- * - "bash", "mcp", "skill" dedicated defaultPolicy key
27
- * - special-key surfaces (e.g. "external_directory") defaults.special
28
- * - everything else (tool-name surfaces) → defaults.tools
6
+ * The universal default is now expressed as permission["*"] in the flat config.
7
+ * mergeDefaults() and getSurfaceDefault() have no replacement.
29
8
  */
30
- export function getSurfaceDefault(
31
- surface: string,
32
- defaults: PermissionDefaultPolicy,
33
- specialKeys: ReadonlySet<string>,
34
- ): PermissionState {
35
- const key = SURFACE_TO_DEFAULT_KEY[surface];
36
- if (key) return defaults[key];
37
- if (specialKeys.has(surface)) return defaults.special;
38
- return defaults.tools;
39
- }
40
-
41
- /**
42
- * Merge zero or more partial default policies on top of DEFAULT_POLICY.
43
- * Later partials override earlier ones (shallow spread per key).
44
- */
45
- export function mergeDefaults(
46
- ...partials: ReadonlyArray<Partial<PermissionDefaultPolicy> | undefined>
47
- ): PermissionDefaultPolicy {
48
- const merged: PermissionDefaultPolicy = { ...DEFAULT_POLICY };
49
-
50
- for (const partial of partials) {
51
- if (!partial) continue;
52
- if (partial.tools) merged.tools = partial.tools;
53
- if (partial.bash) merged.bash = partial.bash;
54
- if (partial.mcp) merged.mcp = partial.mcp;
55
- if (partial.skills) merged.skills = partial.skills;
56
- if (partial.special) merged.special = partial.special;
57
- }
58
9
 
59
- return merged;
60
- }
10
+ export {};
@@ -130,10 +130,9 @@ export function loadPermissionSystemConfig(
130
130
  }
131
131
  if (misplacedKeys.length > 0) {
132
132
  warnings.push(
133
- `config.json contains permission-rule keys that are ignored here: ${misplacedKeys.join(", ")}.\n` +
134
- "Permission rules belong in ~/.pi/agent/pi-permissions.jsonc, " +
135
- "<project>/.pi/agent/pi-permissions.jsonc, or per-agent frontmatter.\n" +
136
- "See config/config.example.json for the keys config.json supports.",
133
+ `config.json contains legacy permission-rule keys that are ignored: ${misplacedKeys.join(", ")}.\n` +
134
+ 'Use the flat permission format: { "permission": { "*": "ask", "read": "allow", ... } }.\n' +
135
+ "See config/config.example.json for the new format.",
137
136
  );
138
137
  }
139
138
 
@@ -29,7 +29,6 @@ import {
29
29
  formatUnknownToolReason,
30
30
  formatUserDeniedReason,
31
31
  } from "../permission-prompts";
32
- import { evaluate } from "../rule";
33
32
  import { deriveApprovalPattern } from "../session-rules";
34
33
  import { findSkillPathMatch } from "../skill-prompt-sanitizer";
35
34
  import { getPermissionLogContext } from "../tool-input-preview";
@@ -170,15 +169,14 @@ export async function handleToolCall(
170
169
  externalDirectoryPath,
171
170
  ctx.cwd,
172
171
  );
173
- const sessionRuleset = deps.runtime.sessionRules.getRuleset();
174
- const sessionMatch = evaluate(
172
+ const extCheck = deps.runtime.permissionManager.checkPermission(
175
173
  "external_directory",
176
- normalizedExtPath,
177
- sessionRuleset,
174
+ { path: normalizedExtPath },
175
+ agentName ?? undefined,
176
+ deps.runtime.sessionRules.getRuleset(),
178
177
  );
179
- const isSessionApproved = sessionRuleset.includes(sessionMatch);
180
178
 
181
- if (isSessionApproved) {
179
+ if (extCheck.source === "session") {
182
180
  deps.runtime.writeReviewLog("permission_request.session_approved", {
183
181
  source: "tool_call",
184
182
  toolCallId: (event as { toolCallId: string }).toolCallId,
@@ -186,16 +184,10 @@ export async function handleToolCall(
186
184
  agentName,
187
185
  path: externalDirectoryPath,
188
186
  resolution: "session_approved",
189
- sessionApprovalPattern: sessionMatch.pattern,
187
+ sessionApprovalPattern: extCheck.matchedPattern,
190
188
  });
191
189
  // Fall through to normal permission check
192
190
  } else {
193
- const extCheck = deps.runtime.permissionManager.checkPermission(
194
- "external_directory",
195
- {},
196
- agentName ?? undefined,
197
- );
198
-
199
191
  let extDirDecision: PermissionPromptDecision | null = null;
200
192
  const extDirMessage = formatExternalDirectoryAskPrompt(
201
193
  toolName,
@@ -265,12 +257,15 @@ export async function handleToolCall(
265
257
  ctx.cwd,
266
258
  );
267
259
  if (externalPaths.length > 0) {
268
- const bashSessionRuleset = deps.runtime.sessionRules.getRuleset();
260
+ const bashSessionRules = deps.runtime.sessionRules.getRuleset();
269
261
  const uncoveredPaths = externalPaths.filter(
270
262
  (p) =>
271
- !bashSessionRuleset.includes(
272
- evaluate("external_directory", p, bashSessionRuleset),
273
- ),
263
+ deps.runtime.permissionManager.checkPermission(
264
+ "external_directory",
265
+ { path: p },
266
+ agentName ?? undefined,
267
+ bashSessionRules,
268
+ ).source !== "session",
274
269
  );
275
270
 
276
271
  if (uncoveredPaths.length === 0) {
@@ -285,6 +280,7 @@ export async function handleToolCall(
285
280
  });
286
281
  // Fall through to normal bash permission check
287
282
  } else {
283
+ // Get the config-level policy (no path → no session check).
288
284
  const extCheck = deps.runtime.permissionManager.checkPermission(
289
285
  "external_directory",
290
286
  {},
@@ -361,6 +357,7 @@ export async function handleToolCall(
361
357
  toolName,
362
358
  input,
363
359
  agentName ?? undefined,
360
+ deps.runtime.sessionRules.getRuleset(),
364
361
  );
365
362
  const permissionLogContext = getPermissionLogContext(
366
363
  check,