@gotgenes/pi-permission-system 12.0.0 → 13.1.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +7 -9
  3. package/config/config.example.json +2 -1
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +28 -2
  6. package/src/common.ts +17 -1
  7. package/src/config-loader.ts +9 -5
  8. package/src/config-modal.ts +3 -7
  9. package/src/denial-messages.ts +2 -1
  10. package/src/extension-config.ts +11 -25
  11. package/src/handlers/gates/bash-path-extractor.ts +0 -12
  12. package/src/handlers/gates/bash-path.ts +12 -10
  13. package/src/handlers/gates/bash-program.ts +52 -11
  14. package/src/handlers/gates/bash-token-classification.ts +1 -1
  15. package/src/index.ts +4 -2
  16. package/src/input-normalizer.ts +17 -11
  17. package/src/normalize.ts +12 -2
  18. package/src/path-utils.ts +81 -0
  19. package/src/permission-manager.ts +82 -17
  20. package/src/permission-resolver.ts +24 -0
  21. package/src/permission-session.ts +2 -4
  22. package/src/rule.ts +63 -11
  23. package/src/types.ts +18 -3
  24. package/test/bash-external-directory.test.ts +1 -81
  25. package/test/common.test.ts +28 -0
  26. package/test/config-loader.test.ts +43 -0
  27. package/test/config-modal.test.ts +7 -10
  28. package/test/config-pipeline.test.ts +90 -0
  29. package/test/denial-messages.test.ts +61 -0
  30. package/test/extension-config.test.ts +0 -58
  31. package/test/handlers/gates/bash-path.test.ts +45 -2
  32. package/test/handlers/gates/bash-program.test.ts +44 -11
  33. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +1 -1
  34. package/test/helpers/gate-fixtures.ts +23 -3
  35. package/test/helpers/handler-fixtures.ts +14 -2
  36. package/test/helpers/session-fixtures.ts +14 -0
  37. package/test/input-normalizer.test.ts +52 -0
  38. package/test/normalize.test.ts +81 -0
  39. package/test/path-utils.test.ts +72 -0
  40. package/test/permission-manager-unified.test.ts +199 -0
  41. package/test/permission-resolver.test.ts +69 -0
  42. package/test/rule.test.ts +135 -1
package/src/path-utils.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  join,
3
3
  normalize,
4
4
  posix as posixPath,
5
+ relative,
5
6
  resolve,
6
7
  win32 as winPath,
7
8
  } from "node:path";
@@ -63,6 +64,86 @@ export function isPathWithinDirectory(
63
64
  );
64
65
  }
65
66
 
