@gotgenes/pi-permission-system 7.1.4 → 7.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 (41) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +2 -2
  3. package/src/active-agent.ts +1 -1
  4. package/src/bash-arity.ts +1 -0
  5. package/src/config-modal.ts +2 -0
  6. package/src/forwarded-permissions/io.ts +4 -2
  7. package/src/forwarded-permissions/polling.ts +8 -7
  8. package/src/handlers/before-agent-start.ts +7 -6
  9. package/src/handlers/gates/bash-path-extractor.ts +3 -5
  10. package/src/handlers/gates/bash-path.ts +1 -1
  11. package/src/handlers/gates/runner.ts +3 -0
  12. package/src/handlers/lifecycle.ts +9 -8
  13. package/src/handlers/permission-gate-handler.ts +12 -7
  14. package/src/logging.ts +3 -0
  15. package/src/node-modules-discovery.ts +1 -1
  16. package/src/normalize.ts +1 -0
  17. package/src/permission-event-rpc.ts +2 -0
  18. package/src/permission-manager.ts +7 -6
  19. package/src/permission-merge.ts +4 -2
  20. package/src/permission-prompter.ts +3 -0
  21. package/src/permission-prompts.ts +1 -1
  22. package/src/policy-loader.ts +5 -5
  23. package/src/service.ts +1 -0
  24. package/src/skill-prompt-sanitizer.ts +3 -3
  25. package/src/tool-registry.ts +1 -1
  26. package/src/yolo-mode.ts +2 -1
  27. package/test/config-modal.test.ts +6 -8
  28. package/test/handlers/before-agent-start.test.ts +1 -1
  29. package/test/handlers/external-directory-integration.test.ts +1 -1
  30. package/test/handlers/gates/skill-read.test.ts +8 -10
  31. package/test/handlers/gates/tool.test.ts +1 -1
  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 +1 -1
  35. package/test/handlers/tool-call.test.ts +1 -1
  36. package/test/permission-event-rpc.test.ts +1 -0
  37. package/test/permission-events.test.ts +2 -0
  38. package/test/permission-forwarding.test.ts +1 -0
  39. package/test/permission-manager-unified.test.ts +4 -2
  40. package/test/permission-session.test.ts +2 -2
  41. package/test/permission-system.test.ts +8 -8
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ 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
+ ## [7.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.4...pi-permission-system-v7.2.0) (2026-05-24)
9
+
10
+
11
+ ### Features
12
+
13
+ * add eslint config with type-aware rules and import enforcement ([4fb3cc6](https://github.com/gotgenes/pi-packages/commit/4fb3cc678da10d350b85c464318476ba9ae99dca))
14
+
8
15
  ## [7.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.3...pi-permission-system-v7.1.4) (2026-05-23)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.1.4",
3
+ "version": "7.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -77,6 +77,6 @@
77
77
  "test": "vitest run",
78
78
  "test:watch": "vitest",
79
79
  "lint:md": "rumdl check *.md docs/**/*.md",
80
- "lint": "biome check . && pnpm run lint:md"
80
+ "lint": "biome check . && eslint . && pnpm run lint:md"
81
81
  }
82
82
  }
@@ -49,7 +49,7 @@ export function getActiveAgentNameFromSystemPrompt(
49
49
  return null;
50
50
  }
51
51
 
52
- const match = systemPrompt.match(ACTIVE_AGENT_TAG_REGEX);
52
+ const match = ACTIVE_AGENT_TAG_REGEX.exec(systemPrompt);
53
53
  if (!match?.[1]) {
54
54
  return null;
55
55
  }
