@gotgenes/pi-permission-system 8.0.0 → 8.2.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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/config/config.example.json +3 -0
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +12 -0
  5. package/src/extension-config.ts +23 -0
  6. package/src/handlers/gates/bash-external-directory.ts +2 -4
  7. package/src/handlers/gates/bash-path.ts +2 -4
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +9 -6
  14. package/src/handlers/permission-gate-handler.ts +110 -141
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-prompts.ts +5 -2
  17. package/src/permission-session.ts +3 -2
  18. package/src/scope-merge.ts +72 -0
  19. package/src/session-approval.ts +43 -0
  20. package/src/session-rules.ts +13 -0
  21. package/src/tool-input-preview.ts +0 -116
  22. package/src/tool-preview-formatter.ts +188 -0
  23. package/test/extension-config.test.ts +93 -0
  24. package/test/handlers/external-directory-integration.test.ts +3 -1
  25. package/test/handlers/external-directory-session-dedup.test.ts +17 -12
  26. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +2 -2
  30. package/test/handlers/gates/runner.test.ts +18 -23
  31. package/test/handlers/gates/tool.test.ts +31 -4
  32. package/test/handlers/input-events.test.ts +1 -1
  33. package/test/handlers/input.test.ts +1 -1
  34. package/test/handlers/tool-call-events.test.ts +3 -2
  35. package/test/handlers/tool-call.test.ts +3 -2
  36. package/test/handlers/validate-requested-tool.test.ts +92 -0
  37. package/test/permission-prompts.test.ts +66 -38
  38. package/test/permission-session.test.ts +6 -3
  39. package/test/scope-merge.test.ts +116 -0
  40. package/test/session-approval.test.ts +75 -0
  41. package/test/session-rules.test.ts +49 -0
  42. package/test/tool-input-preview.test.ts +0 -244
  43. package/test/tool-preview-formatter.test.ts +385 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ 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
