@gotgenes/pi-permission-system 13.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.0.0...pi-permission-system-v13.1.0) (2026-06-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add DenyWithReason type and shared guard ([51750e1](https://github.com/gotgenes/pi-packages/commit/51750e188592520798eaf9676a15a709a779cf96)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
14
+ * **pi-permission-system:** append custom reason to denial messages ([d8e5756](https://github.com/gotgenes/pi-packages/commit/d8e575632678b806d381f1436dbb06197d742104)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
15
+ * **pi-permission-system:** build deny rules with reason in normalizeFlatConfig ([186c15a](https://github.com/gotgenes/pi-packages/commit/186c15a74944bc2800bcea738984021169fabc8d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
16
+ * **pi-permission-system:** preserve deny-with-reason from JSON config ([3201bfd](https://github.com/gotgenes/pi-packages/commit/3201bfd55d68aac1ee87ac452723f6d0783dba6d)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
17
+ * **pi-permission-system:** thread deny reason into PermissionCheckResult ([ed712e4](https://github.com/gotgenes/pi-packages/commit/ed712e47458a662e3d1159e2f5096c709ab2ddf5)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
18
+
19
+
20
+ ### Documentation
21
+
22
+ * **pi-permission-system:** document deny-with-reason config form ([45be4e7](https://github.com/gotgenes/pi-packages/commit/45be4e72c0ca43040cb0f55ca196a0cab0b9fc14)), closes [#395](https://github.com/gotgenes/pi-packages/issues/395)
23
+
8
24
  ## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v12.0.0...pi-permission-system-v13.0.0) (2026-06-12)
9
25
 
10
26
 
@@ -25,7 +25,8 @@
25
25
  "*": "ask",
26
26
  "git *": "ask",
27
27
  "git status": "allow",
28
- "git diff": "allow"
28
+ "git diff": "allow",
29
+ "npm *": { "action": "deny", "reason": "Use pnpm instead" }
29
30
  },
30
31
  "mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
31
32
  "skill": { "*": "ask" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "13.0.0",
3
+ "version": "13.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -125,8 +125,34 @@
125
125
  "minLength": 1
126
126
  },
127
127
  "additionalProperties": {
128
- "$ref": "#/$defs/permissionState"
128
+ "oneOf": [
129
+ {
130
+ "$ref": "#/$defs/permissionState",
131
+ "description": "A permission decision for this pattern."
132
+ },
133
+ {
134
+ "$ref": "#/$defs/denyWithReason",
135
+ "description": "Deny this pattern with an optional custom reason."
136
+ }
137
+ ]
129
138
  }
139
+ },
140
+ "denyWithReason": {
141
+ "type": "object",
142
+ "description": "Deny with an optional custom reason shown to the agent when the action is blocked.",
143
+ "properties": {
144
+ "action": {
145
+ "const": "deny",
146
+ "description": "The permission decision \u2014 must be \"deny\"."
147
+ },
148
+ "reason": {
149
+ "type": "string",
150
+ "maxLength": 500,
151
+ "description": "Optional reason shown to the agent when this action is denied."
152
+ }
153
+ },
154
+ "required": ["action"],
155
+ "additionalProperties": false
130
156
  }
131
157
  }
132
158
  }
package/src/common.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PermissionState } from "./types";
1
+ import type { DenyWithReason, PermissionState } from "./types";
2
2
 
3
3
  export function toRecord(value: unknown): Record<string, unknown> {
4
4
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -38,6 +38,22 @@ export function isPermissionState(value: unknown): value is PermissionState {
38
38
  return value === "allow" || value === "deny" || value === "ask";
39
39
  }
40
40
 
41
+ /**
42
+ * Narrow type guard: a raw value representing a DenyWithReason object.
43
+ * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
44
+ * Rejects a non-string `reason` to keep malformed config out of the rule set.
45
+ */
46
+ export function isDenyWithReason(value: unknown): value is DenyWithReason {
47
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
48
+ return false;
49
+ }
50
+ const record = value as Record<string, unknown>;
51
+ return (
52
+ record.action === "deny" &&
53
+ (record.reason === undefined || typeof record.reason === "string")
54
+ );
55
+ }
56
+
41
57
  type StackNode = { indent: number; target: Record<string, unknown> };
42
58
 
43
59
  export function parseSimpleYamlMap(input: string): Record<string, unknown> {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { normalize } from "node:path";
3
-
4
3
  import {
4
+ isDenyWithReason,
5
5
  isPermissionState,
6
6
  normalizeOptionalPositiveInt,
7
7
  normalizeOptionalStringArray,
@@ -15,7 +15,7 @@ import {
15
15
  getProjectConfigPath,
16
16
  } from "./config-paths";
17
17
  import { mergeFlatPermissions } from "./permission-merge";
18
- import type { FlatPermissionConfig } from "./types";
18
+ import type { FlatPermissionConfig, PatternValue } from "./types";
19
19
 
20
20
  /**
21
21
  * Unified config shape combining runtime knobs and flat permission policy.
@@ -127,7 +127,8 @@ function normalizeOptionalBoolean(value: unknown): boolean | undefined {
127
127
 
128
128
  /**
129
129
  * Normalize a raw `permission` value from parsed JSON into a FlatPermissionConfig.
130
- * Drops non-object top-level values, invalid PermissionState strings, and
130
+ * Accepts PermissionState strings and DenyWithReason objects inside pattern
131
+ * maps. Drops non-object top-level values, invalid PermissionState strings, and
131
132
  * invalid action values inside object maps.
132
133
  */
133
134
  function normalizeFlatPermissionValue(
@@ -147,12 +148,15 @@ function normalizeFlatPermissionValue(
147
148
  hasAny = true;
148
149
  }
149
150
  } else if (typeof val === "object" && val !== null && !Array.isArray(val)) {
150
- const map: Record<string, import("./types").PermissionState> = {};
151
+ const map: Record<string, PatternValue> = {};
151
152
  let mapHasAny = false;
152
153
  for (const [pattern, action] of Object.entries(
153
154
  val as Record<string, unknown>,
154
155
  )) {
155
- if (isPermissionState(action)) {
156
+ if (isDenyWithReason(action)) {
157
+ map[pattern] = action;
158
+ mapHasAny = true;
159
+ } else if (isPermissionState(action)) {
156
160
  map[pattern] = action;
157
161
  mapHasAny = true;
158
162
  }
@@ -126,7 +126,8 @@ function buildToolDenyBody(
126
126
  parts.push(qualifier);
127
127
  }
128
128
 
129
- return `${parts.join(" ")}.`;
129
+ // reasonSuffix appends ` Reason: <reason>.` after the sentence-ending period.
130
+ return `${parts.join(" ")}.${reasonSuffix(check.reason)}`;
130
131
  }
131
132
 
132
133
  /**
package/src/normalize.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isPermissionState } from "./common";
1
+ import { isDenyWithReason, isPermissionState } from "./common";
2
2
  import type { Rule, Ruleset } from "./rule";
3
3
  import type { FlatPermissionConfig } from "./types";
4
4
 
@@ -7,6 +7,8 @@ import type { FlatPermissionConfig } from "./types";
7
7
  *
8
8
  * Each key is a surface name. A string value is shorthand for
9
9
  * `{ "*": action }`. An object value maps patterns to actions.
10
+ * A pattern value may be a PermissionState string or a `DenyWithReason`
11
+ * object (`{ action: "deny", reason?: string }`).
10
12
  * Invalid action values are silently skipped.
11
13
  *
12
14
  * The universal fallback key `"*"` is included if present — callers
@@ -23,7 +25,15 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
23
25
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
24
26
  } else if (typeof value === "object" && value !== null) {
25
27
  for (const [pattern, action] of Object.entries(value)) {
26
- if (isPermissionState(action)) {
28
+ if (isDenyWithReason(action)) {
29
+ rules.push({
30
+ surface,
31
+ pattern,
32
+ action: "deny",
33
+ reason: action.reason,
34
+ origin: "builtin",
35
+ });
36
+ } else if (isPermissionState(action)) {
27
37
  rules.push({ surface, pattern, action, origin: "builtin" });
28
38
  }
29
39
  }
@@ -322,6 +322,7 @@ function buildCheckResult(
322
322
  return {
323
323
  toolName,
324
324
  state: rule.action,
325
+ reason: rule.reason,
325
326
  matchedPattern:
326
327
  rule.layer === "config" || rule.layer === "session"
327
328
  ? rule.pattern
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.
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;
@@ -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 }));
@@ -114,6 +114,67 @@ describe("formatDenyReason", () => {
114
114
  );
115
115
  });
116
116
 
117
+ test("bash with a custom reason appended after the period", () => {
118
+ expect(
119
+ formatDenyReason(
120
+ toolCtx(
121
+ toolCheck("bash", {
122
+ command: "npm install",
123
+ matchedPattern: "npm *",
124
+ reason: "Use pnpm instead",
125
+ }),
126
+ ),
127
+ ),
128
+ ).toBe(
129
+ "[pi-permission-system] is not permitted to run 'bash' command 'npm install' (matched 'npm *'). Reason: Use pnpm instead.",
130
+ );
131
+ });
132
+
133
+ test("custom reason with no matched pattern", () => {
134
+ expect(
135
+ formatDenyReason(
136
+ toolCtx(
137
+ toolCheck("write", {
138
+ reason: "Write access is disabled for security",
139
+ }),
140
+ ),
141
+ ),
142
+ ).toBe(
143
+ "[pi-permission-system] is not permitted to run 'write'. Reason: Write access is disabled for security.",
144
+ );
145
+ });
146
+
147
+ test("custom reason is included alongside the agent name", () => {
148
+ expect(
149
+ formatDenyReason(
150
+ toolCtx(
151
+ toolCheck("bash", {
152
+ command: "yarn build",
153
+ matchedPattern: "yarn *",
154
+ reason: "Use pnpm instead",
155
+ }),
156
+ "dev-agent",
157
+ ),
158
+ ),
159
+ ).toBe(
160
+ "[pi-permission-system] Agent 'dev-agent' is not permitted to run 'bash' command 'yarn build' (matched 'yarn *'). Reason: Use pnpm instead.",
161
+ );
162
+ });
163
+
164
+ test("custom reason on an MCP target", () => {
165
+ expect(
166
+ formatDenyReason(
167
+ toolCtx(
168
+ mcpCheck("server:deploy", {
169
+ reason: "Deploy requires approval from a senior engineer",
170
+ }),
171
+ ),
172
+ ),
173
+ ).toBe(
174
+ "[pi-permission-system] is not permitted to run MCP target 'server:deploy'. Reason: Deploy requires approval from a senior engineer.",
175
+ );
176
+ });
177
+
117
178
  test("MCP source with target on non-mcp toolName", () => {
118
179
  expect(
119
180
  formatDenyReason(
@@ -163,4 +163,85 @@ describe("normalizeFlatConfig", () => {
163
163
  ]);
164
164
  });
165
165
  });
166
+
167
+ describe("deny with reason", () => {
168
+ test("{ action: 'deny', reason } produces a deny rule carrying the reason", () => {
169
+ const result = normalizeFlatConfig({
170
+ bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
171
+ });
172
+ expect(result).toEqual([
173
+ {
174
+ surface: "bash",
175
+ pattern: "npm *",
176
+ action: "deny",
177
+ reason: "Use pnpm instead",
178
+ origin: "builtin",
179
+ },
180
+ ]);
181
+ });
182
+
183
+ test("{ action: 'deny' } without a reason produces a deny rule without reason", () => {
184
+ const result = normalizeFlatConfig({
185
+ bash: { "rm -rf *": { action: "deny" } },
186
+ });
187
+ expect(result).toEqual([
188
+ {
189
+ surface: "bash",
190
+ pattern: "rm -rf *",
191
+ action: "deny",
192
+ origin: "builtin",
193
+ },
194
+ ]);
195
+ });
196
+
197
+ test("deny-with-reason and plain strings coexist in the same surface", () => {
198
+ const result = normalizeFlatConfig({
199
+ bash: {
200
+ "git *": "allow",
201
+ "npm *": { action: "deny", reason: "Use pnpm" },
202
+ "*": "ask",
203
+ },
204
+ });
205
+ expect(result).toEqual([
206
+ {
207
+ surface: "bash",
208
+ pattern: "git *",
209
+ action: "allow",
210
+ origin: "builtin",
211
+ },
212
+ {
213
+ surface: "bash",
214
+ pattern: "npm *",
215
+ action: "deny",
216
+ reason: "Use pnpm",
217
+ origin: "builtin",
218
+ },
219
+ { surface: "bash", pattern: "*", action: "ask", origin: "builtin" },
220
+ ]);
221
+ });
222
+
223
+ test("top-level deny-with-reason object is treated as a pattern map", () => {
224
+ // At the surface level, { action: "deny", reason: "..." } is parsed as a
225
+ // pattern→action map: "action" is a pattern key with action "deny", and
226
+ // "reason" maps to a non-PermissionState string that is dropped.
227
+ const result = normalizeFlatConfig({
228
+ bash: { action: "deny", reason: "Not allowed" } as never,
229
+ });
230
+ expect(result).toEqual([
231
+ {
232
+ surface: "bash",
233
+ pattern: "action",
234
+ action: "deny",
235
+ origin: "builtin",
236
+ },
237
+ ]);
238
+ });
239
+
240
+ test("non-string reason is rejected (malformed config)", () => {
241
+ const result = normalizeFlatConfig({
242
+ bash: { "npm *": { action: "deny", reason: 42 } as never },
243
+ });
244
+ expect(result).toEqual([]);
245
+ });
246
+ });
166
247
  });
@@ -1245,6 +1245,71 @@ describe("cross-cutting path surface", () => {
1245
1245
  }
1246
1246
  });
1247
1247
 
1248
+ // ── Deny-with-reason ────────────────────────────────────────────────────
1249
+
1250
+ it("deny-with-reason: reason threads through to PermissionCheckResult", () => {
1251
+ const { manager, cleanup } = makeManagerWithConfig({
1252
+ bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
1253
+ });
1254
+ try {
1255
+ const result = manager.checkPermission("bash", {
1256
+ command: "npm install",
1257
+ });
1258
+ expect(result.state).toBe("deny");
1259
+ expect(result.reason).toBe("Use pnpm instead");
1260
+ expect(result.matchedPattern).toBe("npm *");
1261
+ } finally {
1262
+ cleanup();
1263
+ }
1264
+ });
1265
+
1266
+ it("deny-without-reason: reason is undefined in PermissionCheckResult", () => {
1267
+ const { manager, cleanup } = makeManagerWithConfig({
1268
+ bash: { "rm -rf *": "deny" },
1269
+ });
1270
+ try {
1271
+ const result = manager.checkPermission("bash", { command: "rm -rf /" });
1272
+ expect(result.state).toBe("deny");
1273
+ expect(result.reason).toBeUndefined();
1274
+ } finally {
1275
+ cleanup();
1276
+ }
1277
+ });
1278
+
1279
+ it("deny-with-reason on a non-bash surface", () => {
1280
+ const { manager, cleanup } = makeManagerWithConfig({
1281
+ read: {
1282
+ "*.env": {
1283
+ action: "deny",
1284
+ reason: "Environment files contain secrets",
1285
+ },
1286
+ },
1287
+ });
1288
+ try {
1289
+ const result = manager.checkPermission("read", { path: ".env" });
1290
+ expect(result.state).toBe("deny");
1291
+ expect(result.reason).toBe("Environment files contain secrets");
1292
+ expect(result.matchedPattern).toBe("*.env");
1293
+ } finally {
1294
+ cleanup();
1295
+ }
1296
+ });
1297
+
1298
+ it("non-string reason falls through to the default (malformed config)", () => {
1299
+ const { manager, cleanup } = makeManagerWithConfig({
1300
+ bash: { "npm *": { action: "deny", reason: 42 } },
1301
+ });
1302
+ try {
1303
+ const result = manager.checkPermission("bash", {
1304
+ command: "npm install",
1305
+ });
1306
+ expect(result.state).toBe("ask");
1307
+ expect(result.reason).toBeUndefined();
1308
+ } finally {
1309
+ cleanup();
1310
+ }
1311
+ });
1312
+
1248
1313
  // ── Last-match-wins ordering ────────────────────────────────────────────
1249
1314
 
1250
1315
  it("last-match-wins: catch-all after deny overrides the deny", () => {
package/test/rule.test.ts CHANGED
@@ -216,6 +216,67 @@ describe("evaluate", () => {
216
216
  expect(result.origin).toBe("builtin");
217
217
  });
218
218
 
219
+ test("evaluate() propagates reason from the matched deny rule", () => {
220
+ const rule: Rule = {
221
+ surface: "bash",
222
+ pattern: "npm *",
223
+ action: "deny",
224
+ reason: "Use pnpm instead",
225
+ layer: "config",
226
+ origin: "global",
227
+ };
228
+ const result = evaluate("bash", "npm install", [rule]);
229
+ expect(result.action).toBe("deny");
230
+ expect(result.reason).toBe("Use pnpm instead");
231
+ });
232
+
233
+ test("evaluate() carries reason through last-match-wins when deny wins", () => {
234
+ const allowAll: Rule = {
235
+ surface: "bash",
236
+ pattern: "*",
237
+ action: "allow",
238
+ layer: "config",
239
+ origin: "global",
240
+ };
241
+ const denyNpm: Rule = {
242
+ surface: "bash",
243
+ pattern: "npm *",
244
+ action: "deny",
245
+ reason: "Use pnpm",
246
+ layer: "config",
247
+ origin: "global",
248
+ };
249
+ const result = evaluate("bash", "npm install", [allowAll, denyNpm]);
250
+ expect(result.action).toBe("deny");
251
+ expect(result.reason).toBe("Use pnpm");
252
+ });
253
+
254
+ test("evaluate() drops reason when a later allow overrides the deny", () => {
255
+ const denyNpm: Rule = {
256
+ surface: "bash",
257
+ pattern: "npm *",
258
+ action: "deny",
259
+ reason: "Use pnpm",
260
+ layer: "config",
261
+ origin: "global",
262
+ };
263
+ const allowInstall: Rule = {
264
+ surface: "bash",
265
+ pattern: "npm install",
266
+ action: "allow",
267
+ layer: "config",
268
+ origin: "global",
269
+ };
270
+ const result = evaluate("bash", "npm install", [denyNpm, allowInstall]);
271
+ expect(result.action).toBe("allow");
272
+ expect(result.reason).toBeUndefined();
273
+ });
274
+
275
+ test("evaluate() synthetic fallback rule has no reason", () => {
276
+ const result = evaluate("bash", "npm install", []);
277
+ expect(result.reason).toBeUndefined();
278
+ });
279
+
219
280
  test("RuleOrigin covers all seven provenance values", () => {
220
281
  const origins: RuleOrigin[] = [
221
282
  "global",