@gotgenes/pi-permission-system 5.5.1 → 5.6.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,20 @@ 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
+ ## [5.6.0](https://github.com/gotgenes/pi-permission-system/compare/v5.5.1...v5.6.0) (2026-05-07)
9
+
10
+
11
+ ### Features
12
+
13
+ * implement runGateCheck gate runner ([#118](https://github.com/gotgenes/pi-permission-system/issues/118)) ([46da4b6](https://github.com/gotgenes/pi-permission-system/commit/46da4b616cddef22d9e1d198a3aac9c640d62bac))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan gate runner extraction ([#118](https://github.com/gotgenes/pi-permission-system/issues/118)) ([8c0eb18](https://github.com/gotgenes/pi-permission-system/commit/8c0eb1881dabc19dba65f72ba5c30ae02e4070a0))
19
+ * **retro:** add retro notes for issue [#111](https://github.com/gotgenes/pi-permission-system/issues/111) ([e327323](https://github.com/gotgenes/pi-permission-system/commit/e327323187822e779454eeafd7372c6256f869ba))
20
+ * update target architecture for gate runner ([#118](https://github.com/gotgenes/pi-permission-system/issues/118)) ([40e1b1b](https://github.com/gotgenes/pi-permission-system/commit/40e1b1b016730e9be3da18fcb831001a2f498081))
21
+
8
22
  ## [5.5.1](https://github.com/gotgenes/pi-permission-system/compare/v5.5.0...v5.5.1) (2026-05-07)
9
23
 
10
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.5.1",
3
+ "version": "5.6.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -5,26 +5,34 @@ import {
5
5
  formatBashExternalDirectoryDenyReason,
6
6
  formatExternalDirectoryHardStopHint,
7
7
  } from "../../external-directory";
8
- import type { PermissionPromptDecision } from "../../permission-dialog";
9
- import { applyPermissionGate } from "../../permission-gate";
8
+ import type { Rule } from "../../rule";
10
9
  import { deriveApprovalPattern } from "../../session-rules";
11
- import type {
12
- BashExternalDirectoryGateDeps,
13
- GateOutcome,
14
- ToolCallContext,
15
- } from "./types";
10
+ import type { PermissionCheckResult } from "../../types";
11
+ import type { GateResult } from "./descriptor";
12
+ import type { ToolCallContext } from "./types";
13
+
14
+ /** Function type for checkPermission used by the descriptor factory. */
15
+ type CheckPermissionFn = (
16
+ surface: string,
17
+ input: unknown,
18
+ agentName?: string,
19
+ sessionRules?: Rule[],
20
+ ) => PermissionCheckResult;
16
21
 
17
22
  /**
18
- * Evaluate the bash external-directory permission gate.
23
+ * Build a pure descriptor for the bash external-directory permission gate.
19
24
  *
20
25
  * Extracts paths from a bash command and checks whether any reference
21
26
  * directories outside the working directory. Returns `null` when the gate
22
27
  * does not apply (tool is not bash, no CWD, or no external paths found).
28
+ * Returns a `GateBypass` when all paths are session-covered.
29
+ * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
23
30
  */
24
- export async function evaluateBashExternalDirectoryGate(
31
+ export async function describeBashExternalDirectoryGate(
25
32
  tcc: ToolCallContext,
26
- deps: BashExternalDirectoryGateDeps,
27
- ): Promise<GateOutcome | null> {
33
+ checkPermission: CheckPermissionFn,
34
+ getSessionRuleset: () => Rule[],
35
+ ): Promise<GateResult> {
28
36
  if (tcc.toolName !== "bash" || !tcc.cwd) return null;
29
37
 
30
38
  const command = getNonEmptyString(toRecord(tcc.input).command);
@@ -36,10 +44,10 @@ export async function evaluateBashExternalDirectoryGate(
36
44
  );
37
45
  if (externalPaths.length === 0) return null;
38
46
 
39
- const bashSessionRules = deps.getSessionRuleset();
47
+ const bashSessionRules = getSessionRuleset();
40
48
  const uncoveredPaths = externalPaths.filter(
41
49
  (p) =>
42
- deps.checkPermission(
50
+ checkPermission(
43
51
  "external_directory",
44
52
  { path: p },
45
53
  tcc.agentName ?? undefined,
@@ -48,58 +56,42 @@ export async function evaluateBashExternalDirectoryGate(
48
56
  );
49
57
 
50
58
  if (uncoveredPaths.length === 0) {
51
- deps.writeReviewLog("permission_request.session_approved", {
52
- source: "tool_call",
53
- toolCallId: tcc.toolCallId,
54
- toolName: tcc.toolName,
55
- agentName: tcc.agentName,
56
- command,
57
- externalPaths,
58
- resolution: "session_approved",
59
- });
60
- return null;
59
+ return {
60
+ action: "allow",
61
+ log: {
62
+ event: "permission_request.session_approved",
63
+ details: {
64
+ source: "tool_call",
65
+ toolCallId: tcc.toolCallId,
66
+ toolName: tcc.toolName,
67
+ agentName: tcc.agentName,
68
+ command,
69
+ externalPaths,
70
+ resolution: "session_approved",
71
+ },
72
+ },
73
+ };
61
74
  }
62
75
 
63
76
  // Get the config-level policy (no path → no session check).
64
- const extCheck = deps.checkPermission(
77
+ const extCheck = checkPermission(
65
78
  "external_directory",
66
79
  {},
67
80
  tcc.agentName ?? undefined,
68
81
  );
69
82
 
70
- let bashExtDecision: PermissionPromptDecision | null = null;
71
83
  const bashExtMessage = formatBashExternalDirectoryAskPrompt(
72
84
  command,
73
85
  uncoveredPaths,
74
86
  tcc.cwd,
75
87
  tcc.agentName ?? undefined,
76
88
  );
77
- const bashExtGate = await applyPermissionGate({
78
- state: extCheck.state,
79
- canConfirm: deps.canConfirm(),
80
- promptForApproval: async () => {
81
- const decision = await deps.promptPermission({
82
- requestId: tcc.toolCallId,
83
- source: "tool_call",
84
- agentName: tcc.agentName,
85
- message: bashExtMessage,
86
- toolCallId: tcc.toolCallId,
87
- toolName: tcc.toolName,
88
- command,
89
- });
90
- bashExtDecision = decision;
91
- return decision;
92
- },
93
- writeLog: deps.writeReviewLog,
94
- logContext: {
95
- source: "tool_call",
96
- toolCallId: tcc.toolCallId,
97
- toolName: tcc.toolName,
98
- agentName: tcc.agentName,
99
- command,
100
- externalPaths: uncoveredPaths,
101
- message: bashExtMessage,
102
- },
89
+
90
+ const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
91
+
92
+ return {
93
+ surface: "external_directory",
94
+ input: {},
103
95
  messages: {
104
96
  denyReason: formatBashExternalDirectoryDenyReason(
105
97
  command,
@@ -115,18 +107,31 @@ export async function evaluateBashExternalDirectoryGate(
115
107
  return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
116
108
  },
117
109
  },
118
- });
119
-
120
- if (bashExtGate.action === "block") {
121
- return { action: "block", reason: bashExtGate.reason };
122
- }
123
-
124
- if (bashExtDecision?.state === "approved_for_session") {
125
- for (const extPath of uncoveredPaths) {
126
- const pattern = deriveApprovalPattern(extPath);
127
- deps.approveSessionRule("external_directory", pattern);
128
- }
129
- }
130
-
131
- return { action: "allow" };
110
+ sessionApproval: {
111
+ surface: "external_directory",
112
+ patterns,
113
+ },
114
+ promptDetails: {
115
+ source: "tool_call",
116
+ agentName: tcc.agentName,
117
+ message: bashExtMessage,
118
+ toolCallId: tcc.toolCallId,
119
+ toolName: tcc.toolName,
120
+ command,
121
+ },
122
+ logContext: {
123
+ source: "tool_call",
124
+ toolCallId: tcc.toolCallId,
125
+ toolName: tcc.toolName,
126
+ agentName: tcc.agentName,
127
+ command,
128
+ externalPaths: uncoveredPaths,
129
+ message: bashExtMessage,
130
+ },
131
+ decision: {
132
+ surface: "external_directory",
133
+ value: command,
134
+ },
135
+ preCheck: extCheck,
136
+ };
132
137
  }
@@ -0,0 +1,115 @@
1
+ import type { PermissionPromptDecision } from "../../permission-dialog";
2
+ import type {
3
+ PermissionDecisionEvent,
4
+ PermissionDecisionResolution,
5
+ } from "../../permission-events";
6
+ import type { Rule } from "../../rule";
7
+ import type { PermissionCheckResult, PermissionState } from "../../types";
8
+ import type { PromptPermissionDetails } from "../types";
9
+
10
+ // ── Descriptor types ───────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Pure output of a gate function — describes what to check and how to present it.
14
+ *
15
+ * The gate runner (`runGateCheck`) uses this descriptor to execute the
16
+ * mechanical check→log→emit→approve cycle without the gate needing to know
17
+ * about logging, event emission, or session-rule recording.
18
+ */
19
+ export interface GateDescriptor {
20
+ /** Permission surface to check (e.g. "bash", "external_directory", "skill"). */
21
+ surface: string;
22
+ /** Input passed to checkPermission. */
23
+ input: unknown;
24
+ /** Message strings/factories for each outcome. */
25
+ messages: {
26
+ denyReason: string;
27
+ unavailableReason: string;
28
+ userDeniedReason: (decision: PermissionPromptDecision) => string;
29
+ };
30
+ /**
31
+ * Session-approval suggestion for "for this session" option.
32
+ * Single pattern or multiple patterns (bash external-directory gate).
33
+ */
34
+ sessionApproval?:
35
+ | { surface: string; pattern: string }
36
+ | { surface: string; patterns: string[] };
37
+ /** Details passed to the interactive permission prompt (requestId is added by the runner). */
38
+ promptDetails: Omit<PromptPermissionDetails, "requestId">;
39
+ /** Extra context fields written to the review log alongside gate outcomes. */
40
+ logContext: Record<string, unknown>;
41
+ /** Surface and value for the decision event (may differ from the check surface). */
42
+ decision: {
43
+ surface: string;
44
+ value: string;
45
+ };
46
+ /**
47
+ * When set, the gate has already resolved the permission state
48
+ * (e.g. from a skill entry match). The runner uses this directly
49
+ * instead of calling checkPermission.
50
+ */
51
+ preResolved?: {
52
+ state: PermissionState;
53
+ };
54
+ /**
55
+ * When set, the runner uses this pre-computed check result directly
56
+ * instead of calling checkPermission. Used when the orchestrator has
57
+ * already performed the check (e.g. to build messages from the result).
58
+ */
59
+ preCheck?: PermissionCheckResult;
60
+ }
61
+
62
+ /**
63
+ * Early allow result — gate has determined the action without needing the runner.
64
+ *
65
+ * Used for cases like Pi infrastructure read bypass where the gate short-circuits
66
+ * with a deterministic allow before reaching the permission check.
67
+ */
68
+ export interface GateBypass {
69
+ action: "allow";
70
+ /** Optional review log entry to emit. */
71
+ log?: { event: string; details: Record<string, unknown> };
72
+ /** Optional decision event to emit. */
73
+ decision?: PermissionDecisionEvent;
74
+ }
75
+
76
+ /** Union of possible gate function return values. */
77
+ export type GateResult = GateDescriptor | GateBypass | null;
78
+
79
+ // ── Runner dependency interface ────────────────────────────────────────────
80
+
81
+ /**
82
+ * Infrastructure dependencies for the gate runner.
83
+ *
84
+ * Built once in the orchestrator and reused for all gates.
85
+ * Handles all side effects: permission checks, logging, event emission,
86
+ * session-rule recording.
87
+ */
88
+ export interface GateRunnerDeps {
89
+ checkPermission(
90
+ surface: string,
91
+ input: unknown,
92
+ agentName?: string,
93
+ sessionRules?: Rule[],
94
+ ): PermissionCheckResult;
95
+ getSessionRuleset(): Rule[];
96
+ approveSessionRule(surface: string, pattern: string): void;
97
+ writeReviewLog(event: string, details: Record<string, unknown>): void;
98
+ emitDecision(event: PermissionDecisionEvent): void;
99
+ canConfirm(): boolean;
100
+ promptPermission(
101
+ details: PromptPermissionDetails,
102
+ ): Promise<PermissionPromptDecision>;
103
+ }
104
+
105
+ // ── Type guard helpers ─────────────────────────────────────────────────────
106
+
107
+ /** Check whether a GateResult is a GateBypass (early allow). */
108
+ export function isGateBypass(result: GateResult): result is GateBypass {
109
+ return result !== null && "action" in result;
110
+ }
111
+
112
+ /** Check whether a GateResult is a GateDescriptor (needs runner). */
113
+ export function isGateDescriptor(result: GateResult): result is GateDescriptor {
114
+ return result !== null && !("action" in result);
115
+ }
@@ -7,26 +7,22 @@ import {
7
7
  isPiInfrastructureRead,
8
8
  normalizePathForComparison,
9
9
  } from "../../external-directory";
10
- import type { PermissionPromptDecision } from "../../permission-dialog";
11
- import { applyPermissionGate } from "../../permission-gate";
12
10
  import { deriveApprovalPattern } from "../../session-rules";
13
- import { deriveResolution } from "./helpers";
14
- import type {
15
- ExternalDirectoryGateDeps,
16
- GateOutcome,
17
- ToolCallContext,
18
- } from "./types";
11
+ import type { GateResult } from "./descriptor";
12
+ import type { ToolCallContext } from "./types";
19
13
 
20
14
  /**
21
- * Evaluate the external-directory permission gate for file tools.
15
+ * Build a pure descriptor for the external-directory permission gate.
22
16
  *
23
17
  * Returns `null` when the gate does not apply (no CWD, tool is not
24
18
  * path-bearing, or path is inside the working directory).
19
+ * Returns a `GateBypass` for Pi infrastructure reads.
20
+ * Returns a `GateDescriptor` for external paths needing a permission check.
25
21
  */
26
- export async function evaluateExternalDirectoryGate(
22
+ export function describeExternalDirectoryGate(
27
23
  tcc: ToolCallContext,
28
- deps: ExternalDirectoryGateDeps,
29
- ): Promise<GateOutcome | null> {
24
+ infraDirs: string[],
25
+ ): GateResult {
30
26
  if (!tcc.cwd) return null;
31
27
 
32
28
  const externalDirectoryPath = getPathBearingToolPath(tcc.toolName, tcc.input);
@@ -42,99 +38,46 @@ export async function evaluateExternalDirectoryGate(
42
38
  );
43
39
 
44
40
  // ── Pi infrastructure read bypass ──────────────────────────────────────
45
- const allInfraDirs = deps.getInfrastructureDirs();
46
41
  if (
47
- isPiInfrastructureRead(
48
- tcc.toolName,
49
- normalizedExtPath,
50
- allInfraDirs,
51
- tcc.cwd,
52
- )
42
+ isPiInfrastructureRead(tcc.toolName, normalizedExtPath, infraDirs, tcc.cwd)
53
43
  ) {
54
- deps.writeReviewLog("permission_request.infrastructure_auto_allowed", {
55
- source: "tool_call",
56
- toolCallId: tcc.toolCallId,
57
- toolName: tcc.toolName,
58
- agentName: tcc.agentName,
59
- path: externalDirectoryPath,
60
- });
61
- deps.emitDecision({
62
- surface: tcc.toolName,
63
- value: externalDirectoryPath,
64
- result: "allow",
65
- resolution: "infrastructure_auto_allowed",
66
- origin: null,
67
- agentName: tcc.agentName ?? null,
68
- matchedPattern: null,
69
- });
70
- return { action: "allow" };
44
+ return {
45
+ action: "allow",
46
+ log: {
47
+ event: "permission_request.infrastructure_auto_allowed",
48
+ details: {
49
+ source: "tool_call",
50
+ toolCallId: tcc.toolCallId,
51
+ toolName: tcc.toolName,
52
+ agentName: tcc.agentName,
53
+ path: externalDirectoryPath,
54
+ },
55
+ },
56
+ decision: {
57
+ surface: tcc.toolName,
58
+ value: externalDirectoryPath,
59
+ result: "allow",
60
+ resolution: "infrastructure_auto_allowed",
61
+ origin: null,
62
+ agentName: tcc.agentName ?? null,
63
+ matchedPattern: null,
64
+ },
65
+ };
71
66
  }
72
67
 
73
- // ── Policy check ───────────────────────────────────────────────────────
74
- const extCheck = deps.checkPermission(
75
- "external_directory",
76
- { path: normalizedExtPath },
77
- tcc.agentName ?? undefined,
78
- deps.getSessionRuleset(),
79
- );
80
-
81
- // Session-rule hit
82
- if (extCheck.source === "session") {
83
- deps.writeReviewLog("permission_request.session_approved", {
84
- source: "tool_call",
85
- toolCallId: tcc.toolCallId,
86
- toolName: tcc.toolName,
87
- agentName: tcc.agentName,
88
- path: externalDirectoryPath,
89
- resolution: "session_approved",
90
- sessionApprovalPattern: extCheck.matchedPattern,
91
- });
92
- deps.emitDecision({
93
- surface: "external_directory",
94
- value: externalDirectoryPath,
95
- result: "allow",
96
- resolution: "session_approved",
97
- origin: extCheck.origin ?? null,
98
- agentName: tcc.agentName ?? null,
99
- matchedPattern: extCheck.matchedPattern ?? null,
100
- });
101
- return { action: "allow" };
102
- }
103
-
104
- // ── Interactive gate ───────────────────────────────────────────────────
105
- let extDirDecision: PermissionPromptDecision | null = null;
68
+ // ── Build descriptor for permission check ───────────────────────────────
106
69
  const extDirMessage = formatExternalDirectoryAskPrompt(
107
70
  tcc.toolName,
108
71
  externalDirectoryPath,
109
72
  tcc.cwd,
110
73
  tcc.agentName ?? undefined,
111
74
  );
112
- const extDirCanConfirm = deps.canConfirm();
113
- const extDirGateResult = await applyPermissionGate({
114
- state: extCheck.state,
115
- canConfirm: extDirCanConfirm,
116
- promptForApproval: async () => {
117
- const decision = await deps.promptPermission({
118
- requestId: tcc.toolCallId,
119
- source: "tool_call",
120
- agentName: tcc.agentName,
121
- message: extDirMessage,
122
- toolCallId: tcc.toolCallId,
123
- toolName: tcc.toolName,
124
- path: externalDirectoryPath,
125
- });
126
- extDirDecision = decision;
127
- return decision;
128
- },
129
- writeLog: deps.writeReviewLog,
130
- logContext: {
131
- source: "tool_call",
132
- toolCallId: tcc.toolCallId,
133
- toolName: tcc.toolName,
134
- agentName: tcc.agentName,
135
- path: externalDirectoryPath,
136
- message: extDirMessage,
137
- },
75
+
76
+ const pattern = deriveApprovalPattern(normalizedExtPath);
77
+
78
+ return {
79
+ surface: "external_directory",
80
+ input: { path: normalizedExtPath },
138
81
  messages: {
139
82
  denyReason: formatExternalDirectoryDenyReason(
140
83
  tcc.toolName,
@@ -150,31 +93,29 @@ export async function evaluateExternalDirectoryGate(
150
93
  decision.denialReason,
151
94
  ),
152
95
  },
153
- });
154
-
155
- deps.emitDecision({
156
- surface: "external_directory",
157
- value: externalDirectoryPath,
158
- result: extDirGateResult.action === "allow" ? "allow" : "deny",
159
- resolution: deriveResolution(
160
- extCheck.state,
161
- extDirGateResult.action,
162
- extDirDecision?.state === "approved_for_session",
163
- extDirCanConfirm,
164
- ),
165
- origin: extCheck.origin ?? null,
166
- agentName: tcc.agentName ?? null,
167
- matchedPattern: extCheck.matchedPattern ?? null,
168
- });
169
-
170
- if (extDirGateResult.action === "block") {
171
- return { action: "block", reason: extDirGateResult.reason };
172
- }
173
-
174
- if (extDirDecision?.state === "approved_for_session") {
175
- const pattern = deriveApprovalPattern(normalizedExtPath);
176
- deps.approveSessionRule("external_directory", pattern);
177
- }
178
-
179
- return { action: "allow" };
96
+ sessionApproval: {
97
+ surface: "external_directory",
98
+ pattern,
99
+ },
100
+ promptDetails: {
101
+ source: "tool_call",
102
+ agentName: tcc.agentName,
103
+ message: extDirMessage,
104
+ toolCallId: tcc.toolCallId,
105
+ toolName: tcc.toolName,
106
+ path: externalDirectoryPath,
107
+ },
108
+ logContext: {
109
+ source: "tool_call",
110
+ toolCallId: tcc.toolCallId,
111
+ toolName: tcc.toolName,
112
+ agentName: tcc.agentName,
113
+ path: externalDirectoryPath,
114
+ message: extDirMessage,
115
+ },
116
+ decision: {
117
+ surface: "external_directory",
118
+ value: externalDirectoryPath,
119
+ },
120
+ };
180
121
  }
@@ -1,6 +1,14 @@
1
- export { evaluateBashExternalDirectoryGate } from "./bash-external-directory";
2
- export { evaluateExternalDirectoryGate } from "./external-directory";
1
+ export { describeBashExternalDirectoryGate } from "./bash-external-directory";
2
+ export type {
3
+ GateBypass,
4
+ GateDescriptor,
5
+ GateResult,
6
+ GateRunnerDeps,
7
+ } from "./descriptor";
8
+ export { isGateBypass, isGateDescriptor } from "./descriptor";
9
+ export { describeExternalDirectoryGate } from "./external-directory";
3
10
  export { deriveDecisionValue, deriveResolution } from "./helpers";
4
- export { evaluateSkillReadGate } from "./skill-read";
5
- export { evaluateToolGate } from "./tool";
11
+ export { runGateCheck } from "./runner";
12
+ export { describeSkillReadGate } from "./skill-read";
13
+ export { describeToolGate } from "./tool";
6
14
  export type { GateOutcome, ToolCallContext } from "./types";