67
+ export interface PathPolicyValueOptions {
68
+ /**
69
+ * Current Pi working directory. When provided, returned values include a
70
+ * project-relative alias for paths that resolve inside this directory.
71
+ */
72
+ cwd?: string;
73
+ /**
74
+ * Directory used to resolve `pathValue` into an absolute policy value.
75
+ * Defaults to `cwd`. Bash uses this for tokens seen after a literal `cd`.
76
+ */
77
+ resolveBase?: string;
78
+ }
79
+
80
+ /**
81
+ * Normalize a single path-like lookup value without resolving it against CWD.
82
+ *
83
+ * Preserves compatibility with existing relative path rules (`src/*`, `*.env`)
84
+ * while applying the same lexical cleanup as
85
+ * {@link normalizePathForComparison}: trim, strip simple wrapping quotes,
86
+ * strip the OpenCode-style leading `@`, and expand `~` / `$HOME`.
87
+ */
88
+ export function normalizePathPolicyLiteral(pathValue: string): string {
89
+ const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
90
+ if (!trimmed) return "";
91
+ const unprefixed = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
92
+ return expandHomePath(unprefixed);
93
+ }
94
+
95
+ /**
96
+ * Return equivalent lookup values for path-policy matching.
97
+ *
98
+ * The first value is the cwd/effective-base normalized absolute path when a
99
+ * base is available. The later values preserve project-relative and raw
100
+ * relative forms so existing rules like `src/*` and `*.env` continue to match.
101
+ */
102
+ export function getPathPolicyValues(
103
+ pathValue: string,
104
+ options: PathPolicyValueOptions = {},
105
+ ): string[] {
106
+ const literal = normalizePathPolicyLiteral(pathValue);
107
+ if (!literal) return [];
108
+ if (literal === "*") return ["*"];
109
+
110
+ return [
111
+ ...new Set([...getAbsolutePathPolicyValues(pathValue, options), literal]),
112
+ ];
113
+ }
114
+
115
+ function getAbsolutePathPolicyValues(
116
+ pathValue: string,
117
+ options: PathPolicyValueOptions,
118
+ ): string[] {
119
+ const resolveBase = options.resolveBase ?? options.cwd;
120
+ if (!resolveBase) return [];
121
+
122
+ const absolute = normalizePathForComparison(pathValue, resolveBase);
123
+ if (!absolute) return [];
124
+
125
+ return [absolute, ...getCwdRelativePathPolicyValues(absolute, options.cwd)];
126
+ }
127
+
128
+ function getCwdRelativePathPolicyValues(
129
+ absolute: string,
130
+ cwd: string | undefined,
131
+ ): string[] {
132
+ if (!cwd) return [];
133
+
134
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
135
+ if (!normalizedCwd) return [];
136
+ if (
137
+ absolute !== normalizedCwd &&
138
+ !isPathWithinDirectory(absolute, normalizedCwd)
139
+ ) {
140
+ return [];
141
+ }
142
+
143
+ const relativeValue = relative(normalizedCwd, absolute);
144
+ return relativeValue ? [relativeValue] : [];
145
+ }
146
+
66
147
  /**
67
148
  * Paths that are universally safe and should never trigger external-directory checks.
68
149
  * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
@@ -3,6 +3,7 @@ import { isPermissionState } from "./common";
3
3
  import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
4
4
  import { normalizeInput } from "./input-normalizer";
5
5
  import { normalizeFlatConfig } from "./normalize";
6
+ import { PATH_SURFACES } from "./path-utils";
6
7
  import {
7
8
  FilePolicyLoader,
8
9
  type PolicyLoader,
@@ -10,7 +11,7 @@ import {
10
11
  type ResolvedPolicyPaths,
11
12
  } from "./policy-loader";
12
13
  import type { Rule, RuleOrigin, Ruleset } from "./rule";
13
- import { evaluate, evaluateFirst } from "./rule";
14
+ import { evaluate, evaluateAnyValue, evaluateFirst } from "./rule";
14
15
  import { mergeScopesWithOrigins } from "./scope-merge";
15
16
  import {
16
17
  composeRuleset,
@@ -63,6 +64,17 @@ export interface ScopedPermissionManager {
63
64
  agentName?: string,
64
65
  sessionRules?: Ruleset,
65
66
  ): PermissionCheckResult;
67
+ /**
68
+ * Evaluate the cross-cutting `path` surface against a caller-supplied set of
69
+ * equivalent policy values (e.g. bash tokens already resolved against a
70
+ * preceding literal `cd`). The values are trusted because they are computed
71
+ * internally, never read from a field on raw tool input.
72
+ */
73
+ checkPathPolicy(
74
+ values: readonly string[],
75
+ agentName?: string,
76
+ sessionRules?: Ruleset,
77
+ ): PermissionCheckResult;
66
78
  getToolPermission(toolName: string, agentName?: string): PermissionState;
67
79
  getConfigIssues(agentName?: string): string[];
68
80
  getPolicyCacheStamp(agentName?: string): string;
@@ -79,6 +91,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
79
91
 