+ ## [8.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.1.0...pi-permission-system-v8.2.0) (2026-05-31)
9
+
10
+
11
+ ### Features
12
+
13
+ * add SessionApproval value object and SessionRules.record ([8f98d92](https://github.com/gotgenes/pi-packages/commit/8f98d9223a424b0993d51c2d9106e7d01c6819d7))
14
+ * centralize decision-event construction in buildDecisionEvent ([19c2c83](https://github.com/gotgenes/pi-packages/commit/19c2c837b1907a4c302105ee86715533477247d4))
15
+
16
+ ## [8.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.0.0...pi-permission-system-v8.1.0) (2026-05-31)
17
+
18
+
19
+ ### Features
20
+
21
+ * add toolInputPreviewMaxLength and toolTextSummaryMaxLength config fields ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([3a7dafb](https://github.com/gotgenes/pi-packages/commit/3a7dafbb0bb8534dabda7eeba6c4d35ba2e8708b))
22
+ * use configured preview limits in permission prompts ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([83e2829](https://github.com/gotgenes/pi-packages/commit/83e2829175a55f2f0436c742e19e3753ee171e47))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * document configurable tool-preview length knobs ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([6d0b134](https://github.com/gotgenes/pi-packages/commit/6d0b134be4ef4c90ddf582b32058c3ec9d2eb13f))
28
+
8
29
  ## [8.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.1...pi-permission-system-v8.0.0) (2026-05-30)
9
30
 
10
31
 
@@ -5,6 +5,9 @@
5
5
  "permissionReviewLog": true,
6
6
  "yoloMode": false,
7
7
 
8
+ "toolInputPreviewMaxLength": 400,
9
+ "toolTextSummaryMaxLength": 120,
10
+
8
11
  "piInfrastructureReadPaths": [],
9
12
 
10
13
  "permission": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.0.0",
3
+ "version": "8.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,18 @@
29
29
  "type": "boolean",
30
30
  "default": false
31
31
  },
32
+ "toolInputPreviewMaxLength": {
33
+ "description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
34
+ "markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
35
+ "type": "integer",
36
+ "minimum": 1
37
+ },
38
+ "toolTextSummaryMaxLength": {
39
+ "description": "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) in permission prompts. Omit to use the default (80).",
40
+ "markdownDescription": "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) shown in permission prompts.\n\nOmit to use the default (80). Increase this when working with long regexes or deep paths that are being cut off.",
41
+ "type": "integer",
42
+ "minimum": 1
43
+ },
32
44
  "piInfrastructureReadPaths": {
33
45
  "description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
34
46
  "markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.",
@@ -12,6 +12,10 @@ export interface PermissionSystemExtensionConfig {
12
12
  yoloMode: boolean;
13
13
  /** Additional directories to auto-allow for reads as Pi infrastructure. */
14
14
  piInfrastructureReadPaths?: string[];
15
+ /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
16
+ toolInputPreviewMaxLength?: number;
17
+ /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
18
+ toolTextSummaryMaxLength?: number;
15
19
  }
16
20
 
17
21
  export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
@@ -42,6 +46,13 @@ export function detectMisplacedPermissionKeys(
42
46
  return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
43
47
  }
44
48
 
49
+ /** Returns `raw` if it is a positive integer; otherwise `undefined`. */
50
+ export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
51
+ return typeof raw === "number" && Number.isInteger(raw) && raw > 0
52
+ ? raw
53
+ : undefined;
54
+ }
55
+
45
56
  export function normalizePermissionSystemConfig(
46
57
  raw: unknown,
47
58
  ): PermissionSystemExtensionConfig {
@@ -60,6 +71,18 @@ export function normalizePermissionSystemConfig(
60
71
  if (piInfrastructureReadPaths !== undefined) {
61
72
  result.piInfrastructureReadPaths = piInfrastructureReadPaths;
62
73
  }
74
+ const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
75
+ record.toolInputPreviewMaxLength,
76
+ );
77
+ if (toolInputPreviewMaxLength !== undefined) {
78
+ result.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
79
+ }
80
+ const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
81
+ record.toolTextSummaryMaxLength,
82
+ );
83
+ if (toolTextSummaryMaxLength !== undefined) {
84
+ result.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
85
+ }
63
86
  return result;
64
87
  }
65
88
 
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
@@ -106,10 +107,7 @@ export async function describeBashExternalDirectoryGate(
106
107
  cwd: tcc.cwd,
107
108
  agentName: tcc.agentName ?? undefined,
108
109
  },
109
- sessionApproval: {
110
- surface: "external_directory",
111
- patterns,
112
- },
110
+ sessionApproval: SessionApproval.multiple("external_directory", patterns),
113
111
  promptDetails: {
114
112
  source: "tool_call",
115
113
  agentName: tcc.agentName,
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "#src/common";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import { extractTokensForPathRules } from "./bash-path-extractor";
@@ -117,10 +118,7 @@ export async function describeBashPathGate(
117
118
  pathValue: worstToken,
118
119
  agentName: tcc.agentName ?? undefined,
119
120
  },
120
- sessionApproval: {
121
- surface: "path",
122
- pattern,
123
- },
121
+ sessionApproval: SessionApproval.single("path", pattern),
124
122
  promptDetails: {
125
123
  source: "tool_call",
126
124
  agentName: tcc.agentName,
@@ -3,6 +3,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
3
3
  import type { PermissionDecisionEvent } from "#src/permission-events";
4
4
  import type { PromptPermissionDetails } from "#src/permission-prompter";
5
5
  import type { Rule } from "#src/rule";
6
+ import type { SessionApproval } from "#src/session-approval";
6
7
  import type { PermissionCheckResult, PermissionState } from "#src/types";
7
8
 
8
9
  // ── Descriptor types ───────────────────────────────────────────────────────
@@ -22,12 +23,11 @@ export interface GateDescriptor {
22
23
  /** Structured denial context — the runner formats messages from this. */
23
24
  denialContext: DenialContext;
24
25
  /**
25
- * Session-approval suggestion for "for this session" option.
26
- * Single pattern or multiple patterns (bash external-directory gate).
26
+ * Session-approval suggestion for the "for this session" option.
27
+ * Wraps either a single pattern or multiple patterns behind a unified
28
+ * interface — the runner never needs to know which case applies.
27
29
  */
28
- sessionApproval?:
29
- | { surface: string; pattern: string }
30
- | { surface: string; patterns: string[] };
30
+ sessionApproval?: SessionApproval;
31
31
  /** Details passed to the interactive permission prompt (requestId is added by the runner). */
32
32
  promptDetails: Omit<PromptPermissionDetails, "requestId">;
33
33
  /** Extra context fields written to the review log alongside gate outcomes. */
@@ -87,7 +87,7 @@ export interface GateRunnerDeps {
87
87
  sessionRules?: Rule[],
88
88
  ): PermissionCheckResult;
89
89
  getSessionRuleset(): Rule[];
90
- approveSessionRule(surface: string, pattern: string): void;
90
+ recordSessionApproval(approval: SessionApproval): void;
91
91
  writeReviewLog(event: string, details: Record<string, unknown>): void;
92
92
  emitDecision(event: PermissionDecisionEvent): void;
93
93
  canConfirm(): boolean;
@@ -4,6 +4,7 @@ import {
4
4
  isPiInfrastructureRead,
5
5
  normalizePathForComparison,
6
6
  } from "#src/path-utils";
7
+ import { SessionApproval } from "#src/session-approval";
7
8
  import { deriveApprovalPattern } from "#src/session-rules";
8
9
  import type { GateResult } from "./descriptor";
9
10
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
@@ -83,10 +84,7 @@ export function describeExternalDirectoryGate(
83
84
  cwd: tcc.cwd,
84
85
  agentName: tcc.agentName ?? undefined,
85
86
  },
86
- sessionApproval: {
87
- surface: "external_directory",
88
- pattern,
89
- },
87
+ sessionApproval: SessionApproval.single("external_directory", pattern),
90
88
  promptDetails: {
91
89
  source: "tool_call",
92
90
  agentName: tcc.agentName,
@@ -1,4 +1,7 @@
1
- import type { PermissionDecisionResolution } from "#src/permission-events";
1
+ import type {
2
+ PermissionDecisionEvent,
3
+ PermissionDecisionResolution,
4
+ } from "#src/permission-events";
2
5
  import type { PermissionCheckResult } from "#src/types";
3
6
 
4
7
  /**
@@ -17,6 +20,32 @@ export function deriveDecisionValue(
17
20
  return toolName;
18
21
  }
19
22
 
23
+ /**
24
+ * Build a `PermissionDecisionEvent` from the gate's inputs.
25
+ *
26
+ * Centralises the `origin / agentName / matchedPattern ?? null` normalization
27
+ * that is otherwise duplicated across the session-hit path and the gate-result
28
+ * path in `runGateCheck`.
29
+ */
30
+ export function buildDecisionEvent(
31
+ decision: { surface: string; value: string },
32
+ check: Pick<PermissionCheckResult, "origin" | "matchedPattern">,
33
+ agentName: string | null,
34
+ result: "allow" | "deny",
35
+ resolution: PermissionDecisionResolution,
36
+ ): PermissionDecisionEvent {
37
+ return {
38
+ surface: decision.surface,
39
+ value: decision.value,
40
+ result,
41
+ resolution,
42
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
43
+ origin: check.origin ?? null,
44
+ agentName: agentName ?? null,
45
+ matchedPattern: check.matchedPattern ?? null,
46
+ };
47
+ }
48
+
20
49
  /**
21
50
  * Map the gate outcome back to a PermissionDecisionResolution.
22
51
  *
@@ -1,5 +1,6 @@
1
1
  import { getPathBearingToolPath } from "#src/path-utils";
2
2
  import type { Rule } from "#src/rule";
3
+ import { SessionApproval } from "#src/session-approval";
3
4
  import { deriveApprovalPattern } from "#src/session-rules";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
  import type { GateDescriptor, GateResult } from "./descriptor";
@@ -55,10 +56,7 @@ export function describePathGate(
55
56
  pathValue: filePath,
56
57
  agentName: tcc.agentName ?? undefined,
57
58
  },
58
- sessionApproval: {
59
- surface: "path",
60
- pattern,
61
- },
59
+ sessionApproval: SessionApproval.single("path", pattern),
62
60
  promptDetails: {
63
61
  source: "tool_call",
64
62
  agentName: tcc.agentName,
@@ -7,7 +7,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
7
7
  import { applyPermissionGate } from "#src/permission-gate";
8
8
  import type { PermissionCheckResult } from "#src/types";
9
9
  import type { GateDescriptor, GateRunnerDeps } from "./descriptor";
10
- import { deriveResolution } from "./helpers";
10
+ import { buildDecisionEvent, deriveResolution } from "./helpers";
11
11
  import type { GateOutcome } from "./types";
12
12
 
13
13
  /**
@@ -56,37 +56,21 @@ export async function runGateCheck(
56
56
  resolution: "session_approved",
57
57
  sessionApprovalPattern: check.matchedPattern,
58
58
  });
59
- deps.emitDecision({
60
- surface: descriptor.decision.surface,
61
- value: descriptor.decision.value,
62
- result: "allow",
63
- resolution: "session_approved",
64
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
65
- origin: check.origin ?? null,
66
- agentName: agentName ?? null,
67
- matchedPattern: check.matchedPattern ?? null,
68
- });
59
+ deps.emitDecision(
60
+ buildDecisionEvent(
61
+ descriptor.decision,
62
+ check,
63
+ agentName,
64
+ "allow",
65
+ "session_approved",
66
+ ),
67
+ );
69
68
  return { action: "allow" };
70
69
  }
71
70
 
72
71
  // 3. Apply the deny/ask/allow gate
73
72
  const canConfirm = deps.canConfirm();
74
73
 
75
- // Resolve the first pattern for applyPermissionGate's sessionApproval param
76
- const singleSessionApproval = descriptor.sessionApproval
77
- ? "pattern" in descriptor.sessionApproval
78
- ? {
79
- surface: descriptor.sessionApproval.surface,
80
- pattern: descriptor.sessionApproval.pattern,
81
- }
82
- : descriptor.sessionApproval.patterns.length > 0
83
- ? {
84
- surface: descriptor.sessionApproval.surface,
85
- pattern: descriptor.sessionApproval.patterns[0],
86
- }
87
- : undefined
88
- : undefined;
89
-
90
74
  // Construct messages from the centralized formatter.
91
75
  const messages = {
92
76
  denyReason: formatDenyReason(descriptor.denialContext),
@@ -99,7 +83,7 @@ export async function runGateCheck(
99
83
  const gateResult = await applyPermissionGate({
100
84
  state: check.state,
101
85
  canConfirm,
102
- sessionApproval: singleSessionApproval,
86
+ sessionApproval: descriptor.sessionApproval?.toGateApproval(),
103
87
  promptForApproval: async () => {
104
88
  const decision = await deps.promptPermission({
105
89
  requestId: toolCallId,
@@ -119,37 +103,26 @@ export async function runGateCheck(
119
103
  gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
120
104
 
121
105
  // 5. Emit decision event
122
- deps.emitDecision({
123
- surface: descriptor.decision.surface,
124
- value: descriptor.decision.value,
125
- result: gateResult.action === "allow" ? "allow" : "deny",
126
- resolution: deriveResolution(
127
- check.state,
128
- gateResult.action,
129
- hasSessionApproval,
130
- canConfirm,
131
- autoApproved,
106
+ deps.emitDecision(
107
+ buildDecisionEvent(
108
+ descriptor.decision,
109
+ check,
110
+ agentName,
111
+ gateResult.action === "allow" ? "allow" : "deny",
112
+ deriveResolution(
113
+ check.state,
114
+ gateResult.action,
115
+ hasSessionApproval,
116
+ canConfirm,
117
+ autoApproved,
118
+ ),
132
119
  ),
133
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
134
- origin: check.origin ?? null,
135
- agentName: agentName ?? null,
136
- matchedPattern: check.matchedPattern ?? null,
137
- });
120
+ );
138
121
 
139
- // 6. Record session approval(s)
140
- if (gateResult.action === "allow" && hasSessionApproval) {
141
- if (descriptor.sessionApproval) {
142
- if ("patterns" in descriptor.sessionApproval) {
143
- for (const pattern of descriptor.sessionApproval.patterns) {
144
- deps.approveSessionRule(descriptor.sessionApproval.surface, pattern);
145
- }
146
- } else {
147
- deps.approveSessionRule(
148
- descriptor.sessionApproval.surface,
149
- descriptor.sessionApproval.pattern,
150
- );
151
- }
152
- }
122
+ // 6. Record session approval — tell the store; it owns the per-pattern loop
123
+ // hasSessionApproval already implies gateResult.action === "allow"
124
+ if (hasSessionApproval && descriptor.sessionApproval) {
125
+ deps.recordSessionApproval(descriptor.sessionApproval);
153
126
  }
154
127
 
155
128
  if (gateResult.action === "block") {
@@ -1,7 +1,8 @@
1
1
  import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
2
2
  import { suggestSessionPattern } from "#src/pattern-suggest";
3
3
  import { formatAskPrompt } from "#src/permission-prompts";
4
- import { getPermissionLogContext } from "#src/tool-input-preview";
4
+ import { SessionApproval } from "#src/session-approval";
5
+ import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
5
6
  import type { PermissionCheckResult } from "#src/types";
6
7
  import type { GateDescriptor } from "./descriptor";
7
8
  import { deriveDecisionValue } from "./helpers";
@@ -31,8 +32,9 @@ function deriveSuggestionValue(
31
32
  export function describeToolGate(
32
33
  tcc: ToolCallContext,
33
34
  check: PermissionCheckResult,
35
+ formatter: ToolPreviewFormatter,
34
36
  ): GateDescriptor {
35
- const permissionLogContext = getPermissionLogContext(
37
+ const permissionLogContext = formatter.getPermissionLogContext(
36
38
  check,
37
39
  tcc.input,
38
40
  PATH_BEARING_TOOLS,
@@ -48,6 +50,7 @@ export function describeToolGate(
48
50
  check,
49
51
  tcc.agentName ?? undefined,
50
52
  tcc.input,
53
+ formatter,
51
54
  );
52
55
 
53
56
  return {
@@ -59,10 +62,10 @@ export function describeToolGate(
59
62
  agentName: tcc.agentName ?? undefined,
60
63
  input: tcc.input,
61
64
  },
62
- sessionApproval: {
63
- surface: suggestion.surface,
64
- pattern: suggestion.pattern,
65
- },
65
+ sessionApproval: SessionApproval.single(
66
+ suggestion.surface,
67
+ suggestion.pattern,
68
+ ),
66
69
  promptDetails: {
67
70
  source: "tool_call",
68
71
  agentName: tcc.agentName,