package/src/bash-arity.ts CHANGED
@@ -175,6 +175,7 @@ export function prefix(tokens: string[]): string[] {
175
175
  .map((t) => t.toLowerCase())
176
176
  .join(" ");
177
177
  const arity = ARITY[key];
178
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ARITY record type hides that a key may be absent at runtime
178
179
  if (arity !== undefined) {
179
180
  return tokens.slice(0, Math.min(arity, tokens.length));
180
181
  }
@@ -61,6 +61,7 @@ function toOnOff(value: boolean): string {
61
61
  }
62
62
 
63
63
  function formatRulesSummary(rules: Ruleset): string {
64
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- origin may be absent despite its type
64
65
  const configRules = rules.filter((r) => r.layer === "config" && r.origin);
65
66
  if (configRules.length === 0) return "";
66
67
  const formatted = configRules
@@ -171,6 +172,7 @@ async function openSettingsModal(
171
172
  margin: 1,
172
173
  };
173
174
 
175
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
174
176
  await ctx.ui.custom<void>(
175
177
  (_tui, _theme, _keybindings, done) => {
176
178
  let current = controller.getConfig();
@@ -9,13 +9,13 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
- import { isPermissionDecisionState } from "../permission-dialog";
12
+ import { isPermissionDecisionState } from "#src/permission-dialog";
13
13
  import {
14
14
  createPermissionForwardingLocation,
15
15
  type ForwardedPermissionRequest,
16
16
  type ForwardedPermissionResponse,
17
17
  type PermissionForwardingLocation,
18
- } from "../permission-forwarding";
18
+ } from "#src/permission-forwarding";
19
19
 
20
20
  type LogFn = (event: string, details: Record<string, unknown>) => void;
21
21
 
@@ -262,6 +262,7 @@ export function readForwardedPermissionRequest(
262
262
  const raw = readFileSync(filePath, "utf-8");
263
263
  const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
264
264
  if (
265
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
265
266
  !parsed ||
266
267
  typeof parsed.id !== "string" ||
267
268
  typeof parsed.createdAt !== "number" ||
@@ -303,6 +304,7 @@ export function readForwardedPermissionResponse(
303
304
  const raw = readFileSync(filePath, "utf-8");
304
305
  const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
305
306
  if (
307
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
306
308
  !parsed ||
307
309
  typeof parsed.approved !== "boolean" ||
308
310
  !isPermissionDecisionState(parsed.state) ||
@@ -5,12 +5,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  getActiveAgentName,
7
7
  getActiveAgentNameFromSystemPrompt,
8
- } from "../active-agent";
9
- import { toRecord } from "../common";
8
+ } from "#src/active-agent";
9
+ import { toRecord } from "#src/common";
10
10
  import type {
11
11
  PermissionPromptDecision,
12
12
  RequestPermissionOptions,
13
- } from "../permission-dialog";
13
+ } from "#src/permission-dialog";
14
14
  import {
15
15
  type ForwardedPermissionRequest,
16
16
  type ForwardedPermissionResponse,
@@ -19,8 +19,8 @@ import {
19
19
  PERMISSION_FORWARDING_TIMEOUT_MS,
20
20
  resolvePermissionForwardingTargetSessionId,
21
21
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
- } from "../permission-forwarding";
23
- import { isSubagentExecutionContext } from "../subagent-context";
22
+ } from "#src/permission-forwarding";
23
+ import { isSubagentExecutionContext } from "#src/subagent-context";
24
24
 
25
25
  import {
26
26
  cleanupPermissionForwardingLocationIfEmpty,
@@ -69,6 +69,7 @@ function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
69
69
  }
70
70
 
71
71
  try {
72
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
72
73
  const systemPrompt = getSystemPrompt.call(ctx);
73
74
  return typeof systemPrompt === "string" ? systemPrompt : undefined;
74
75
  } catch (error) {
@@ -135,8 +136,8 @@ export async function waitForForwardedPermissionApproval(
135
136
 
136
137
  const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
137
138
  const requesterAgentName =
138
- getActiveAgentName(ctx) ||
139
- getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
139
+ getActiveAgentName(ctx) ??
140
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
140
141
  "unknown";
141
142
  const request: ForwardedPermissionRequest = {
142
143
  id: requestId,
@@ -6,12 +6,12 @@ import type {
6
6
  import {
7
7
  createActiveToolsCacheKey,
8
8
  createBeforeAgentStartPromptStateKey,
9
- } from "../before-agent-start-cache";
10
- import type { PermissionSession } from "../permission-session";
11
- import { resolveSkillPromptEntries } from "../skill-prompt-sanitizer";
12
- import { sanitizeAvailableToolsSection } from "../system-prompt-sanitizer";
13
- import { getToolNameFromValue, type ToolRegistry } from "../tool-registry";
14
- import type { PermissionState } from "../types";
9
+ } from "#src/before-agent-start-cache";
10
+ import type { PermissionSession } from "#src/permission-session";
11
+ import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
12
+ import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
13
+ import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
14
+ import type { PermissionState } from "#src/types";
15
15
 
16
16
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
17
17
  interface BeforeAgentStartPayload {
@@ -45,6 +45,7 @@ export class AgentPrepHandler {
45
45
  private readonly toolRegistry: ToolRegistry,
46
46
  ) {}
47
47
 
48
+ // eslint-disable-next-line @typescript-eslint/require-await
48
49
  async handle(
49
50
  event: BeforeAgentStartPayload,
50
51
  ctx: ExtensionContext,
@@ -40,13 +40,11 @@ async function initParser(): Promise<TSParser> {
40
40
  const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
41
41
  const bash = await Language.load(bashWasm);
42
42
  parser.setLanguage(bash);
43
- return parser as TSParser;
43
+ return parser;
44
44
  }
45
45
 
46
46
  function getParser(): Promise<TSParser> {
47
- if (!parserPromise) {
48
- parserPromise = initParser();
49
- }
47
+ parserPromise ??= initParser();
50
48
  return parserPromise;
51
49
  }
52
50
 
@@ -83,7 +81,7 @@ function resolveNodeText(node: TSNode): string {
83
81
  case "raw_string": {
84
82
  // Strip surrounding single quotes: 'content' → content
85
83
  const t = node.text;
86
- if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
84
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
87
85
  return t.slice(1, -1);
88
86
  }
89
87
  return t;
@@ -73,7 +73,7 @@ export async function describeBashPathGate(
73
73
  worstToken = token;
74
74
  break; // Short-circuit on deny.
75
75
  }
76
- if (check.state === "ask" && (!worstCheck || worstCheck.state !== "ask")) {
76
+ if (check.state === "ask" && worstCheck?.state !== "ask") {
77
77
  worstCheck = check;
78
78
  worstToken = token;
79
79
  }
@@ -61,6 +61,7 @@ export async function runGateCheck(
61
61
  value: descriptor.decision.value,
62
62
  result: "allow",
63
63
  resolution: "session_approved",
64
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
64
65
  origin: check.origin ?? null,
65
66
  agentName: agentName ?? null,
66
67
  matchedPattern: check.matchedPattern ?? null,
@@ -107,6 +108,7 @@ export async function runGateCheck(
107
108
  autoApproved = decision.autoApproved === true;
108
109
  return decision;
109
110
  },
111
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain functions; no this-binding issue
110
112
  writeLog: deps.writeReviewLog,
111
113
  logContext: { ...descriptor.logContext, agentName },
112
114
  messages,
@@ -128,6 +130,7 @@ export async function runGateCheck(
128
130
  canConfirm,
129
131
  autoApproved,
130
132
  ),
133
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
131
134
  origin: check.origin ?? null,
132
135
  agentName: agentName ?? null,
133
136
  matchedPattern: check.matchedPattern ?? null,
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import type { PermissionSession } from "../permission-session";
4
- import { PERMISSION_SYSTEM_STATUS_KEY } from "../status";
3
+ import type { PermissionSession } from "#src/permission-session";
4
+ import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
5
5
 
6
6
  /** Minimal subset of SessionStartEvent used by this handler. */
7
7
  interface SessionStartPayload {
@@ -26,7 +26,7 @@ export class SessionLifecycleHandler {
26
26
  private readonly cleanupRpc: () => void,
27
27
  ) {}
28
28
 
29
- async handleSessionStart(
29
+ handleSessionStart(
30
30
  event: SessionStartPayload,
31
31
  ctx: ExtensionContext,
32
32
  ): Promise<void> {
@@ -48,13 +48,12 @@ export class SessionLifecycleHandler {
48
48
  cwd: ctx.cwd,
49
49
  });
50
50
  }
51
+ return Promise.resolve();
51
52
  }
52
53
 
53
- async handleResourcesDiscover(
54
- event: ResourcesDiscoverPayload,
55
- ): Promise<void> {
54
+ handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void> {
56
55
  if (event.reason !== "reload") {
57
- return;
56
+ return Promise.resolve();
58
57
  }
59
58
 
60
59
  const { session } = this;
@@ -64,9 +63,10 @@ export class SessionLifecycleHandler {
64
63
  reason: event.reason,
65
64
  cwd: session.getRuntimeContext()?.cwd ?? null,
66
65
  });
66
+ return Promise.resolve();
67
67
  }
68
68
 
69
- async handleSessionShutdown(): Promise<void> {
69
+ handleSessionShutdown(): Promise<void> {
70
70
  const { session } = this;
71
71
  const ctx = session.getRuntimeContext();
72
72
  if (ctx) {
@@ -74,5 +74,6 @@ export class SessionLifecycleHandler {
74
74
  }
75
75
  session.shutdown();
76
76
  this.cleanupRpc();
77
+ return Promise.resolve();
77
78
  }
78
79
  }
@@ -3,24 +3,24 @@ import type {
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { toRecord } from "../common";
6
+ import { toRecord } from "#src/common";
7
7
  import {
8
8
  emitDecisionEvent,
9
9
  type PermissionEventBus,
10
- } from "../permission-events";
11
- import { applyPermissionGate } from "../permission-gate";
12
- import type { PromptPermissionDetails } from "../permission-prompter";
10
+ } from "#src/permission-events";
11
+ import { applyPermissionGate } from "#src/permission-gate";
12
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
13
13
  import {
14
14
  formatMissingToolNameReason,
15
15
  formatSkillAskPrompt,
16
16
  formatUnknownToolReason,
17
- } from "../permission-prompts";
18
- import type { PermissionSession } from "../permission-session";
17
+ } from "#src/permission-prompts";
18
+ import type { PermissionSession } from "#src/permission-session";
19
19
  import {
20
20
  checkRequestedToolRegistration,
21
21
  getToolNameFromValue,
22
22
  type ToolRegistry,
23
- } from "../tool-registry";
23
+ } from "#src/tool-registry";
24
24
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
25
25
  import { describeBashPathGate } from "./gates/bash-path";
26
26
  import type { GateRunnerDeps } from "./gates/descriptor";
@@ -104,6 +104,7 @@ export class PermissionGateHandler {
104
104
  session.prompt(ctx, details);
105
105
  const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
106
106
  emitDecisionEvent(this.events, e);
107
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
107
108
  const writeReviewLog = session.logger.review;
108
109
  const checkPermission: GateRunnerDeps["checkPermission"] = (
109
110
  surface,
@@ -305,6 +306,7 @@ export class PermissionGateHandler {
305
306
  skillInputAutoApproved = decision.autoApproved === true;
306
307
  return decision;
307
308
  },
309
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
308
310
  writeLog: session.logger.review,
309
311
  logContext: {
310
312
  source: "skill_input",
@@ -324,6 +326,7 @@ export class PermissionGateHandler {
324
326
  surface: "skill",
325
327
  value: skillName,
326
328
  result: skillInputGate.action === "allow" ? "allow" : "deny",
329
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive fallback; TypeScript narrows check.state before the ternary's else branch */
327
330
  resolution:
328
331
  check.state === "allow"
329
332
  ? "policy_allow"
@@ -336,6 +339,8 @@ export class PermissionGateHandler {
336
339
  : skillInputCanConfirm
337
340
  ? "user_denied"
338
341
  : "confirmation_unavailable",
342
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
343
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
339
344
  origin: check.origin ?? null,
340
345
  agentName: agentName ?? null,
341
346
  matchedPattern: check.matchedPattern ?? null,
package/src/logging.ts CHANGED
@@ -21,12 +21,15 @@ export function safeJsonStringify(value: unknown): string | undefined {
21
21
  }
22
22
 
23
23
  if (typeof currentValue === "object" && currentValue !== null) {
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
24
25
  if (seen.has(currentValue)) {
25
26
  return "[Circular]";
26
27
  }
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
27
29
  seen.add(currentValue);
28
30
  }
29
31
 
32
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
30
33
  return currentValue;
31
34
  });
32
35
  }
@@ -40,7 +40,7 @@ function discoverGlobalNodeModulesViaSubprocess(): string | null {
40
40
  timeout: 5000,
41
41
  stdio: ["ignore", "pipe", "ignore"],
42
42
  });
43
- const root = result.stdout?.trim();
43
+ const root = result.stdout.trim();
44
44
  if (result.status === 0 && root && existsSync(root)) {
45
45
  return root;
46
46
  }
package/src/normalize.ts CHANGED
@@ -20,6 +20,7 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
20
20
  if (isPermissionState(value)) {
21
21
  rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
22
22
  }
23
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
23
24
  } else if (typeof value === "object" && value !== null) {
24
25
  for (const [pattern, action] of Object.entries(value)) {
25
26
  if (isPermissionState(action)) {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- this module implements the deprecated event-bus RPC channel; references to its own deprecated symbols are intentional */
1
2
  /**
2
3
  * Permission event bus RPC handlers.
3
4
  *
@@ -112,6 +113,7 @@ function handleCheckRpc(
112
113
  const data: PermissionsCheckReplyData = {
113
114
  result: result.state,
114
115
  matchedPattern: result.matchedPattern ?? null,
116
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the reply record
115
117
  origin: result.origin ?? null,
116
118
  };
117
119
  events.emit(replyChannel, successReply(data));
@@ -78,7 +78,7 @@ export class PermissionManager {
78
78
  }
79
79
 
80
80
  private resolvePermissions(agentName?: string): ResolvedPermissions {
81
- const cacheKey = agentName || "__global__";
81
+ const cacheKey = agentName ?? "__global__";
82
82
  const stamp = this.loader.getCacheStamp(agentName);
83
83
  const cached = this.resolvedPermissionsCache.get(cacheKey);
84
84
  if (cached?.stamp === stamp) {
@@ -107,17 +107,19 @@ export class PermissionManager {
107
107
 
108
108
  for (const [surface, value] of Object.entries(scope.permission)) {
109
109
  const baseVal = mergedPermission[surface];
110
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
110
111
  const bothObjects =
111
112
  typeof baseVal === "object" &&
112
113
  baseVal !== null &&
113
114
  typeof value === "object" &&
114
115
  value !== null;
116
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
115
117
 
116
118
  if (bothObjects) {
117
119
  // Shallow-merge: each incoming pattern is attributed to this scope;
118
120
  // existing patterns from lower scopes keep their earlier origin.
119
121
  if (!origins.has(surface)) origins.set(surface, new Map());
120
- for (const pattern of Object.keys(value as Record<string, unknown>)) {
122
+ for (const pattern of Object.keys(value)) {
121
123
  origins.get(surface)!.set(pattern, scopeName);
122
124
  }
123
125
  } else {
@@ -125,10 +127,9 @@ export class PermissionManager {
125
127
  const surfaceOrigins = new Map<string, RuleOrigin>();
126
128
  if (typeof value === "string") {
127
129
  surfaceOrigins.set("*", scopeName);
130
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
128
131
  } else if (typeof value === "object" && value !== null) {
129
- for (const pattern of Object.keys(
130
- value as Record<string, unknown>,
131
- )) {
132
+ for (const pattern of Object.keys(value)) {
132
133
  surfaceOrigins.set(pattern, scopeName);
133
134
  }
134
135
  }
@@ -146,7 +147,7 @@ export class PermissionManager {
146
147
  // The "*" key feeds synthesizeDefaults() only — it is NOT included as a
147
148
  // config rule so that extension tools fall through to source:"default".
148
149
  const universalFallback = isPermissionState(mergedPermission["*"])
149
- ? (mergedPermission["*"] as PermissionState)
150
+ ? mergedPermission["*"]
150
151
  : DEFAULT_UNIVERSAL_FALLBACK;
151
152
  // Track which scope contributed the universal fallback.
152
153
  const universalFallbackOrigin: RuleOrigin =
@@ -12,15 +12,17 @@ export function mergeFlatPermissions(
12
12
  const merged: FlatPermissionConfig = { ...base };
13
13
  for (const [key, value] of Object.entries(override)) {
14
14
  const baseVal = merged[key];
15
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
15
16
  if (
16
17
  typeof baseVal === "object" &&
17
18
  baseVal !== null &&
18
19
  typeof value === "object" &&
19
20
  value !== null
20
21
  ) {
22
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
21
23
  merged[key] = {
22
- ...(baseVal as Record<string, PermissionState>),
23
- ...(value as Record<string, PermissionState>),
24
+ ...baseVal,
25
+ ...value,
24
26
  };
25
27
  } else {
26
28
  merged[key] = value;
@@ -148,6 +148,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
148
148
  private buildForwardingDeps(): PermissionForwardingDeps {
149
149
  const { deps } = this;
150
150
  const logger: ForwardedPermissionLogger = {
151
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
151
152
  writeReviewLog: deps.writeReviewLog,
152
153
  writeDebugLog: () => undefined,
153
154
  };
@@ -155,7 +156,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
155
156
  forwardingDir: deps.forwardingDir,
156
157
  subagentSessionsDir: deps.subagentSessionsDir,
157
158
  logger,
159
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
158
160
  writeReviewLog: deps.writeReviewLog,
161
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
159
162
  requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
160
163
  shouldAutoApprove: () => false,
161
164
  };
@@ -38,7 +38,7 @@ export function formatAskPrompt(
38
38
  const patternInfo = result.matchedPattern
39
39
  ? ` (matched '${result.matchedPattern}')`
40
40
  : "";
41
- return `${subject} requested bash command '${result.command || ""}'${patternInfo}. Allow this command?`;
41
+ return `${subject} requested bash command '${result.command ?? ""}'${patternInfo}. Allow this command?`;
42
42
  }
43
43
 
44
44
  if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
@@ -169,12 +169,12 @@ export class FilePolicyLoader implements PolicyLoader {
169
169
 
170
170
  constructor(options: PolicyLoaderOptions = {}) {
171
171
  this.globalConfigPath =
172
- options.globalConfigPath || defaultGlobalConfigPath();
173
- this.agentsDir = options.agentsDir || defaultAgentsDir();
174
- this.projectGlobalConfigPath = options.projectGlobalConfigPath || null;
175
- this.projectAgentsDir = options.projectAgentsDir || null;
172
+ options.globalConfigPath ?? defaultGlobalConfigPath();
173
+ this.agentsDir = options.agentsDir ?? defaultAgentsDir();
174
+ this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
175
+ this.projectAgentsDir = options.projectAgentsDir ?? null;
176
176
  this.globalMcpConfigPath =
177
- options.globalMcpConfigPath || defaultGlobalMcpConfigPath();
177
+ options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
178
178
  this.configuredMcpServerNamesOverride = options.mcpServerNames
179
179
  ? [
180
180
  ...new Set(
package/src/service.ts CHANGED
@@ -71,5 +71,6 @@ export function getPermissionsService(): PermissionsService | undefined {
71
71
  * extension is torn down.
72
72
  */
73
73
  export function unpublishPermissionsService(): void {
74
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
74
75
  delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
75
76
  }
@@ -70,9 +70,9 @@ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
70
70
 
71
71
  for (const match of sectionBody.matchAll(skillBlockRegex)) {
72
72
  const block = match[1];
73
- const nameMatch = block.match(SKILL_NAME_REGEX);
74
- const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
75
- const locationMatch = block.match(SKILL_LOCATION_REGEX);
73
+ const nameMatch = SKILL_NAME_REGEX.exec(block);
74
+ const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block);
75
+ const locationMatch = SKILL_LOCATION_REGEX.exec(block);
76
76
 
77
77
  if (!nameMatch || !descriptionMatch || !locationMatch) {
78
78
  continue;
@@ -35,7 +35,7 @@ function buildReverseAliases(
35
35
  const reverse = new Map<string, string[]>();
36
36
 
37
37
  for (const [alias, canonical] of Object.entries(aliases)) {
38
- const existing = reverse.get(canonical) || [];
38
+ const existing = reverse.get(canonical) ?? [];
39
39
  if (!existing.includes(alias)) {
40
40
  existing.push(alias);
41
41
  }
package/src/yolo-mode.ts CHANGED
@@ -10,7 +10,8 @@ export interface AskPermissionResolutionOptions {
10
10
  export function isYoloModeEnabled(
11
11
  config: PermissionSystemExtensionConfig,
12
12
  ): boolean {
13
- return config.yoloMode === true;
13
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- typed as boolean but may be undefined at runtime (untyped callers); Boolean() guards against that
14
+ return Boolean(config.yoloMode);
14
15
  }
15
16
 
16
17
  export function shouldAutoApprovePermissionState(
@@ -68,7 +68,7 @@ function createCommandContext(hasUI: boolean): {
68
68
  }
69
69
 
70
70
  function lastNotification(notifications: Notification[]): Notification {
71
- return notifications[notifications.length - 1] as Notification;
71
+ return notifications[notifications.length - 1];
72
72
  }
73
73
 
74
74
  test("permission-system command completions expose top-level config actions", () => {
@@ -101,7 +101,7 @@ test("permission-system command completions expose top-level config actions", ()
101
101
  definition = nextDefinition;
102
102
  },
103
103
  } as never,
104
- controller as never,
104
+ controller,
105
105
  );
106
106
 
107
107
  expect(definition!.getArgumentCompletions).toBeTypeOf("function");
@@ -172,13 +172,11 @@ test("permission-system command handlers manage config summary, persistence, and
172
172
  definition = nextDefinition;
173
173
  },
174
174
  } as never,
175
- controller as never,
175
+ controller,
176
176
  );
177
177
 
178
178
  expect(registeredName).toBe("permission-system");
179
- expect(definition!.description ?? "").toContain(
180
- "Configure pi-permission-system",
181
- );
179
+ expect(definition!.description).toContain("Configure pi-permission-system");
182
180
 
183
181
  const infoCtx = createCommandContext(true);
184
182
  await definition!.handler("show", infoCtx.ctx);
@@ -267,7 +265,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
267
265
  definition = nextDef;
268
266
  },
269
267
  } as never,
270
- controller as never,
268
+ controller,
271
269
  );
272
270
 
273
271
  const ctx = createCommandContext(true);
@@ -300,7 +298,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
300
298
  definition = nextDef;
301
299
  },
302
300
  } as never,
303
- controller as never,
301
+ controller,
304
302
  );
305
303
 
306
304
  const ctx = createCommandContext(true);
@@ -52,7 +52,7 @@ function makeSession(
52
52
  activate: vi.fn(),
53
53
  refreshConfig: vi.fn(),
54
54
  resolveAgentName: vi.fn().mockReturnValue(null),
55
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
55
+ getToolPermission: vi.fn().mockReturnValue("allow"),
56
56
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
57
57
  shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
58
58
  commitActiveToolsCacheKey: vi.fn(),
@@ -110,7 +110,7 @@ function makeSession(
110
110
  activate: vi.fn(),
111
111
  resolveAgentName: vi.fn().mockReturnValue(null),
112
112
  checkPermission: makeCheckPermission("deny"),
113
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
113
+ getToolPermission: vi.fn().mockReturnValue("allow"),
114
114
  getSessionRuleset: vi.fn().mockReturnValue([]),
115
115
  approveSessionRule: vi.fn(),
116
116
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -74,7 +74,7 @@ describe("describeSkillReadGate", () => {
74
74
  makeSkillEntry({ state: "ask" }),
75
75
  ]);
76
76
  expect(result).not.toBeNull();
77
- const desc = result as GateDescriptor;
77
+ const desc = result!;
78
78
  expect(desc.preResolved).toEqual({ state: "ask" });
79
79
  });
80
80
 
@@ -83,7 +83,7 @@ describe("describeSkillReadGate", () => {
83
83
  makeSkillEntry({ state: "allow" }),
84
84
  ]);
85
85
  expect(result).not.toBeNull();
86
- const desc = result as GateDescriptor;
86
+ const desc = result!;
87
87
  expect(desc.preResolved).toEqual({ state: "allow" });
88
88
  });
89
89
 
@@ -92,14 +92,14 @@ describe("describeSkillReadGate", () => {
92
92
  makeSkillEntry({ state: "deny" }),
93
93
  ]);
94
94
  expect(result).not.toBeNull();
95
- const desc = result as GateDescriptor;
95
+ const desc = result!;
96
96
  expect(desc.preResolved).toEqual({ state: "deny" });
97
97
  });
98
98
 
99
99
  it("decision surface is 'skill' and decision value is the skill name", () => {
100
100
  const result = describeSkillReadGate(makeTcc(), () => [
101
101
  makeSkillEntry({ name: "my-skill" }),
102
- ]) as GateDescriptor;
102
+ ])!;
103
103
  expect(result.decision.surface).toBe("skill");
104
104
  expect(result.decision.value).toBe("my-skill");
105
105
  });
@@ -107,7 +107,7 @@ describe("describeSkillReadGate", () => {
107
107
  it("denialContext contains the skill name and read path", () => {
108
108
  const result = describeSkillReadGate(makeTcc(), () => [
109
109
  makeSkillEntry({ name: "librarian" }),
110
- ]) as GateDescriptor;
110
+ ])!;
111
111
  expect(result.denialContext).toEqual({
112
112
  kind: "skill_read",
113
113
  skillName: "librarian",
@@ -120,7 +120,7 @@ describe("describeSkillReadGate", () => {
120
120
  const result = describeSkillReadGate(
121
121
  makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
122
122
  () => [makeSkillEntry({ name: "my-skill" })],
123
- ) as GateDescriptor;
123
+ )!;
124
124
  expect(result.promptDetails).toMatchObject({
125
125
  source: "skill_read",
126
126
  agentName: "test-agent",
@@ -135,7 +135,7 @@ describe("describeSkillReadGate", () => {
135
135
  const result = describeSkillReadGate(
136
136
  makeTcc({ agentName: "agent-1" }),
137
137
  () => [makeSkillEntry({ name: "librarian" })],
138
- ) as GateDescriptor;
138
+ )!;
139
139
  expect(result.logContext).toMatchObject({
140
140
  source: "skill_read",
141
141
  skillName: "librarian",
@@ -144,9 +144,7 @@ describe("describeSkillReadGate", () => {
144
144
  });
145
145
 
146
146
  it("surface is 'skill' on the descriptor", () => {
147
- const result = describeSkillReadGate(makeTcc(), () => [
148
- makeSkillEntry(),
149
- ]) as GateDescriptor;
147
+ const result = describeSkillReadGate(makeTcc(), () => [makeSkillEntry()])!;
150
148
  expect(result.surface).toBe("skill");
151
149
  });
152
150
  });
@@ -93,7 +93,7 @@ describe("describeToolGate", () => {
93
93
  it("populates denialContext with agent name when provided", () => {
94
94
  const check = makeCheckResult("ask", { toolName: "read" });
95
95
  const desc = describeToolGate(makeTcc({ agentName: "my-agent" }), check);
96
- expect(desc.denialContext!.agentName).toBe("my-agent");
96
+ expect(desc.denialContext.agentName).toBe("my-agent");
97
97
  });
98
98
 
99
99
  it("populates denialContext with input for tool context", () => {
@@ -54,7 +54,7 @@ function makeSession(
54
54
  origin: "global",
55
55
  matchedPattern: "*",
56
56
  }),
57
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
57
+ getToolPermission: vi.fn().mockReturnValue("allow"),
58
58
  getSessionRuleset: vi.fn().mockReturnValue([]),
59
59
  approveSessionRule: vi.fn(),
60
60
  canPrompt: vi.fn().mockReturnValue(true),
@@ -42,7 +42,7 @@ function makeSession(
42
42
  activate: vi.fn(),
43
43
  resolveAgentName: vi.fn().mockReturnValue(null),
44
44
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
45
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
45
+ getToolPermission: vi.fn().mockReturnValue("allow"),
46
46
  getSessionRuleset: vi.fn().mockReturnValue([]),
47
47
  approveSessionRule: vi.fn(),
48
48
  canPrompt: vi.fn().mockReturnValue(true),
@@ -75,7 +75,7 @@ function makeSession(
75
75
  activate: vi.fn(),
76
76
  resolveAgentName: vi.fn().mockReturnValue(null),
77
77
  checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
78
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
78
+ getToolPermission: vi.fn().mockReturnValue("allow"),
79
79
  getSessionRuleset: vi.fn().mockReturnValue([]),
80
80
  approveSessionRule: vi.fn(),
81
81
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -66,7 +66,7 @@ function makeSession(
66
66
  activate: vi.fn(),
67
67
  resolveAgentName: vi.fn().mockReturnValue(null),
68
68
  checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
69
- getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
69
+ getToolPermission: vi.fn().mockReturnValue("allow"),
70
70
  getSessionRuleset: vi.fn().mockReturnValue([]),
71
71
  approveSessionRule: vi.fn(),
72
72
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- tests the deprecated RPC channel implementation */
1
2
  import { createEventBus } from "@earendil-works/pi-coding-agent";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- tests the deprecated RPC channel implementation */
1
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { dirname, join } from "node:path";
@@ -164,6 +165,7 @@ describe("type shapes (PermissionsRpcReply)", () => {
164
165
  error: "no_ui",
165
166
  };
166
167
  expect(reply.success).toBe(false);
168
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- narrowing on discriminated union
167
169
  if (!reply.success) {
168
170
  expect(reply.error).toBe("no_ui");
169
171
  }
@@ -24,6 +24,7 @@ describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
24
24
  });
25
25
 
26
26
  test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
27
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- test verifying the deprecated alias
27
28
  expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
28
29
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
29
30
  );
@@ -685,10 +685,12 @@ function createInMemoryPolicyLoader(
685
685
  ): PolicyLoader {
686
686
  const issues: string[] = [];
687
687
  return {
688
- loadGlobalConfig: () => scopes.global ?? {},
689
- loadProjectConfig: () => scopes.project ?? {},
688
+ loadGlobalConfig: () => scopes.global ?? ({} as const),
689
+ loadProjectConfig: () => scopes.project ?? ({} as const),
690
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
690
691
  loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
691
692
  loadProjectAgentConfig: (name?: string) =>
693
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
692
694
  (name && scopes.projectAgent?.[name]) || {},
693
695
  getConfiguredMcpServerNames: () => mcpServerNames,
694
696
  getCacheStamp: () => "in-memory",
@@ -122,7 +122,7 @@ function makePermissionManager(
122
122
  toolName: "read",
123
123
  source: "tool",
124
124
  origin: "builtin",
125
- } as PermissionCheckResult),
125
+ }),
126
126
  getToolPermission: vi.fn().mockReturnValue("allow"),
127
127
  getConfigIssues: vi.fn().mockReturnValue([]),
128
128
  getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
@@ -260,7 +260,7 @@ describe("PermissionSession", () => {
260
260
  toolName: "bash",
261
261
  source: "bash",
262
262
  origin: "global",
263
- } as PermissionCheckResult),
263
+ }),
264
264
  });
265
265
  mockCreatePermissionManagerForCwd.mockReturnValue(pm2);
266
266
  const { session } = createSession();
@@ -112,6 +112,7 @@ type ExtensionHarnessOptions = {
112
112
 
113
113
  const INHERITED_SUBAGENT_ENV_KEYS = [
114
114
  ...SUBAGENT_ENV_HINT_KEYS,
115
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- test uses deprecated alias intentionally
115
116
  SUBAGENT_PARENT_SESSION_ENV_KEY,
116
117
  ] as const;
117
118
 
@@ -121,6 +122,7 @@ async function withIsolatedSubagentEnv<T>(
121
122
  const originalValues = new Map<string, string | undefined>();
122
123
  for (const key of INHERITED_SUBAGENT_ENV_KEYS) {
123
124
  originalValues.set(key, process.env[key]);
125
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
124
126
  delete process.env[key];
125
127
  }
126
128
 
@@ -129,6 +131,7 @@ async function withIsolatedSubagentEnv<T>(
129
131
  } finally {
130
132
  for (const [key, value] of originalValues.entries()) {
131
133
  if (value === undefined) {
134
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
132
135
  delete process.env[key];
133
136
  } else {
134
137
  process.env[key] = value;
@@ -143,7 +146,7 @@ function createToolCallHarness(
143
146
  options: ExtensionHarnessOptions = {},
144
147
  ): ExtensionHarness {
145
148
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
146
- const cwd = options.cwd || baseDir;
149
+ const cwd = options.cwd ?? baseDir;
147
150
  const prompts: string[] = [];
148
151
  const handlers: Record<string, MockHandler> = {};
149
152
  const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
@@ -188,10 +191,7 @@ function createToolCallHarness(
188
191
  prompts,
189
192
  cleanup: async (): Promise<void> => {
190
193
  await Promise.resolve(
191
- handlers.session_shutdown?.(
192
- {},
193
- createMockContext(cwd, prompts, options),
194
- ),
194
+ handlers.session_shutdown({}, createMockContext(cwd, prompts, options)),
195
195
  );
196
196
  rmSync(baseDir, { recursive: true, force: true });
197
197
  },
@@ -236,7 +236,7 @@ async function runToolCall(
236
236
  handler(event, createMockContext(harness.cwd, harness.prompts, options)),
237
237
  ),
238
238
  );
239
- return (result ?? {}) as Record<string, unknown>;
239
+ return result ?? {};
240
240
  }
241
241
 
242
242
  test("Yolo mode only auto-approves ask-state permissions", () => {
@@ -1515,7 +1515,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
1515
1515
 
1516
1516
  expect(result.prompt).not.toContain("denied-skill");
1517
1517
  expect(result.prompt).toContain("visible-skill");
1518
- expect((result.prompt.match(/<available_skills>/g) || []).length).toBe(1);
1518
+ expect((result.prompt.match(/<available_skills>/g) ?? []).length).toBe(1);
1519
1519
  expect(result.entries.map((entry) => entry.name)).toEqual([
1520
1520
  "visible-skill",
1521
1521
  ]);
@@ -2371,7 +2371,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
2371
2371
  hasUI: true,
2372
2372
  selectResponse: "Yes",
2373
2373
  });
2374
- await Promise.resolve(harness.handlers.session_shutdown?.({}, shutdownCtx));
2374
+ await Promise.resolve(harness.handlers.session_shutdown({}, shutdownCtx));
2375
2375
 
2376
2376
  // Access same path again — should prompt because cache was cleared
2377
2377
  const result = await runToolCall(