@gotgenes/pi-permission-system 5.5.1 → 5.6.1

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.
@@ -10,17 +10,14 @@ import {
10
10
  checkRequestedToolRegistration,
11
11
  getToolNameFromValue,
12
12
  } from "../tool-registry";
13
- import { evaluateBashExternalDirectoryGate } from "./gates/bash-external-directory";
14
- import { evaluateExternalDirectoryGate } from "./gates/external-directory";
15
- import { evaluateSkillReadGate } from "./gates/skill-read";
16
- import { evaluateToolGate } from "./gates/tool";
17
- import type {
18
- BashExternalDirectoryGateDeps,
19
- ExternalDirectoryGateDeps,
20
- SkillReadGateDeps,
21
- ToolCallContext,
22
- ToolGateDeps,
23
- } from "./gates/types";
13
+ import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
14
+ import type { GateRunnerDeps } from "./gates/descriptor";
15
+ import { isGateBypass } from "./gates/descriptor";
16
+ import { describeExternalDirectoryGate } from "./gates/external-directory";
17
+ import { runGateCheck } from "./gates/runner";
18
+ import { describeSkillReadGate } from "./gates/skill-read";
19
+ import { describeToolGate } from "./gates/tool";
20
+ import type { ToolCallContext } from "./gates/types";
24
21
  import type { HandlerDeps, PromptPermissionDetails } from "./types";
25
22
 