80
92
  export class PermissionManager implements ScopedPermissionManager {
81
93
  private readonly agentDir: string | undefined;
94
+ private currentCwd: string | undefined;
82
95
  private loader: PolicyLoader;
83
96
  private readonly resolvedPermissionsCache = new Map<
84
97
  string,
@@ -104,6 +117,8 @@ export class PermissionManager implements ScopedPermissionManager {
104
117
  * built with explicit paths), only the cache is cleared.
105
118
  */
106
119
  configureForCwd(cwd: string | undefined | null): void {
120
+ this.currentCwd =
121
+ typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
107
122
  if (this.agentDir !== undefined) {
108
123
  this.loader = new FilePolicyLoader(
109
124
  derivePolicyLoaderOptions(this.agentDir, cwd),
@@ -245,29 +260,79 @@ export class PermissionManager implements ScopedPermissionManager {
245
260
  normalizedToolName,
246
261
  input,
247
262
  this.loader.getConfiguredMcpServerNames(),
263
+ this.currentCwd,
248
264
  );
249
265
 
250
- const { rule, value } = evaluateFirst(surface, values, fullRules);
266
+ return buildCheckResult(
267
+ surface,
268
+ values,
269
+ resultExtras,
270
+ normalizedToolName,
271
+ toolName,
272
+ fullRules,
273
+ );
274
+ }
251
275
 
252
- // For MCP, replace the normalizer's fallback target with the actual
253
- // matched candidate value so PermissionCheckResult.target is accurate.
254
- const extras =
255
- surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
276
+ checkPathPolicy(
277
+ values: readonly string[],
278
+ agentName?: string,
279
+ sessionRules?: Ruleset,
280
+ ): PermissionCheckResult {
281
+ const { composedRules } = this.resolvePermissions(agentName);
282
+ const fullRules: Ruleset = sessionRules?.length
283
+ ? [...composedRules, ...sessionRules]
284
+ : composedRules;
256
285
 
257
- return {
258
- toolName,
259
- state: rule.action,
260
- matchedPattern:
261
- rule.layer === "config" || rule.layer === "session"
262
- ? rule.pattern
263
- : undefined,
264
- source: deriveSource(rule, normalizedToolName),
265
- origin: rule.origin,
266
- ...extras,
267
- };
286
+ const lookupValues = values.length > 0 ? [...values] : ["*"];
287
+ return buildCheckResult(
288
+ "path",
289
+ lookupValues,
290
+ {},
291
+ "path",
292
+ "path",
293
+ fullRules,
294
+ );
268
295
  }
269
296
  }
270
297
 
298
+ /**
299
+ * Evaluate a normalized surface/values triple and shape the result.
300
+ *
301
+ * Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
302
+ * aliases); every other surface keeps {@link evaluateFirst}. Shared by
303
+ * `checkPermission` and `checkPathPolicy`.
304
+ */
305
+ function buildCheckResult(
306
+ surface: string,
307
+ values: string[],
308
+ resultExtras: Record<string, unknown>,
309
+ normalizedToolName: string,
310
+ toolName: string,
311
+ fullRules: Ruleset,
312
+ ): PermissionCheckResult {
313
+ const { rule, value } = PATH_SURFACES.has(surface)
314
+ ? evaluateAnyValue(surface, values, fullRules)
315
+ : evaluateFirst(surface, values, fullRules);
316
+
317
+ // For MCP, replace the normalizer's fallback target with the actual
318
+ // matched candidate value so PermissionCheckResult.target is accurate.
319
+ const extras =
320
+ surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
321
+
322
+ return {
323
+ toolName,
324
+ state: rule.action,
325
+ reason: rule.reason,
326
+ matchedPattern:
327
+ rule.layer === "config" || rule.layer === "session"
328
+ ? rule.pattern
329
+ : undefined,
330
+ source: deriveSource(rule, normalizedToolName),
331
+ origin: rule.origin,
332
+ ...extras,
333
+ };
334
+ }
335
+
271
336
  /**
272
337
  * Derive `PolicyLoaderOptions` from an agentDir + an optional cwd.
273
338
  * Setting agentsDir explicitly from agentDir removes the hidden
@@ -17,6 +17,15 @@ export interface ScopedPermissionResolver {
17
17
  input: unknown,
18
18
  agentName?: string,
19
19
  ): PermissionCheckResult;
20
+ /**
21
+ * Resolve the cross-cutting `path` surface against a caller-supplied set of
22
+ * equivalent policy values, applying the current session rules. Used by the
23
+ * bash path gate, which computes cd-aware policy values per token.
24
+ */
25
+ resolvePathPolicy(
26
+ values: readonly string[],
27
+ agentName?: string,
28
+ ): PermissionCheckResult;
20
29
  }
21
30
 
22
31
  /**
@@ -53,6 +62,21 @@ export class PermissionResolver implements ScopedPermissionResolver {
53
62
  );
54
63
  }
55
64
 
65
+ /**
66
+ * Resolve the `path` surface for precomputed policy values, composing the
67
+ * current session ruleset so callers never thread it by hand.
68
+ */
69
+ resolvePathPolicy(
70
+ values: readonly string[],
71
+ agentName?: string,
72
+ ): PermissionCheckResult {
73
+ return this.permissionManager.checkPathPolicy(
74
+ values,
75
+ agentName,
76
+ this.sessionRules.getRuleset(),
77
+ );
78
+ }
79
+
56
80
  checkPermission(
57
81
  surface: string,
58
82
  input: unknown,
@@ -150,10 +150,8 @@ export class PermissionSession implements ToolCallGateInputs {
150
150
  return this.knownAgentName;
151
151
  }
152
152
 
153
- // Read by config-modal (`controller.session.lastKnownActiveAgentName`).
154
- // fallow cannot trace the getter through the command's object-literal
155
- // wiring, so it reports a false positive here.
156
- // fallow-ignore-next-line unused-class-member
153
+ // Read by the `index.ts` config-modal adapter closure:
154
+ // `permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
157
155
  get lastKnownActiveAgentName(): string | null {
158
156
  return this.knownAgentName;
159
157
  }
package/src/rule.ts CHANGED
@@ -27,6 +27,8 @@ export interface Rule {
27
27
  pattern: string;
28
28
  /** The permission decision. */
29
29
  action: PermissionState;
30
+ /** Custom denial reason for deny rules (optional). */
31
+ reason?: string;
30
32
  /**
31
33
  * Origin layer — used to derive PermissionCheckResult.source after evaluation.
32
34
  * Not used by evaluate(); purely informational metadata.
@@ -55,17 +57,8 @@ export function evaluate(
55
57
  defaultAction?: PermissionState,
56
58
  platform: NodeJS.Platform = process.platform,
57
59
  ): Rule {
58
- // On Windows, path-surface values are canonicalized + lowercased; fold the
59
- // pattern→value match (case and separators) so mixed-case / forward-slash
60
- // overrides still match. The surface→surface match stays exact.
61
- const matchOptions =
62
- platform === "win32" && PATH_SURFACES.has(surface)
63
- ? { caseInsensitive: true, windowsSeparators: true }
64
- : undefined;
65
- const rule = rules.findLast(
66
- (r) =>
67
- wildcardMatch(r.surface, surface) &&
68
- wildcardMatch(r.pattern, pattern, matchOptions),
60
+ const rule = rules.findLast((r) =>
61
+ ruleMatches(r, surface, pattern, platform),
69
62
  );
70
63
  if (rule !== undefined) return rule;
71
64
  return {
@@ -76,6 +69,33 @@ export function evaluate(
76
69
  };
77
70
  }
78
71
 
72
+ /**
73
+ * On Windows, path-surface values are canonicalized + lowercased; fold the
74
+ * pattern→value match (case and separators) so mixed-case / forward-slash
75
+ * overrides still match. The surface→surface match stays exact.
76
+ */
77
+ function pathMatchOptions(
78
+ surface: string,
79
+ platform: NodeJS.Platform,
80
+ ): { caseInsensitive: true; windowsSeparators: true } | undefined {
81
+ return platform === "win32" && PATH_SURFACES.has(surface)
82
+ ? { caseInsensitive: true, windowsSeparators: true }
83
+ : undefined;
84
+ }
85
+
86
+ function ruleMatches(
87
+ rule: Rule,
88
+ surface: string,
89
+ value: string,
90
+ platform: NodeJS.Platform,
91
+ ): boolean {
92
+ const matchOptions = pathMatchOptions(surface, platform);
93
+ return (
94
+ wildcardMatch(rule.surface, surface) &&
95
+ wildcardMatch(rule.pattern, value, matchOptions)
96
+ );
97
+ }
98
+
79
99
  /**
80
100
  * Evaluate a surface against an ordered list of candidate values, stopping at
81
101
  * the first candidate that matches a non-default rule (last-match-wins within
@@ -134,3 +154,35 @@ export function evaluateFirst(
134
154
  value: fallbackValue,
135
155
  };
136
156
  }
157
+
158
+ /**
159
+ * Evaluate equivalent lookup values as aliases of the same path.
160
+ *
161
+ * Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
162
+ * last rule that matches any alias wins. This lets absolute allowlists and
163
+ * legacy relative rules coexist without a catch-all match on the first alias
164
+ * masking a later, more specific rule on another alias.
165
+ */
166
+ export function evaluateAnyValue(
167
+ surface: string,
168
+ values: string[],
169
+ rules: Ruleset,
170
+ platform: NodeJS.Platform = process.platform,
171
+ ): { rule: Rule; value: string } {
172
+ const fallbackValue = values[0] ?? "*";
173
+ const rule = rules.findLast((r) =>
174
+ values.some((value) => ruleMatches(r, surface, value, platform)),
175
+ );
176
+ if (rule !== undefined) {
177
+ return {
178
+ rule,
179
+ value:
180
+ values.find((value) => ruleMatches(rule, surface, value, platform)) ??
181
+ fallbackValue,
182
+ };
183
+ }
184
+ return {
185
+ rule: evaluate(surface, fallbackValue, rules),
186
+ value: fallbackValue,
187
+ };
188
+ }
package/src/types.ts CHANGED
@@ -4,14 +4,27 @@ import type { RuleOrigin } from "./rule";
4
4
 
5
5
  export type { RuleOrigin };
6
6
 
7
+ /**
8
+ * A deny action with an optional reason annotation, used when a pattern maps
9
+ * to an object instead of a plain PermissionState string.
10
+ */
11
+ export interface DenyWithReason {
12
+ action: "deny";
13
+ reason?: string;
14
+ }
15
+
16
+ /** A pattern value: a PermissionState string OR a DenyWithReason object. */
17
+ export type PatternValue = PermissionState | DenyWithReason;
18
+
7
19
  /**
8
20
  * The on-disk permission shape inside the `"permission"` key.
9
- * Each key is a surface name; values are either a PermissionState string
10
- * (shorthand for `{ "*": action }`) or a pattern→action map.
21
+ * A surface value is a PermissionState string (shorthand for `{ "*": action }`)
22
+ * or a pattern→value map. Pattern values may be a PermissionState string or a
23
+ * DenyWithReason object. A top-level value is never a bare DenyWithReason.
11
24
  */
12
25
  export type FlatPermissionConfig = Record<
13
26
  string,
14
- PermissionState | Record<string, PermissionState>
27
+ PermissionState | Record<string, PatternValue>
15
28
  >;
16
29
 
17
30
  /**
@@ -34,6 +47,8 @@ export type BashCommandContext =
34
47
  export interface PermissionCheckResult {
35
48
  toolName: string;
36
49
  state: PermissionState;
50
+ /** Custom denial reason from a deny-with-reason pattern, when present. */
51
+ reason?: string;
37
52
  matchedPattern?: string;
38
53
  command?: string;
39
54
  target?: string;
@@ -18,10 +18,7 @@ vi.mock("node:fs", () => ({
18
18
  }));
19
19
 
20
20
  import { formatDenyReason } from "#src/denial-messages";
21
- import {
22
- extractExternalPathsFromBashCommand,
23
- extractTokensForPathRules,
24
- } from "#src/handlers/gates/bash-path-extractor";
21
+ import { extractExternalPathsFromBashCommand } from "#src/handlers/gates/bash-path-extractor";
25
22
  import { formatBashExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
26
23
 
27
24
  afterEach(() => {
@@ -957,80 +954,3 @@ describe("bash external-directory denial messages (centralized)", () => {
957
954
  expect(result).not.toContain("Hard stop");
958
955
  });
959
956
  });
960
-
961
- describe("extractTokensForPathRules", () => {
962
- test("extracts dot-files: cat .env", async () => {
963
- const tokens = await extractTokensForPathRules("cat .env");
964
- expect(tokens).toContain(".env");
965
- });
966
-
967
- test("extracts relative dot-paths: git add src/.env", async () => {
968
- const tokens = await extractTokensForPathRules("git add src/.env");
969
- expect(tokens).toContain("src/.env");
970
- });
971
-
972
- test("extracts nothing from plain words: echo hello", async () => {
973
- const tokens = await extractTokensForPathRules("echo hello");
974
- expect(tokens).toHaveLength(0);
975
- });
976
-
977
- test("extracts ./src and skips flags: rm -rf ./src", async () => {
978
- const tokens = await extractTokensForPathRules("rm -rf ./src");
979
- expect(tokens).toContain("./src");
980
- expect(tokens).not.toContain("-rf");
981
- });
982
-
983
- test("extracts absolute paths: cat /etc/hosts", async () => {
984
- const tokens = await extractTokensForPathRules("cat /etc/hosts");
985
- expect(tokens).toContain("/etc/hosts");
986
- });
987
-
988
- test("skips URLs: curl https://example.com", async () => {
989
- const tokens = await extractTokensForPathRules("curl https://example.com");
990
- expect(tokens).not.toContain("https://example.com");
991
- });
992
-
993
- test("extracts slash-containing tokens: cat src/foo.ts", async () => {
994
- const tokens = await extractTokensForPathRules("cat src/foo.ts");
995
- expect(tokens).toContain("src/foo.ts");
996
- });
997
-
998
- test("skips heredoc content", async () => {
999
- const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
1000
- expect(tokens).not.toContain(".env");
1001
- });
1002
-
1003
- test("skips @scope/package patterns", async () => {
1004
- const tokens = await extractTokensForPathRules(
1005
- "npm install @scope/package",
1006
- );
1007
- expect(tokens).not.toContain("@scope/package");
1008
- });
1009
-
1010
- test("skips env assignments", async () => {
1011
- const tokens = await extractTokensForPathRules("FOO=/bar command");
1012
- expect(tokens).not.toContain("FOO=/bar");
1013
- });
1014
-
1015
- test("skips bare-slash tokens", async () => {
1016
- const tokens = await extractTokensForPathRules("ls /");
1017
- expect(tokens).not.toContain("/");
1018
- });
1019
-
1020
- test("extracts redirect targets: echo test > .env", async () => {
1021
- const tokens = await extractTokensForPathRules("echo test > .env");
1022
- expect(tokens).toContain(".env");
1023
- });
1024
-
1025
- test("extracts multiple path tokens: cp .env .env.backup", async () => {
1026
- const tokens = await extractTokensForPathRules("cp .env .env.backup");
1027
- expect(tokens).toContain(".env");
1028
- expect(tokens).toContain(".env.backup");
1029
- });
1030
-
1031
- test("deduplicates repeated tokens", async () => {
1032
- const tokens = await extractTokensForPathRules("cat .env && rm .env");
1033
- const envCount = tokens.filter((t) => t === ".env").length;
1034
- expect(envCount).toBe(1);
1035
- });
1036
- });
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, test, vi } from "vitest";
3
3
  import {
4
4
  extractFrontmatter,
5
5
  getNonEmptyString,
6
+ isDenyWithReason,
6
7
  isPermissionState,
7
8
  normalizeOptionalPositiveInt,
8
9
  normalizeOptionalStringArray,
@@ -102,6 +103,33 @@ describe("isPermissionState", () => {
102
103
  });
103
104
  });
104
105
 
106
+ describe("isDenyWithReason", () => {
107
+ test("returns true for { action: 'deny' } without a reason", () => {
108
+ expect(isDenyWithReason({ action: "deny" })).toBe(true);
109
+ });
110
+
111
+ test("returns true for { action: 'deny', reason: '...' }", () => {
112
+ expect(isDenyWithReason({ action: "deny", reason: "Use pnpm" })).toBe(true);
113
+ });
114
+
115
+ test("returns false for non-deny actions", () => {
116
+ expect(isDenyWithReason({ action: "allow" })).toBe(false);
117
+ expect(isDenyWithReason({ action: "ask" })).toBe(false);
118
+ });
119
+
120
+ test("returns false for a non-string reason", () => {
121
+ expect(isDenyWithReason({ action: "deny", reason: 42 })).toBe(false);
122
+ expect(isDenyWithReason({ action: "deny", reason: null })).toBe(false);
123
+ });
124
+
125
+ test("returns false for non-object types", () => {
126
+ expect(isDenyWithReason(null)).toBe(false);
127
+ expect(isDenyWithReason(undefined)).toBe(false);
128
+ expect(isDenyWithReason("deny")).toBe(false);
129
+ expect(isDenyWithReason(["deny"])).toBe(false);
130
+ });
131
+ });
132
+
105
133
  describe("extractFrontmatter", () => {
106
134
  test("returns empty string when no frontmatter delimiter", () => {
107
135
  expect(extractFrontmatter("# Hello\nSome content")).toBe("");
@@ -243,6 +243,49 @@ describe("loadUnifiedConfig", () => {
243
243
  });
244
244
  });
245
245
 
246
+ it("preserves a deny-with-reason object inside a pattern map", () => {
247
+ const configPath = join(tempDir, "config.json");
248
+ writeFileSync(
249
+ configPath,
250
+ JSON.stringify({
251
+ permission: {
252
+ bash: {
253
+ "git *": "allow",
254
+ "npm *": { action: "deny", reason: "Use pnpm instead" },
255
+ },
256
+ },
257
+ }),
258
+ );
259
+
260
+ const result = loadUnifiedConfig(configPath);
261
+ expect(result.config.permission).toEqual({
262
+ bash: {
263
+ "git *": "allow",
264
+ "npm *": { action: "deny", reason: "Use pnpm instead" },
265
+ },
266
+ });
267
+ });
268
+
269
+ it("strips a deny object with a non-string reason (malformed)", () => {
270
+ const configPath = join(tempDir, "config.json");
271
+ writeFileSync(
272
+ configPath,
273
+ JSON.stringify({
274
+ permission: {
275
+ bash: {
276
+ "git *": "allow",
277
+ "npm *": { action: "deny", reason: 42 },
278
+ },
279
+ },
280
+ }),
281
+ );
282
+
283
+ const result = loadUnifiedConfig(configPath);
284
+ expect(result.config.permission).toEqual({
285
+ bash: { "git *": "allow" },
286
+ });
287
+ });
288
+
246
289
  it("returns no permission when the permission field is absent", () => {
247
290
  const configPath = join(tempDir, "config.json");
248
291
  writeFileSync(configPath, JSON.stringify({ debugLog: false }));
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { expect, test, vi } from "vitest";
5
+ import { loadUnifiedConfig } from "#src/config-loader";
5
6
  import { registerPermissionSystemCommand } from "#src/config-modal";
6
7
  import type { CommandConfigStore } from "#src/config-store";
7
8
  import {
@@ -89,8 +90,7 @@ test("permission-system command completions expose top-level config actions", ()
89
90
  const controller = {
90
91
  config: configStore,
91
92
  configPath,
92
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
93
- session: { lastKnownActiveAgentName: null },
93
+ getActiveAgentConfigRules: () => [] as Ruleset,
94
94
  };
95
95
 
96
96
  let definition: {
@@ -146,7 +146,7 @@ test("permission-system command handlers manage config summary, persistence, and
146
146
  current: () => config,
147
147
  save: (next) => {
148
148
  const currentConfig = normalizePermissionSystemConfig(
149
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
149
+ loadUnifiedConfig(configPath).config,
150
150
  );
151
151
  const normalized = normalizePermissionSystemConfig(next);
152
152
  writeFileSync(
@@ -155,7 +155,7 @@ test("permission-system command handlers manage config summary, persistence, and
155
155
  "utf-8",
156
156
  );
157
157
  config = normalizePermissionSystemConfig(
158
- JSON.parse(readFileSync(configPath, "utf-8")) as unknown,
158
+ loadUnifiedConfig(configPath).config,
159
159
  );
160
160
  expect(config).not.toEqual(currentConfig);
161
161
  },
@@ -163,8 +163,7 @@ test("permission-system command handlers manage config summary, persistence, and
163
163
  const controller = {
164
164
  config: configStore,
165
165
  configPath,
166
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
167
- session: { lastKnownActiveAgentName: null },
166
+ getActiveAgentConfigRules: () => [] as Ruleset,
168
167
  };
169
168
 
170
169
  let registeredName = "";
@@ -262,8 +261,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
262
261
  const controller = {
263
262
  config: { current: () => config, save: () => {} } as CommandConfigStore,
264
263
  configPath: "/fake/config.json",
265
- permissionManager: { getComposedConfigRules: () => composedRules },
266
- session: { lastKnownActiveAgentName: null },
264
+ getActiveAgentConfigRules: () => composedRules,
267
265
  };
268
266
 
269
267
  let definition: {
@@ -295,8 +293,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
295
293
  const controller = {
296
294
  config: { current: () => config, save: () => {} } as CommandConfigStore,
297
295
  configPath: "/fake/config.json",
298
- permissionManager: { getComposedConfigRules: () => [] as Ruleset },
299
- session: { lastKnownActiveAgentName: null },
296
+ getActiveAgentConfigRules: () => [] as Ruleset,
300
297
  };
301
298
 
302
299
  let definition: {