@gotgenes/pi-permission-system 6.0.2 → 7.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,20 +2,13 @@ import { describe, expect, test } from "vitest";
2
2
 
3
3
  import {
4
4
  formatBashExternalDirectoryAskPrompt,
5
- formatBashExternalDirectoryDenyReason,
6
5
  formatExternalDirectoryAskPrompt,
7
- formatExternalDirectoryDenyReason,
8
- formatExternalDirectoryHardStopHint,
9
- formatExternalDirectoryUserDeniedReason,
10
6
  } from "../../../src/handlers/gates/external-directory-messages";
11
7
 
12
- describe("formatExternalDirectoryHardStopHint", () => {
13
- test("returns the hard stop instruction string", () => {
14
- const hint = formatExternalDirectoryHardStopHint();
15
- expect(hint).toContain("Hard stop");
16
- expect(hint).toContain("external directory");
17
- });
18
- });
8
+ // Denial message functions (formatExternalDirectoryDenyReason,
9
+ // formatExternalDirectoryUserDeniedReason, formatExternalDirectoryHardStopHint,
10
+ // formatBashExternalDirectoryDenyReason) have moved to denial-messages.ts.
11
+ // Their behavior is tested in denial-messages.test.ts.
19
12
 
20
13
  describe("formatExternalDirectoryAskPrompt", () => {
21
14
  test("uses 'Current agent' when no agent name provided", () => {
@@ -43,60 +36,6 @@ describe("formatExternalDirectoryAskPrompt", () => {
43
36
  });
44
37
  });
45
38
 
46
- describe("formatExternalDirectoryDenyReason", () => {
47
- test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
48
- const result = formatExternalDirectoryDenyReason(
49
- "read",
50
- "/etc/passwd",
51
- "/projects/my-app",
52
- "sec-agent",
53
- );
54
- expect(result).toContain("Agent 'sec-agent'");
55
- expect(result).toContain("read");
56
- expect(result).toContain("/etc/passwd");
57
- expect(result).toContain("/projects/my-app");
58
- expect(result).toContain("Hard stop");
59
- });
60
-
61
- test("uses 'Current agent' without agent name", () => {
62
- const result = formatExternalDirectoryDenyReason(
63
- "read",
64
- "/etc",
65
- "/projects",
66
- );
67
- expect(result).toContain("Current agent");
68
- });
69
- });
70
-
71
- describe("formatExternalDirectoryUserDeniedReason", () => {
72
- test("includes tool name and path", () => {
73
- const result = formatExternalDirectoryUserDeniedReason(
74
- "edit",
75
- "/etc/hosts",
76
- );
77
- expect(result).toContain("edit");
78
- expect(result).toContain("/etc/hosts");
79
- expect(result).toContain("Hard stop");
80
- });
81
-
82
- test("appends denial reason when provided", () => {
83
- const result = formatExternalDirectoryUserDeniedReason(
84
- "edit",
85
- "/etc/hosts",
86
- "too risky",
87
- );
88
- expect(result).toContain("Reason: too risky");
89
- });
90
-
91
- test("omits reason suffix when not provided", () => {
92
- const result = formatExternalDirectoryUserDeniedReason(
93
- "edit",
94
- "/etc/hosts",
95
- );
96
- expect(result).not.toContain("Reason:");
97
- });
98
- });
99
-
100
39
  describe("formatBashExternalDirectoryAskPrompt", () => {
101
40
  test("includes command, paths, cwd, and agent name", () => {
102
41
  const result = formatBashExternalDirectoryAskPrompt(
@@ -120,18 +59,3 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
120
59
  expect(result).toContain("Current agent");
121
60
  });
122
61
  });
123
-
124
- describe("formatBashExternalDirectoryDenyReason", () => {
125
- test("includes command, paths, cwd, agent name, and hard stop hint", () => {
126
- const result = formatBashExternalDirectoryDenyReason(
127
- "rm /etc/hosts",
128
- ["/etc/hosts"],
129
- "/projects/my-app",
130
- "sec-agent",
131
- );
132
- expect(result).toContain("Agent 'sec-agent'");
133
- expect(result).toContain("rm /etc/hosts");
134
- expect(result).toContain("/etc/hosts");
135
- expect(result).toContain("Hard stop");
136
- });
137
- });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import type {
4
4
  GateBypass,
@@ -136,15 +136,17 @@ describe("describeExternalDirectoryGate", () => {
136
136
  expect(result.sessionApproval).toHaveProperty("pattern");
137
137
  });
138
138
 
139
- it("messages contain the external path", () => {
139
+ it("denialContext contains the external path and cwd", () => {
140
140
  const result = describeExternalDirectoryGate(
141
141
  makeTcc({ input: { path: "/outside/project/file.ts" } }),
142
142
  ["/test/agent"],
143
143
  ) as GateDescriptor;
144
- expect(result.messages.denyReason).toContain("/outside/project/file.ts");
145
- expect(result.messages.unavailableReason).toContain(
146
- "/outside/project/file.ts",
147
- );
144
+ expect(result.denialContext).toMatchObject({
145
+ kind: "external_directory",
146
+ toolName: "read",
147
+ pathValue: "/outside/project/file.ts",
148
+ cwd: "/test/project",
149
+ });
148
150
  });
149
151
 
150
152
  it("promptDetails includes path and tool_call source", () => {
@@ -164,7 +164,7 @@ describe("describePathGate", () => {
164
164
  expect(result.sessionApproval).toHaveProperty("pattern");
165
165
  });
166
166
 
167
- it("descriptor messages reference the file path", () => {
167
+ it("descriptor denialContext references the file path and tool name", () => {
168
168
  const checkPermission = vi
169
169
  .fn<CheckPermissionFn>()
170
170
  .mockReturnValue(
@@ -175,8 +175,12 @@ describe("describePathGate", () => {
175
175
  checkPermission,
176
176
  getSessionRuleset,
177
177
  ) as GateDescriptor;
178
- expect(result.messages.denyReason).toContain(".env");
179
- expect(result.messages.unavailableReason).toContain(".env");
178
+ expect(result.denialContext).toEqual({
179
+ kind: "path",
180
+ toolName: "read",
181
+ pathValue: ".env",
182
+ agentName: undefined,
183
+ });
180
184
  });
181
185
 
182
186
  it("descriptor decision uses surface 'path' and the file path as value", () => {
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
+ import type { DenialContext } from "../../../src/denial-messages";
4
+ import { EXTENSION_TAG } from "../../../src/denial-messages";
3
5
  import type {
4
6
  GateDescriptor,
5
7
  GateRunnerDeps,
@@ -15,10 +17,9 @@ function makeDescriptor(
15
17
  return {
16
18
  surface: "read",
17
19
  input: {},
18
- messages: {
19
- denyReason: "Tool 'read' is denied.",
20
- unavailableReason: "No UI available.",
21
- userDeniedReason: (d) => `User denied. ${d.denialReason ?? ""}`,
20
+ denialContext: {
21
+ kind: "tool",
22
+ check: makeCheckResult("deny"),
22
23
  },
23
24
  promptDetails: {
24
25
  source: "tool_call",
@@ -358,4 +359,104 @@ describe("runGateCheck", () => {
358
359
  await runGateCheck(makeDescriptor(), null, "tc-1", deps);
359
360
  expect(deps.approveSessionRule).not.toHaveBeenCalled();
360
361
  });
362
+
363
+ describe("denialContext formatting", () => {
364
+ function makeDenialContextDescriptor(
365
+ denialContext: DenialContext,
366
+ overrides: Partial<GateDescriptor> = {},
367
+ ): GateDescriptor {
368
+ return {
369
+ surface: "write",
370
+ input: {},
371
+ denialContext,
372
+ promptDetails: {
373
+ source: "tool_call",
374
+ agentName: null,
375
+ message: "Allow tool 'write'?",
376
+ toolCallId: "tc-1",
377
+ toolName: "write",
378
+ },
379
+ logContext: {
380
+ source: "tool_call",
381
+ toolCallId: "tc-1",
382
+ toolName: "write",
383
+ },
384
+ decision: {
385
+ surface: "write",
386
+ value: "write",
387
+ },
388
+ ...overrides,
389
+ };
390
+ }
391
+
392
+ it("uses denialContext to format denyReason with extension tag", async () => {
393
+ const deps = makeRunnerDeps({
394
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
395
+ });
396
+ const ctx: DenialContext = {
397
+ kind: "tool",
398
+ check: makeCheckResult("deny"),
399
+ agentName: "test-agent",
400
+ };
401
+ const result = await runGateCheck(
402
+ makeDenialContextDescriptor(ctx),
403
+ "test-agent",
404
+ "tc-1",
405
+ deps,
406
+ );
407
+ expect(result.action).toBe("block");
408
+ if (result.action === "block") {
409
+ expect(result.reason).toContain(EXTENSION_TAG);
410
+ expect(result.reason).not.toContain("Hard stop");
411
+ }
412
+ });
413
+
414
+ it("uses denialContext to format unavailableReason with extension tag", async () => {
415
+ const deps = makeRunnerDeps({
416
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
417
+ canConfirm: vi.fn().mockReturnValue(false),
418
+ });
419
+ const ctx: DenialContext = {
420
+ kind: "tool",
421
+ check: makeCheckResult("ask"),
422
+ };
423
+ const result = await runGateCheck(
424
+ makeDenialContextDescriptor(ctx),
425
+ null,
426
+ "tc-1",
427
+ deps,
428
+ );
429
+ expect(result.action).toBe("block");
430
+ if (result.action === "block") {
431
+ expect(result.reason).toContain(EXTENSION_TAG);
432
+ expect(result.reason).toContain("no interactive UI");
433
+ }
434
+ });
435
+
436
+ it("uses denialContext to format userDeniedReason with extension tag", async () => {
437
+ const deps = makeRunnerDeps({
438
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
439
+ promptPermission: vi.fn().mockResolvedValue({
440
+ approved: false,
441
+ state: "denied",
442
+ denialReason: "too risky",
443
+ }),
444
+ });
445
+ const ctx: DenialContext = {
446
+ kind: "tool",
447
+ check: makeCheckResult("ask"),
448
+ };
449
+ const result = await runGateCheck(
450
+ makeDenialContextDescriptor(ctx),
451
+ null,
452
+ "tc-1",
453
+ deps,
454
+ );
455
+ expect(result.action).toBe("block");
456
+ if (result.action === "block") {
457
+ expect(result.reason).toContain(EXTENSION_TAG);
458
+ expect(result.reason).toContain("too risky");
459
+ }
460
+ });
461
+ });
361
462
  });
@@ -104,17 +104,16 @@ describe("describeSkillReadGate", () => {
104
104
  expect(result.decision.value).toBe("my-skill");
105
105
  });
106
106
 
107
- it("messages contain the skill name", () => {
107
+ it("denialContext contains the skill name and read path", () => {
108
108
  const result = describeSkillReadGate(makeTcc(), () => [
109
109
  makeSkillEntry({ name: "librarian" }),
110
110
  ]) as GateDescriptor;
111
- expect(result.messages.denyReason).toContain("librarian");
112
- expect(result.messages.unavailableReason).toContain("librarian");
113
- const deniedMsg = result.messages.userDeniedReason({
114
- approved: false,
115
- state: "denied",
111
+ expect(result.denialContext).toEqual({
112
+ kind: "skill_read",
113
+ skillName: "librarian",
114
+ readPath: "/skills/librarian/SKILL.md",
115
+ agentName: undefined,
116
116
  });
117
- expect(deniedMsg).toContain("librarian");
118
117
  });
119
118
 
120
119
  it("promptDetails includes skill_read source and skillName", () => {
@@ -1,6 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
 
3
- import type { GateDescriptor } from "../../../src/handlers/gates/descriptor";
4
3
  import { describeToolGate } from "../../../src/handlers/gates/tool";
5
4
  import type { ToolCallContext } from "../../../src/handlers/gates/types";
6
5
  import type { PermissionCheckResult } from "../../../src/types";
@@ -80,50 +79,33 @@ describe("describeToolGate", () => {
80
79
  expect(desc.decision.value).toBe("server:tool");
81
80
  });
82
81
 
83
- it("populates messages.denyReason via formatDenyReason", () => {
82
+ it("populates denialContext with kind 'tool' and check result", () => {
84
83
  const check = makeCheckResult("deny", { toolName: "read" });
85
84
  const desc = describeToolGate(makeTcc(), check);
86
- expect(desc.messages.denyReason).toContain("read");
87
- expect(desc.messages.denyReason).toContain("not permitted");
85
+ expect(desc.denialContext).toEqual({
86
+ kind: "tool",
87
+ check,
88
+ agentName: undefined,
89
+ input: {},
90
+ });
88
91
  });
89
92
 
90
- it("populates messages.unavailableReason with bash command when tool is bash", () => {
91
- const check = makeCheckResult("ask", {
92
- toolName: "bash",
93
- command: "rm -rf /",
94
- });
95
- const desc = describeToolGate(
96
- makeTcc({ toolName: "bash", input: { command: "rm -rf /" } }),
97
- check,
98
- );
99
- expect(desc.messages.unavailableReason).toContain("rm -rf /");
100
- expect(desc.messages.unavailableReason).toContain("no interactive UI");
93
+ it("populates denialContext with agent name when provided", () => {
94
+ const check = makeCheckResult("ask", { toolName: "read" });
95
+ const desc = describeToolGate(makeTcc({ agentName: "my-agent" }), check);
96
+ expect(desc.denialContext!.agentName).toBe("my-agent");
101
97
  });
102
98
 
103
- it("populates messages.unavailableReason with tool name for non-bash tools", () => {
99
+ it("populates denialContext with input for tool context", () => {
100
+ const check = makeCheckResult("ask", { toolName: "bash", command: "ls" });
104
101
  const desc = describeToolGate(
105
- makeTcc({ toolName: "write" }),
106
- makeCheckResult("ask"),
102
+ makeTcc({ toolName: "bash", input: { command: "ls" } }),
103
+ check,
107
104
  );
108
- expect(desc.messages.unavailableReason).toContain("write");
109
- expect(desc.messages.unavailableReason).toContain("no interactive UI");
110
- });
111
-
112
- it("populates messages.unavailableReason with mcp for mcp tool", () => {
113
- const check = makeCheckResult("ask", { toolName: "mcp", target: "s:t" });
114
- const desc = describeToolGate(makeTcc({ toolName: "mcp" }), check);
115
- expect(desc.messages.unavailableReason).toContain("mcp");
116
- });
117
-
118
- it("populates messages.userDeniedReason as a function", () => {
119
- const check = makeCheckResult("ask", { toolName: "read" });
120
- const desc = describeToolGate(makeTcc(), check);
121
- const reason = desc.messages.userDeniedReason({
122
- approved: false,
123
- state: "denied",
124
- denialReason: "too risky",
105
+ expect(desc.denialContext).toMatchObject({
106
+ kind: "tool",
107
+ input: { command: "ls" },
125
108
  });
126
- expect(reason).toContain("too risky");
127
109
  });
128
110
 
129
111
  it("populates sessionApproval via suggestSessionPattern", () => {
@@ -7,14 +7,10 @@ vi.mock("../src/tool-input-preview.js", () => ({
7
7
 
8
8
  import {
9
9
  formatAskPrompt,
10
- formatDenyReason,
11
10
  formatMissingToolNameReason,
12
- formatPermissionHardStopHint,
13
11
  formatSkillAskPrompt,
14
12
  formatSkillPathAskPrompt,
15
- formatSkillPathDenyReason,
16
13
  formatUnknownToolReason,
17
- formatUserDeniedReason,
18
14
  } from "../src/permission-prompts";
19
15
  import type { SkillPromptEntry } from "../src/skill-prompt-sanitizer";
20
16
  import { formatToolInputForPrompt } from "../src/tool-input-preview";
@@ -106,89 +102,6 @@ describe("formatUnknownToolReason", () => {
106
102
  });
107
103
  });
108
104
 
109
- describe("formatPermissionHardStopHint", () => {
110
- test("returns MCP-specific message for mcp tool with target", () => {
111
- const result = formatPermissionHardStopHint(mcpResult("server:tool"));
112
- expect(result).toContain("MCP permission denial");
113
- });
114
-
115
- test("returns MCP-specific message for mcp source with target", () => {
116
- const result = formatPermissionHardStopHint(
117
- toolResult("anything", { source: "mcp", target: "server:tool" }),
118
- );
119
- expect(result).toContain("MCP permission denial");
120
- });
121
-
122
- test("returns generic message for non-MCP tools", () => {
123
- const result = formatPermissionHardStopHint(toolResult("read"));
124
- expect(result).toContain("Hard stop");
125
- expect(result).not.toContain("MCP");
126
- });
127
- });
128
-
129
- describe("formatDenyReason", () => {
130
- test("includes tool name and hard stop hint", () => {
131
- const result = formatDenyReason(toolResult("read"));
132
- expect(result).toContain("read");
133
- expect(result).toContain("Hard stop");
134
- });
135
-
136
- test("includes agent name when provided", () => {
137
- const result = formatDenyReason(toolResult("write"), "my-agent");
138
- expect(result).toContain("Agent 'my-agent'");
139
- });
140
-
141
- test("includes MCP target for mcp results", () => {
142
- const result = formatDenyReason(mcpResult("server:do-thing"));
143
- expect(result).toContain("server:do-thing");
144
- expect(result).toContain("MCP");
145
- });
146
-
147
- test("includes bash command when present", () => {
148
- const result = formatDenyReason(
149
- toolResult("bash", { command: "rm -rf /" }),
150
- );
151
- expect(result).toContain("rm -rf /");
152
- });
153
-
154
- test("includes matched pattern when present", () => {
155
- const result = formatDenyReason(
156
- toolResult("bash", { command: "rm -rf /", matchedPattern: "rm *" }),
157
- );
158
- expect(result).toContain("matched 'rm *'");
159
- });
160
- });
161
-
162
- describe("formatUserDeniedReason", () => {
163
- test("mentions tool name for generic tools", () => {
164
- const result = formatUserDeniedReason(toolResult("read"));
165
- expect(result).toContain("read");
166
- expect(result).toContain("Hard stop");
167
- });
168
-
169
- test("mentions bash command for bash results", () => {
170
- const result = formatUserDeniedReason(
171
- toolResult("bash", { command: "ls -la" }),
172
- );
173
- expect(result).toContain("ls -la");
174
- });
175
-
176
- test("mentions MCP target for mcp results", () => {
177
- const result = formatUserDeniedReason(mcpResult("server:query"));
178
- expect(result).toContain("server:query");
179
- });
180
-
181
- test("appends denial reason when provided", () => {
182
- const result = formatUserDeniedReason(toolResult("read"), "too sensitive");
183
- expect(result).toContain("Reason: too sensitive");
184
- });
185
-
186
- test("omits reason suffix when not provided", () => {
187
- const result = formatUserDeniedReason(toolResult("read"));
188
- expect(result).not.toContain("Reason:");
189
- });
190
- });
191
-
192
105
  describe("formatAskPrompt", () => {
193
106
  test("uses 'Current agent' when no agent name given", () => {
194
107
  const result = formatAskPrompt(toolResult("read"), undefined, {
@@ -289,23 +202,5 @@ describe("formatSkillPathAskPrompt", () => {
289
202
  });
290
203
  });
291
204
 
292
- describe("formatSkillPathDenyReason", () => {
293
- test("includes skill name, read path, and agent name", () => {
294
- const result = formatSkillPathDenyReason(
295
- skillEntry("librarian"),
296
- "/skills/librarian/SKILL.md",
297
- "my-agent",
298
- );
299
- expect(result).toContain("librarian");
300
- expect(result).toContain("/skills/librarian/SKILL.md");
301
- expect(result).toContain("Agent 'my-agent'");
302
- });
303
-
304
- test("uses 'Current agent' without agent name", () => {
305
- const result = formatSkillPathDenyReason(
306
- skillEntry("librarian"),
307
- "/skills/librarian/SKILL.md",
308
- );
309
- expect(result).toContain("Current agent");
310
- });
311
- });
205
+ // formatSkillPathDenyReason has moved to denial-messages.ts.
206
+ // Its behavior is tested in denial-messages.test.ts.
@@ -1874,10 +1874,11 @@ test("tool_call blocks path-bearing tools outside cwd when external_directory is
1874
1874
  });
1875
1875
 
1876
1876
  expect(result.block).toBe(true);
1877
- expect(String(result.reason)).toMatch(
1878
- /external directory permission denial/i,
1879
- );
1880
- expect(String(result.reason)).toMatch(/repo-sibling/);
1877
+ const reason = String(result.reason);
1878
+ expect(reason).toContain("is not permitted to run tool 'read'");
1879
+ expect(reason).toContain("repo-sibling");
1880
+ expect(reason).toContain("[pi-permission-system]");
1881
+ expect(reason).not.toContain("Hard stop");
1881
1882
  } finally {
1882
1883
  await harness.cleanup();
1883
1884
  rmSync(rootDir, { recursive: true, force: true });
@@ -2003,10 +2004,13 @@ test("tool_call blocks bash command with external path when external_directory i
2003
2004
  });
2004
2005
 
2005
2006
  expect(result.block).toBe(true);
2006
- expect(String(result.reason)).toMatch(
2007
- /external directory permission denial/i,
2007
+ const reason = String(result.reason);
2008
+ expect(reason).toContain(
2009
+ "is not permitted to run bash command 'cat /etc/hosts'",
2008
2010
  );
2009
- expect(String(result.reason)).toMatch(/\/etc\/hosts/);
2011
+ expect(reason).toContain("/etc/hosts");
2012
+ expect(reason).toContain("[pi-permission-system]");
2013
+ expect(reason).not.toContain("Hard stop");
2010
2014
  } finally {
2011
2015
  await harness.cleanup();
2012
2016
  }