26
23
  /**
@@ -92,10 +89,10 @@ export async function handleToolCall(
92
89
  const canConfirm = () => deps.canRequestPermissionConfirmation(ctx);
93
90
  const promptPermission = (details: PromptPermissionDetails) =>
94
91
  deps.promptPermission(ctx, details);
95
- const emitDecision = (e: Parameters<ToolGateDeps["emitDecision"]>[0]) =>
92
+ const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
96
93
  emitDecisionEvent(deps.events, e);
97
94
  const { writeReviewLog } = deps;
98
- const checkPermission: ToolGateDeps["checkPermission"] = (
95
+ const checkPermission: GateRunnerDeps["checkPermission"] = (
99
96
  surface,
100
97
  input,
101
98
  agent,
@@ -111,21 +108,8 @@ export async function handleToolCall(
111
108
  const approveSessionRule = (surface: string, pattern: string) =>
112
109
  deps.session.sessionRules.approve(surface, pattern);
113
110
 
114
- // ── Skill-read gate ──────────────────────────────────────────────────────
115
- const skillReadGateDeps: SkillReadGateDeps = {
116
- getActiveSkillEntries: () => deps.session.activeSkillEntries,
117
- writeReviewLog,
118
- emitDecision,
119
- canConfirm,
120
- promptPermission,
121
- };
122
- const skillResult = await evaluateSkillReadGate(tcc, skillReadGateDeps);
123
- if (skillResult?.action === "block") {
124
- return { block: true, reason: skillResult.reason };
125
- }
126
-
127
- // ── External-directory gate (file tools) ─────────────────────────────────
128
- const extDirGateDeps: ExternalDirectoryGateDeps = {
111
+ // ── Shared runner deps (built once, reused for all gates) ─────────────
112
+ const runnerDeps: GateRunnerDeps = {
129
113
  checkPermission,
130
114
  getSessionRuleset,
131
115
  approveSessionRule,
@@ -133,44 +117,91 @@ export async function handleToolCall(
133
117
  emitDecision,
134
118
  canConfirm,
135
119
  promptPermission,
136
- getInfrastructureDirs: () => [
137
- ...deps.piInfrastructureDirs,
138
- ...deps.getPiInfrastructureReadPaths(),
139
- ],
140
120
  };
141
- const extDirResult = await evaluateExternalDirectoryGate(tcc, extDirGateDeps);
142
- if (extDirResult?.action === "block") {
143
- return { block: true, reason: extDirResult.reason };
144
- }
145
121
 
146
- // ── Bash external-directory gate ─────────────────────────────────────────
147
- const bashExtGateDeps: BashExternalDirectoryGateDeps = {
148
- checkPermission,
149
- getSessionRuleset,
150
- approveSessionRule,
151
- writeReviewLog,
152
- canConfirm,
153
- promptPermission,
154
- };
155
- const bashExtResult = await evaluateBashExternalDirectoryGate(
122
+ // ── Skill-read gate (descriptor + runner) ────────────────────────────────
123
+ const skillDescriptor = describeSkillReadGate(
156
124
  tcc,
157
- bashExtGateDeps,
125
+ () => deps.session.activeSkillEntries,
158
126
  );
159
- if (bashExtResult?.action === "block") {
160
- return { block: true, reason: bashExtResult.reason };
127
+ if (skillDescriptor) {
128
+ const skillResult = await runGateCheck(
129
+ skillDescriptor,
130
+ tcc.agentName,
131
+ tcc.toolCallId,
132
+ runnerDeps,
133
+ );
134
+ if (skillResult.action === "block") {
135
+ return { block: true, reason: skillResult.reason };
136
+ }
137
+ }
138
+
139
+ // ── External-directory gate (descriptor + runner) ─────────────────────────
140
+ const infraDirs = [
141
+ ...deps.piInfrastructureDirs,
142
+ ...deps.getPiInfrastructureReadPaths(),
143
+ ];
144
+ const extDirDesc = describeExternalDirectoryGate(tcc, infraDirs);
145
+ if (extDirDesc) {
146
+ if (isGateBypass(extDirDesc)) {
147
+ if (extDirDesc.log) {
148
+ writeReviewLog(extDirDesc.log.event, extDirDesc.log.details);
149
+ }
150
+ if (extDirDesc.decision) {
151
+ emitDecision(extDirDesc.decision);
152
+ }
153
+ } else {
154
+ const extDirResult = await runGateCheck(
155
+ extDirDesc,
156
+ tcc.agentName,
157
+ tcc.toolCallId,
158
+ runnerDeps,
159
+ );
160
+ if (extDirResult.action === "block") {
161
+ return { block: true, reason: extDirResult.reason };
162
+ }
163
+ }
161
164
  }
162
165
 
163
- // ── Normal tool permission gate ──────────────────────────────────────────
164
- const toolGateDeps: ToolGateDeps = {
166
+ // ── Bash external-directory gate (descriptor + runner) ─────────────────────
167
+ const bashExtDesc = await describeBashExternalDirectoryGate(
168
+ tcc,
165
169
  checkPermission,
166
170
  getSessionRuleset,
167
- approveSessionRule,
168
- writeReviewLog,
169
- emitDecision,
170
- canConfirm,
171
- promptPermission,
172
- };
173
- const toolResult = await evaluateToolGate(tcc, toolGateDeps);
171
+ );
172
+ if (bashExtDesc) {
173
+ if (isGateBypass(bashExtDesc)) {
174
+ if (bashExtDesc.log) {
175
+ writeReviewLog(bashExtDesc.log.event, bashExtDesc.log.details);
176
+ }
177
+ } else {
178
+ const bashExtResult = await runGateCheck(
179
+ bashExtDesc,
180
+ tcc.agentName,
181
+ tcc.toolCallId,
182
+ runnerDeps,
183
+ );
184
+ if (bashExtResult.action === "block") {
185
+ return { block: true, reason: bashExtResult.reason };
186
+ }
187
+ }
188
+ }
189
+
190
+ // ── Normal tool permission gate (descriptor + runner) ───────────────────────────
191
+ const toolCheck = checkPermission(
192
+ tcc.toolName,
193
+ tcc.input,
194
+ tcc.agentName ?? undefined,
195
+ getSessionRuleset(),
196
+ );
197
+ const toolDescriptor = describeToolGate(tcc, toolCheck);
198
+ toolDescriptor.preCheck = toolCheck;
199
+ const toolResult = await runGateCheck(
200
+ toolDescriptor,
201
+ tcc.agentName,
202
+ tcc.toolCallId,
203
+ runnerDeps,
204
+ );
174
205
  if (toolResult.action === "block") {
175
206
  return { block: true, reason: toolResult.reason };
176
207
  }
@@ -1,13 +1,17 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
-
3
- import { evaluateBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
2
+ import { describeBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
4
3
  import type {
5
- BashExternalDirectoryGateDeps,
6
- ToolCallContext,
7
- } from "../../../src/handlers/gates/types";
4
+ GateBypass,
5
+ GateDescriptor,
6
+ } from "../../../src/handlers/gates/descriptor";
7
+ import {
8
+ isGateBypass,
9
+ isGateDescriptor,
10
+ } from "../../../src/handlers/gates/descriptor";
11
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
8
12
  import type { PermissionCheckResult } from "../../../src/types";
9
13
 
10
- // ── helpers ─────────────��───────────────────────────────────────────���──────
14
+ // ── helpers ────────────────────────────────────────────────────────────────
11
15
 
12
16
  function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
13
17
  return {
@@ -33,158 +37,156 @@ function makeCheckResult(
33
37
  };
34
38
  }
35
39
 
36
- function makeBashExtGateDeps(
37
- overrides: Partial<BashExternalDirectoryGateDeps> = {},
38
- ): BashExternalDirectoryGateDeps {
39
- return {
40
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
41
- getSessionRuleset: vi.fn().mockReturnValue([]),
42
- approveSessionRule: vi.fn(),
43
- writeReviewLog: vi.fn(),
44
- canConfirm: vi.fn().mockReturnValue(true),
45
- promptPermission: vi
46
- .fn()
47
- .mockResolvedValue({ approved: true, state: "approved" }),
48
- ...overrides,
49
- };
50
- }
51
-
52
- // ── tests ─────────────────────────────��───────────────────────────────���────
40
+ // ── tests ──────────────────────────────────────────────────────────────────
53
41
 
54
- describe("evaluateBashExternalDirectoryGate", () => {
42
+ describe("describeBashExternalDirectoryGate", () => {
55
43
  it("returns null when tool is not bash", async () => {
56
- const tcc = makeTcc({ toolName: "read" });
57
- const result = await evaluateBashExternalDirectoryGate(
58
- tcc,
59
- makeBashExtGateDeps(),
44
+ const result = await describeBashExternalDirectoryGate(
45
+ makeTcc({ toolName: "read" }),
46
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
47
+ vi.fn().mockReturnValue([]),
60
48
  );
61
49
  expect(result).toBeNull();
62
50
  });
63
51
 
64
52
  it("returns null when no CWD", async () => {
65
- const tcc = makeTcc({ cwd: undefined });
66
- const result = await evaluateBashExternalDirectoryGate(
67
- tcc,
68
- makeBashExtGateDeps(),
53
+ const result = await describeBashExternalDirectoryGate(
54
+ makeTcc({ cwd: undefined }),
55
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
56
+ vi.fn().mockReturnValue([]),
69
57
  );
70
58
  expect(result).toBeNull();
71
59
  });
72
60
 
73
61
  it("returns null when command has no external paths", async () => {
74
- const tcc = makeTcc({ input: { command: "ls -la" } });
75
- const result = await evaluateBashExternalDirectoryGate(
76
- tcc,
77
- makeBashExtGateDeps(),
62
+ const result = await describeBashExternalDirectoryGate(
63
+ makeTcc({ input: { command: "ls -la" } }),
64
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
65
+ vi.fn().mockReturnValue([]),
78
66
  );
79
67
  expect(result).toBeNull();
80
68
  });
81
69
 
82
- it("returns null and logs when all external paths are session-covered", async () => {
83
- const deps = makeBashExtGateDeps({
84
- checkPermission: vi
85
- .fn()
86
- .mockReturnValue(makeCheckResult("allow", { source: "session" })),
70
+ it("returns GateBypass when all external paths are session-covered", async () => {
71
+ const checkPermission = vi
72
+ .fn()
73
+ .mockReturnValue(makeCheckResult("allow", { source: "session" }));
74
+ const result = await describeBashExternalDirectoryGate(
75
+ makeTcc(),
76
+ checkPermission,
77
+ vi.fn().mockReturnValue([]),
78
+ );
79
+ expect(result).not.toBeNull();
80
+ expect(isGateBypass(result)).toBe(true);
81
+ const bypass = result as GateBypass;
82
+ expect(bypass.action).toBe("allow");
83
+ expect(bypass.log).toMatchObject({
84
+ event: "permission_request.session_approved",
85
+ details: expect.objectContaining({ resolution: "session_approved" }),
87
86
  });
88
- const tcc = makeTcc();
89
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
90
- expect(result).toBeNull();
91
- expect(deps.writeReviewLog).toHaveBeenCalledWith(
92
- "permission_request.session_approved",
93
- expect.objectContaining({ resolution: "session_approved" }),
87
+ });
88
+
89
+ it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
90
+ const checkPermission = vi.fn().mockReturnValue(makeCheckResult("ask"));
91
+ const result = await describeBashExternalDirectoryGate(
92
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
93
+ checkPermission,
94
+ vi.fn().mockReturnValue([]),
94
95
  );
96
+ expect(isGateDescriptor(result)).toBe(true);
97
+ const desc = result as GateDescriptor;
98
+ expect(desc.sessionApproval).toBeDefined();
99
+ expect(desc.sessionApproval).toHaveProperty("patterns");
100
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
101
+ expect(patterns.length).toBeGreaterThan(0);
95
102
  });
96
103
 
97
- it("blocks when policy is deny", async () => {
98
- const deps = makeBashExtGateDeps({
99
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
100
- });
101
- const tcc = makeTcc();
102
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
103
- expect(result).toMatchObject({ action: "block" });
104
+ it("uses config-level checkPermission for the policy state", async () => {
105
+ const checkPermission = vi
106
+ .fn()
107
+ .mockImplementation((surface: string, input: Record<string, unknown>) => {
108
+ // Path-specific check returns session for coverage filtering
109
+ if (input.path) return makeCheckResult("allow", { source: "special" });
110
+ // Config-level check (no path) returns deny
111
+ return makeCheckResult("deny");
112
+ });
113
+ const result = await describeBashExternalDirectoryGate(
114
+ makeTcc(),
115
+ checkPermission,
116
+ vi.fn().mockReturnValue([]),
117
+ );
118
+ expect(isGateDescriptor(result)).toBe(true);
119
+ // The descriptor should carry the deny state from the config-level check
120
+ // (it will be checked as preCheck by the runner)
121
+ const desc = result as GateDescriptor;
122
+ expect(desc.preCheck?.state).toBe("deny");
104
123
  });
105
124
 
106
- it("allows without recording session rules when user approves once", async () => {
107
- const deps = makeBashExtGateDeps({
108
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
109
- promptPermission: vi
110
- .fn()
111
- .mockResolvedValue({ approved: true, state: "approved" }),
112
- });
113
- const tcc = makeTcc();
114
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
115
- expect(result).toEqual({ action: "allow" });
116
- expect(deps.approveSessionRule).not.toHaveBeenCalled();
125
+ it("descriptor surface is 'external_directory'", async () => {
126
+ const result = await describeBashExternalDirectoryGate(
127
+ makeTcc(),
128
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
129
+ vi.fn().mockReturnValue([]),
130
+ );
131
+ const desc = result as GateDescriptor;
132
+ expect(desc.surface).toBe("external_directory");
117
133
  });
118
134
 
119
- it("records one session rule per uncovered path on approved_for_session", async () => {
120
- const deps = makeBashExtGateDeps({
121
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
122
- promptPermission: vi
123
- .fn()
124
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
125
- });
126
- // Command referencing two external paths
127
- const tcc = makeTcc({
128
- input: {
129
- command: "diff /outside/a.ts /outside/b.ts",
130
- },
131
- });
132
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
133
- expect(result).toEqual({ action: "allow" });
134
- // Each uncovered path gets its own session rule
135
- expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
136
- for (const call of (deps.approveSessionRule as ReturnType<typeof vi.fn>)
137
- .mock.calls) {
138
- expect(call[0]).toBe("external_directory");
139
- }
135
+ it("descriptor decision surface is 'external_directory'", async () => {
136
+ const result = await describeBashExternalDirectoryGate(
137
+ makeTcc(),
138
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
139
+ vi.fn().mockReturnValue([]),
140
+ );
141
+ const desc = result as GateDescriptor;
142
+ expect(desc.decision.surface).toBe("external_directory");
140
143
  });
141
144
 
142
- it("blocks when user denies", async () => {
143
- const deps = makeBashExtGateDeps({
144
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
145
- promptPermission: vi
146
- .fn()
147
- .mockResolvedValue({ approved: false, state: "denied" }),
148
- });
149
- const tcc = makeTcc();
150
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
151
- expect(result).toMatchObject({ action: "block" });
145
+ it("messages contain the command", async () => {
146
+ const result = await describeBashExternalDirectoryGate(
147
+ makeTcc({ input: { command: "cat /outside/file.ts" } }),
148
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
149
+ vi.fn().mockReturnValue([]),
150
+ );
151
+ const desc = result as GateDescriptor;
152
+ expect(desc.messages.denyReason).toContain("cat /outside/file.ts");
153
+ expect(desc.messages.unavailableReason).toContain("cat /outside/file.ts");
152
154
  });
153
155
 
154
- it("blocks when no UI available", async () => {
155
- const deps = makeBashExtGateDeps({
156
- checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
157
- canConfirm: vi.fn().mockReturnValue(false),
156
+ it("promptDetails includes command and tool_call source", async () => {
157
+ const result = await describeBashExternalDirectoryGate(
158
+ makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
159
+ vi.fn().mockReturnValue(makeCheckResult("ask")),
160
+ vi.fn().mockReturnValue([]),
161
+ );
162
+ const desc = result as GateDescriptor;
163
+ expect(desc.promptDetails).toMatchObject({
164
+ source: "tool_call",
165
+ agentName: "agent-1",
166
+ toolCallId: "tc-5",
167
+ toolName: "bash",
168
+ command: "cat /outside/project/file.ts",
158
169
  });
159
- const tcc = makeTcc();
160
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
161
- expect(result).toMatchObject({ action: "block" });
162
170
  });
163
171
 
164
- it("only prompts about uncovered paths when some are session-covered", async () => {
172
+ it("only includes uncovered paths when some are session-covered", async () => {
165
173
  const checkPermission = vi
166
174
  .fn()
167
- .mockImplementation(
168
- (
169
- surface: string,
170
- input: Record<string, unknown>,
171
- ): PermissionCheckResult => {
172
- if (
173
- surface === "external_directory" &&
174
- input.path === "/outside/a.ts"
175
- ) {
176
- return makeCheckResult("allow", { source: "session" });
177
- }
178
- return makeCheckResult("ask");
179
- },
180
- );
181
- const deps = makeBashExtGateDeps({ checkPermission });
182
- const tcc = makeTcc({
183
- input: { command: "diff /outside/a.ts /outside/b.ts" },
184
- });
185
- const result = await evaluateBashExternalDirectoryGate(tcc, deps);
186
- expect(result).toEqual({ action: "allow" });
187
- // The prompt should have been called (for uncovered /outside/b.ts)
188
- expect(deps.promptPermission).toHaveBeenCalled();
175
+ .mockImplementation((surface: string, input: Record<string, unknown>) => {
176
+ if (input.path === "/outside/a.ts") {
177
+ return makeCheckResult("allow", { source: "session" });
178
+ }
179
+ return makeCheckResult("ask");
180
+ });
181
+ const result = await describeBashExternalDirectoryGate(
182
+ makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
183
+ checkPermission,
184
+ vi.fn().mockReturnValue([]),
185
+ );
186
+ expect(isGateDescriptor(result)).toBe(true);
187
+ const desc = result as GateDescriptor;
188
+ // Should have patterns only for the uncovered path
189
+ const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
190
+ expect(patterns.length).toBe(1);
189
191
  });
190
192
  });