@gotgenes/pi-permission-system 3.3.0 → 3.5.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,39 @@ 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
+ ## [3.5.0](https://github.com/gotgenes/pi-permission-system/compare/v3.4.0...v3.5.0) (2026-05-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * deprecate doom_loop special permission key ([68e70e7](https://github.com/gotgenes/pi-permission-system/commit/68e70e71b68e5a76a071ef4613da356a91080158))
14
+ * remove doom_loop from type union and config-loader ([bf2f288](https://github.com/gotgenes/pi-permission-system/commit/bf2f2886a800187337e82954e812e6d05e9bd451))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * add architecture documents for current and target permission model ([aab1ac5](https://github.com/gotgenes/pi-permission-system/commit/aab1ac50c4478d2e393c2a796bf6fcc4ec606f79))
20
+ * plan doom_loop deprecation ([#54](https://github.com/gotgenes/pi-permission-system/issues/54)) ([2e730f5](https://github.com/gotgenes/pi-permission-system/commit/2e730f52189dd2996ebbe90dd5d2b3206a45d1f6))
21
+ * plan handler extraction from piPermissionSystemExtension ([#42](https://github.com/gotgenes/pi-permission-system/issues/42)) ([6ecd419](https://github.com/gotgenes/pi-permission-system/commit/6ecd4190fb9a60009eb695b4998ab8a1d1419139))
22
+ * remove doom_loop from schema, example, and README ([7f422e0](https://github.com/gotgenes/pi-permission-system/commit/7f422e086f0052e0d9449dbd0122c57b923b053d))
23
+ * **retro:** add retro notes for issue [#45](https://github.com/gotgenes/pi-permission-system/issues/45) ([14c5559](https://github.com/gotgenes/pi-permission-system/commit/14c55595c5abfaa51f8ec83369452db5f457836c))
24
+
25
+ ## [3.4.0](https://github.com/gotgenes/pi-permission-system/compare/v3.3.0...v3.4.0) (2026-05-03)
26
+
27
+
28
+ ### Features
29
+
30
+ * add "approve for session" option to permission dialog ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([909d5ee](https://github.com/gotgenes/pi-permission-system/commit/909d5ee540615f876852b3bdb60154487c2570fd))
31
+ * add SessionApprovalCache for ephemeral session approvals ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([4f97779](https://github.com/gotgenes/pi-permission-system/commit/4f9777980eba139c9c85a027eeddc84ff932911c))
32
+ * wire session approvals into external-directory gates ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([3ab156d](https://github.com/gotgenes/pi-permission-system/commit/3ab156dc4ad10bd37938a5096dc6c33970767b1a))
33
+
34
+
35
+ ### Documentation
36
+
37
+ * document session-scoped approval option ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([eb1eb9c](https://github.com/gotgenes/pi-permission-system/commit/eb1eb9c0052e7dd88ad7162b322160cfd6e0e62b))
38
+ * plan session-scoped approvals for permission prompts ([#45](https://github.com/gotgenes/pi-permission-system/issues/45)) ([29dcede](https://github.com/gotgenes/pi-permission-system/commit/29dcede17ad68fd3980bf3289069e237de2b4ef0))
39
+ * **retro:** add retro notes for issue [#41](https://github.com/gotgenes/pi-permission-system/issues/41) ([fd2755f](https://github.com/gotgenes/pi-permission-system/commit/fd2755fcf4ec68c9e65e6128e9b869da1f368abb))
40
+
8
41
  ## [3.3.0](https://github.com/gotgenes/pi-permission-system/compare/v3.2.0...v3.3.0) (2026-05-03)
9
42
 
10
43
 
package/README.md CHANGED
@@ -137,7 +137,7 @@ The config file combines runtime knobs and permission policy in one object:
137
137
  "bash": { "git status": "allow", "git *": "ask" },
138
138
  "mcp": { "mcp_status": "allow" },
139
139
  "skills": { "*": "ask" },
140
- "special": { "doom_loop": "deny", "external_directory": "ask" }
140
+ "special": { "external_directory": "ask" }
141
141
  }
142
142
  ```
143
143
 
@@ -353,13 +353,11 @@ Reserved permission checks:
353
353
 
354
354
  | Key | Description |
355
355
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
356
- | `doom_loop` | Controls doom loop detection behavior |
357
356
  | `external_directory` | Enforces ask/allow/deny decisions for path-bearing tools and bash commands that reference paths outside the active working directory |
358
357
 
359
358
  ```jsonc
360
359
  {
361
360
  "special": {
362
- "doom_loop": "deny",
363
361
  "external_directory": "ask",
364
362
  },
365
363
  }
@@ -470,6 +468,23 @@ Example edit approval prompt:
470
468
  Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
471
469
  ```
472
470
 
471
+ ### Session-Scoped Approvals
472
+
473
+ When `special.external_directory` resolves to `ask`, the permission dialog offers four options:
474
+
475
+ ```text
476
+ Yes | Yes, for this session | No | No, provide reason
477
+ ```
478
+
479
+ Selecting **Yes, for this session** approves the current request and caches the directory prefix so that subsequent accesses under the same directory skip the prompt for the remainder of the session.
480
+ For example, approving access to `~/other-project/src/foo.ts` covers all paths under `~/other-project/src/` until the session ends.
481
+
482
+ Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
483
+ The review log records these decisions with `resolution: "session_approved"` so they remain auditable.
484
+
485
+ This is currently scoped to the `external_directory` surface only.
486
+ Other permission surfaces (tools, bash patterns, MCP, skills) always use the standard one-time approval flow.
487
+
473
488
  ### Subagent Permission Forwarding
474
489
 
475
490
  When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories. The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
@@ -514,6 +529,7 @@ This makes it easy to verify which files the extension actually loaded:
514
529
  index.ts → Root Pi entrypoint shim
515
530
  src/
516
531
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
532
+ ├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
517
533
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
518
534
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
519
535
  ├── config-reporter.ts → Resolved config path reporting for diagnostic logs
@@ -27,7 +27,6 @@
27
27
  "*": "ask"
28
28
  },
29
29
  "special": {
30
- "doom_loop": "deny",
31
30
  "external_directory": "ask"
32
31
  }
33
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -60,8 +60,8 @@
60
60
  "default": "ask"
61
61
  },
62
62
  "special": {
63
- "description": "Default permission for special checks (doom_loop, external_directory) when no explicit rule matches.",
64
- "markdownDescription": "Default permission for special checks (`doom_loop`, `external_directory`) when no explicit rule matches.",
63
+ "description": "Default permission for special checks (external_directory) when no explicit rule matches.",
64
+ "markdownDescription": "Default permission for special checks (`external_directory`) when no explicit rule matches.",
65
65
  "$ref": "#/$defs/permissionState",
66
66
  "default": "ask"
67
67
  }
@@ -122,10 +122,6 @@
122
122
  "type": "object",
123
123
  "additionalProperties": false,
124
124
  "properties": {
125
- "doom_loop": {
126
- "description": "Controls doom loop detection behavior.",
127
- "$ref": "#/$defs/permissionState"
128
- },
129
125
  "external_directory": {
130
126
  "description": "Enforces permission checks for path-bearing file tools (read, write, edit, find, grep, ls) when they target paths outside the active working directory.",
131
127
  "markdownDescription": "Enforces permission checks for path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory.\n\nEvaluated **before** the normal tool permission check — so `tools.read: \"allow\"` can permit ordinary reads while `external_directory: \"ask\"` still requires confirmation for paths outside `cwd`.",
@@ -36,6 +36,7 @@ export interface UnifiedConfigLoadResult {
36
36
  }
37
37
 
38
38
  const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
39
+ "doom_loop",
39
40
  "tool_call_limit",
40
41
  ]);
41
42
 
@@ -49,7 +50,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
49
50
  "ls",
50
51
  ]);
51
52
 
52
- const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
53
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
53
54
 
54
55
  export function stripJsonComments(input: string): string {
55
56
  let output = "";
@@ -69,7 +69,6 @@ const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
69
69
  "skills",
70
70
  "special",
71
71
  "external_directory",
72
- "doom_loop",
73
72
  ]);
74
73
 
75
74
  export function detectMisplacedPermissionKeys(
package/src/index.ts CHANGED
@@ -80,6 +80,10 @@ import {
80
80
  formatUnknownToolReason,
81
81
  formatUserDeniedReason,
82
82
  } from "./permission-prompts";
83
+ import {
84
+ deriveApprovalPrefix,
85
+ SessionApprovalCache,
86
+ } from "./session-approval-cache";
83
87
  import {
84
88
  findSkillPathMatch,
85
89
  resolveSkillPromptEntries,
@@ -234,6 +238,7 @@ function createPermissionManagerForCwd(
234
238
 
235
239
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
236
240
  let permissionManager = new PermissionManager();
241
+ const sessionApprovalCache = new SessionApprovalCache();
237
242
  let activeSkillEntries: SkillPromptEntry[] = [];
238
243
  let lastKnownActiveAgentName: string | null = null;
239
244
  let lastActiveToolsCacheKey: string | null = null;
@@ -587,6 +592,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
587
592
  runtimeContext?.ui.setStatus(PERMISSION_SYSTEM_STATUS_KEY, undefined);
588
593
  runtimeContext = null;
589
594
  invalidateAgentStartCache();
595
+ sessionApprovalCache.clear();
590
596
  stopForwardedPermissionPolling();
591
597
  });
592
598
 
@@ -814,60 +820,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
814
820
  externalDirectoryPath &&
815
821
  isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)
816
822
  ) {
817
- const extCheck = permissionManager.checkPermission(
818
- "external_directory",
819
- {},
820
- agentName ?? undefined,
821
- );
822
-
823
- const extDirMessage = formatExternalDirectoryAskPrompt(
824
- toolName,
823
+ const normalizedExtPath = normalizePathForComparison(
825
824
  externalDirectoryPath,
826
825
  ctx.cwd,
827
- agentName ?? undefined,
828
826
  );
829
- const extDirGate = await applyPermissionGate({
830
- state: extCheck.state,
831
- canConfirm: canRequestPermissionConfirmation(ctx),
832
- promptForApproval: () =>
833
- promptPermission(ctx, {
834
- requestId: event.toolCallId,
835
- source: "tool_call",
836
- agentName,
837
- message: extDirMessage,
838
- toolCallId: event.toolCallId,
839
- toolName,
840
- path: externalDirectoryPath,
841
- }),
842
- writeLog: writeReviewLog,
843
- logContext: {
827
+ const sessionPrefix = sessionApprovalCache.findMatchingPrefix(
828
+ "external_directory",
829
+ normalizedExtPath,
830
+ );
831
+
832
+ if (sessionPrefix) {
833
+ writeReviewLog("permission_request.session_approved", {
844
834
  source: "tool_call",
845
835
  toolCallId: event.toolCallId,
846
836
  toolName,
847
837
  agentName,
848
838
  path: externalDirectoryPath,
849
- message: extDirMessage,
850
- },
851
- messages: {
852
- denyReason: formatExternalDirectoryDenyReason(
839
+ resolution: "session_approved",
840
+ sessionApprovalPrefix: sessionPrefix,
841
+ });
842
+ // Fall through to normal permission check
843
+ } else {
844
+ const extCheck = permissionManager.checkPermission(
845
+ "external_directory",
846
+ {},
847
+ agentName ?? undefined,
848
+ );
849
+
850
+ let extDirDecision: PermissionPromptDecision | null = null;
851
+ const extDirMessage = formatExternalDirectoryAskPrompt(
852
+ toolName,
853
+ externalDirectoryPath,
854
+ ctx.cwd,
855
+ agentName ?? undefined,
856
+ );
857
+ const extDirGate = await applyPermissionGate({
858
+ state: extCheck.state,
859
+ canConfirm: canRequestPermissionConfirmation(ctx),
860
+ promptForApproval: async () => {
861
+ const decision = await promptPermission(ctx, {
862
+ requestId: event.toolCallId,
863
+ source: "tool_call",
864
+ agentName,
865
+ message: extDirMessage,
866
+ toolCallId: event.toolCallId,
867
+ toolName,
868
+ path: externalDirectoryPath,
869
+ });
870
+ extDirDecision = decision;
871
+ return decision;
872
+ },
873
+ writeLog: writeReviewLog,
874
+ logContext: {
875
+ source: "tool_call",
876
+ toolCallId: event.toolCallId,
853
877
  toolName,
854
- externalDirectoryPath,
855
- ctx.cwd,
856
- agentName ?? undefined,
857
- ),
858
- unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
859
- userDeniedReason: (decision) =>
860
- formatExternalDirectoryUserDeniedReason(
878
+ agentName,
879
+ path: externalDirectoryPath,
880
+ message: extDirMessage,
881
+ },
882
+ messages: {
883
+ denyReason: formatExternalDirectoryDenyReason(
861
884
  toolName,
862
885
  externalDirectoryPath,
863
- decision.denialReason,
886
+ ctx.cwd,
887
+ agentName ?? undefined,
864
888
  ),
865
- },
866
- });
867
- if (extDirGate.action === "block") {
868
- return { block: true, reason: extDirGate.reason };
889
+ unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
890
+ userDeniedReason: (decision) =>
891
+ formatExternalDirectoryUserDeniedReason(
892
+ toolName,
893
+ externalDirectoryPath,
894
+ decision.denialReason,
895
+ ),
896
+ },
897
+ });
898
+ if (extDirGate.action === "block") {
899
+ return { block: true, reason: extDirGate.reason };
900
+ }
901
+
902
+ if (extDirDecision?.state === "approved_for_session") {
903
+ const prefix = deriveApprovalPrefix(normalizedExtPath);
904
+ sessionApprovalCache.approve("external_directory", prefix);
905
+ }
869
906
  }
870
- // state === "allow" → fall through to normal permission check
907
+ // Fall through to normal permission check
871
908
  }
872
909
 
873
910
  // Bash external directory gate: extract paths from bash commands
@@ -879,61 +916,91 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
879
916
  ctx.cwd,
880
917
  );
881
918
  if (externalPaths.length > 0) {
882
- const extCheck = permissionManager.checkPermission(
883
- "external_directory",
884
- {},
885
- agentName ?? undefined,
919
+ // Filter out paths already covered by session approvals
920
+ const uncoveredPaths = externalPaths.filter(
921
+ (p) => !sessionApprovalCache.has("external_directory", p),
886
922
  );
887
923
 
888
- const bashExtMessage = formatBashExternalDirectoryAskPrompt(
889
- command,
890
- externalPaths,
891
- ctx.cwd,
892
- agentName ?? undefined,
893
- );
894
- const bashExtGate = await applyPermissionGate({
895
- state: extCheck.state,
896
- canConfirm: canRequestPermissionConfirmation(ctx),
897
- promptForApproval: () =>
898
- promptPermission(ctx, {
899
- requestId: event.toolCallId,
900
- source: "tool_call",
901
- agentName,
902
- message: bashExtMessage,
903
- toolCallId: event.toolCallId,
904
- toolName,
905
- command,
906
- }),
907
- writeLog: writeReviewLog,
908
- logContext: {
924
+ if (uncoveredPaths.length === 0) {
925
+ // All external paths are session-approved
926
+ writeReviewLog("permission_request.session_approved", {
909
927
  source: "tool_call",
910
928
  toolCallId: event.toolCallId,
911
929
  toolName,
912
930
  agentName,
913
931
  command,
914
932
  externalPaths,
915
- message: bashExtMessage,
916
- },
917
- messages: {
918
- denyReason: formatBashExternalDirectoryDenyReason(
933
+ resolution: "session_approved",
934
+ });
935
+ // Fall through to normal bash permission check
936
+ } else {
937
+ const extCheck = permissionManager.checkPermission(
938
+ "external_directory",
939
+ {},
940
+ agentName ?? undefined,
941
+ );
942
+
943
+ let bashExtDecision: PermissionPromptDecision | null = null;
944
+ const bashExtMessage = formatBashExternalDirectoryAskPrompt(
945
+ command,
946
+ uncoveredPaths,
947
+ ctx.cwd,
948
+ agentName ?? undefined,
949
+ );
950
+ const bashExtGate = await applyPermissionGate({
951
+ state: extCheck.state,
952
+ canConfirm: canRequestPermissionConfirmation(ctx),
953
+ promptForApproval: async () => {
954
+ const decision = await promptPermission(ctx, {
955
+ requestId: event.toolCallId,
956
+ source: "tool_call",
957
+ agentName,
958
+ message: bashExtMessage,
959
+ toolCallId: event.toolCallId,
960
+ toolName,
961
+ command,
962
+ });
963
+ bashExtDecision = decision;
964
+ return decision;
965
+ },
966
+ writeLog: writeReviewLog,
967
+ logContext: {
968
+ source: "tool_call",
969
+ toolCallId: event.toolCallId,
970
+ toolName,
971
+ agentName,
919
972
  command,
920
- externalPaths,
921
- ctx.cwd,
922
- agentName ?? undefined,
923
- ),
924
- unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
925
- userDeniedReason: (decision) => {
926
- const reasonSuffix = decision.denialReason
927
- ? ` Reason: ${decision.denialReason}.`
928
- : "";
929
- return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
973
+ externalPaths: uncoveredPaths,
974
+ message: bashExtMessage,
930
975
  },
931
- },
932
- });
933
- if (bashExtGate.action === "block") {
934
- return { block: true, reason: bashExtGate.reason };
976
+ messages: {
977
+ denyReason: formatBashExternalDirectoryDenyReason(
978
+ command,
979
+ uncoveredPaths,
980
+ ctx.cwd,
981
+ agentName ?? undefined,
982
+ ),
983
+ unavailableReason: `Bash command '${command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`,
984
+ userDeniedReason: (decision) => {
985
+ const reasonSuffix = decision.denialReason
986
+ ? ` Reason: ${decision.denialReason}.`
987
+ : "";
988
+ return `User denied external directory access for bash command '${command}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
989
+ },
990
+ },
991
+ });
992
+ if (bashExtGate.action === "block") {
993
+ return { block: true, reason: bashExtGate.reason };
994
+ }
995
+
996
+ if (bashExtDecision?.state === "approved_for_session") {
997
+ for (const extPath of uncoveredPaths) {
998
+ const prefix = deriveApprovalPrefix(extPath);
999
+ sessionApprovalCache.approve("external_directory", prefix);
1000
+ }
1001
+ }
935
1002
  }
936
- // state === "allow" → fall through to normal bash permission check
1003
+ // Fall through to normal bash permission check
937
1004
  }
938
1005
  }
939
1006
  }
@@ -1,5 +1,6 @@
1
1
  export type PermissionDecisionState =
2
2
  | "approved"
3
+ | "approved_for_session"
3
4
  | "denied"
4
5
  | "denied_with_reason";
5
6
 
@@ -15,10 +16,12 @@ export interface PermissionDecisionUi {
15
16
  }
16
17
 
17
18
  const APPROVE_OPTION = "Yes";
19
+ const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
18
20
  const DENY_OPTION = "No";
19
21
  const DENY_WITH_REASON_OPTION = "No, provide reason";
20
22
  const PERMISSION_DECISION_OPTIONS = [
21
23
  APPROVE_OPTION,
24
+ APPROVE_FOR_SESSION_OPTION,
22
25
  DENY_OPTION,
23
26
  DENY_WITH_REASON_OPTION,
24
27
  ] as const;
@@ -54,7 +57,10 @@ export function isPermissionDecisionState(
54
57
  value: unknown,
55
58
  ): value is PermissionDecisionState {
56
59
  return (
57
- value === "approved" || value === "denied" || value === "denied_with_reason"
60
+ value === "approved" ||
61
+ value === "approved_for_session" ||
62
+ value === "denied" ||
63
+ value === "denied_with_reason"
58
64
  );
59
65
  }
60
66
 
@@ -74,6 +80,13 @@ export async function requestPermissionDecisionFromUi(
74
80
  };
75
81
  }
76
82
 
83
+ if (selected === APPROVE_FOR_SESSION_OPTION) {
84
+ return {
85
+ approved: true,
86
+ state: "approved_for_session",
87
+ };
88
+ }
89
+
77
90
  if (selected === DENY_WITH_REASON_OPTION) {
78
91
  const denialReason = normalizePermissionDenialReason(
79
92
  await ui.input(
@@ -46,7 +46,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
46
46
  "find",
47
47
  "ls",
48
48
  ]);
49
- const SPECIAL_PERMISSION_KEYS = new Set(["doom_loop", "external_directory"]);
49
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
50
50
  const MCP_BASELINE_TARGETS = new Set([
51
51
  "mcp_status",
52
52
  "mcp_list",
@@ -156,6 +156,7 @@ function getConfiguredMcpServerNamesFromPaths(
156
156
  }
157
157
 
158
158
  const DEPRECATED_SPECIAL_KEYS: ReadonlySet<string> = new Set([
159
+ "doom_loop",
159
160
  "tool_call_limit",
160
161
  ]);
161
162
 
@@ -0,0 +1,81 @@
1
+ import { dirname, sep } from "node:path";
2
+
3
+ import { isPathWithinDirectory } from "./external-directory";
4
+
5
+ /**
6
+ * Ephemeral in-memory cache of session-scoped permission approvals.
7
+ * Keyed by permission surface (e.g. "external_directory"), values are
8
+ * normalized directory prefixes that have been approved for the session.
9
+ *
10
+ * Cleared on session_shutdown — never persisted to disk.
11
+ */
12
+ export class SessionApprovalCache {
13
+ private approvals = new Map<string, Set<string>>();
14
+
15
+ /** Record a directory prefix as approved for the given surface. */
16
+ approve(surface: string, prefix: string): void {
17
+ let prefixes = this.approvals.get(surface);
18
+ if (!prefixes) {
19
+ prefixes = new Set();
20
+ this.approvals.set(surface, prefixes);
21
+ }
22
+ prefixes.add(prefix);
23
+ }
24
+
25
+ /**
26
+ * Check whether a path falls under any approved prefix for the given surface.
27
+ * Uses `isPathWithinDirectory()` for correct separator-aware prefix matching.
28
+ */
29
+ has(surface: string, path: string): boolean {
30
+ const prefixes = this.approvals.get(surface);
31
+ if (!prefixes) {
32
+ return false;
33
+ }
34
+ for (const prefix of prefixes) {
35
+ if (isPathWithinDirectory(path, prefix)) {
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /** Find and return the matching approved prefix, or null if none matches. */
43
+ findMatchingPrefix(surface: string, path: string): string | null {
44
+ const prefixes = this.approvals.get(surface);
45
+ if (!prefixes) {
46
+ return null;
47
+ }
48
+ for (const prefix of prefixes) {
49
+ if (isPathWithinDirectory(path, prefix)) {
50
+ return prefix;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Remove all session approvals. */
57
+ clear(): void {
58
+ this.approvals.clear();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Derive the directory prefix to approve from a normalized path.
64
+ * Returns `dirname(path)` with a trailing separator so that
65
+ * prefix matching via `isPathWithinDirectory()` works correctly.
66
+ *
67
+ * For paths that already end with a separator (directories),
68
+ * the trailing separator is stripped by dirname and re-added.
69
+ */
70
+ export function deriveApprovalPrefix(normalizedPath: string): string {
71
+ // If the path already ends with a separator, it's a directory — return as-is.
72
+ if (normalizedPath.endsWith(sep)) {
73
+ return normalizedPath;
74
+ }
75
+ const dir = dirname(normalizedPath);
76
+ if (dir === normalizedPath) {
77
+ // Root path — dirname('/') === '/'
78
+ return dir;
79
+ }
80
+ return dir.endsWith(sep) ? dir : `${dir}${sep}`;
81
+ }
package/src/types.ts CHANGED
@@ -15,7 +15,7 @@ export type BashPermissions = Record<string, PermissionState>;
15
15
 
16
16
  export type SkillPermissions = Record<string, PermissionState>;
17
17
 
18
- export type SpecialPermissionName = "doom_loop" | "external_directory";
18
+ export type SpecialPermissionName = "external_directory";
19
19
 
20
20
  export type SpecialPermissions = Record<string, PermissionState>;
21
21
 
@@ -133,7 +133,7 @@ describe("loadUnifiedConfig", () => {
133
133
  expect(result.config.bash).toEqual({ "git *": "ask" });
134
134
  });
135
135
 
136
- it("collects deprecated special key issues", () => {
136
+ it("collects deprecated special key issues (doom_loop and tool_call_limit)", () => {
137
137
  const configPath = join(tempDir, "config.json");
138
138
  writeFileSync(
139
139
  configPath,
@@ -143,9 +143,10 @@ describe("loadUnifiedConfig", () => {
143
143
  );
144
144
 
145
145
  const result = loadUnifiedConfig(configPath);
146
- expect(result.issues).toHaveLength(1);
147
- expect(result.issues[0]).toContain("tool_call_limit");
148
- expect(result.config.special).toEqual({ doom_loop: "deny" });
146
+ expect(result.issues).toHaveLength(2);
147
+ expect(result.issues.some((i) => i.includes("doom_loop"))).toBe(true);
148
+ expect(result.issues.some((i) => i.includes("tool_call_limit"))).toBe(true);
149
+ expect(result.config.special).toBeUndefined();
149
150
  });
150
151
  });
151
152
 
@@ -42,7 +42,6 @@ describe("detectMisplacedPermissionKeys", () => {
42
42
  skills: {},
43
43
  special: {},
44
44
  external_directory: {},
45
- doom_loop: {},
46
45
  });
47
46
  expect(result).toEqual([
48
47
  "defaultPolicy",
@@ -52,10 +51,16 @@ describe("detectMisplacedPermissionKeys", () => {
52
51
  "skills",
53
52
  "special",
54
53
  "external_directory",
55
- "doom_loop",
56
54
  ]);
57
55
  });
58
56
 
57
+ it("does not detect doom_loop as a misplaced permission key (deprecated)", () => {
58
+ const result = detectMisplacedPermissionKeys({
59
+ doom_loop: {},
60
+ });
61
+ expect(result).toEqual([]);
62
+ });
63
+
59
64
  it("ignores unknown keys that are not permission-rule keys", () => {
60
65
  const result = detectMisplacedPermissionKeys({
61
66
  debugLog: true,