@gotgenes/pi-permission-system 10.5.0 → 10.5.2

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 (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/handlers/before-agent-start.ts +11 -6
  4. package/src/handlers/lifecycle.ts +7 -4
  5. package/src/handlers/permission-gate-handler.ts +3 -3
  6. package/src/index.ts +8 -3
  7. package/src/input-normalizer.ts +20 -8
  8. package/src/path-utils.ts +1 -10
  9. package/src/permission-resolver.ts +0 -3
  10. package/src/permission-session.ts +8 -52
  11. package/src/session-rules.ts +3 -2
  12. package/src/skill-prompt-sanitizer.ts +1 -1
  13. package/test/before-agent-start-cache.test.ts +89 -0
  14. package/test/handlers/before-agent-start.test.ts +56 -86
  15. package/test/handlers/external-directory-session-dedup.test.ts +175 -159
  16. package/test/handlers/gates/bash-path.test.ts +57 -0
  17. package/test/handlers/gates/path.test.ts +58 -0
  18. package/test/handlers/input.test.ts +5 -4
  19. package/test/handlers/lifecycle.test.ts +79 -85
  20. package/test/handlers/tool-call.test.ts +106 -2
  21. package/test/helpers/handler-fixtures.ts +99 -102
  22. package/test/helpers/manager-harness.ts +61 -0
  23. package/test/helpers/session-fixtures.ts +192 -0
  24. package/test/input-normalizer.test.ts +77 -1
  25. package/test/logging.test.ts +51 -0
  26. package/test/path-utils.test.ts +10 -0
  27. package/test/permission-forwarding.test.ts +73 -0
  28. package/test/permission-manager-unified.test.ts +1577 -3
  29. package/test/permission-resolver.test.ts +3 -1
  30. package/test/permission-session.test.ts +14 -198
  31. package/test/session-rules.test.ts +13 -5
  32. package/test/skill-prompt-sanitizer.test.ts +130 -0
  33. package/test/status.test.ts +10 -0
  34. package/test/system-prompt-sanitizer.test.ts +68 -0
  35. package/test/tool-registry.test.ts +42 -0
  36. package/test/yolo-mode.test.ts +78 -0
  37. package/src/agent-prep-session.ts +0 -28
  38. package/src/gate-handler-session.ts +0 -13
  39. package/src/session-lifecycle-session.ts +0 -24
  40. package/test/permission-system.test.ts +0 -2785
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ 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
+ ## [10.5.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.1...pi-permission-system-v10.5.2) (2026-06-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** expand $HOME in normalizePathForComparison ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([1b92ed3](https://github.com/gotgenes/pi-packages/commit/1b92ed3d2364174d3287171c58ce8452239b3e8d))
14
+ * **pi-permission-system:** home-expand path values before matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([48a7b37](https://github.com/gotgenes/pi-packages/commit/48a7b3783857b449442d30edefe04f8255e5f4f8))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** note path values are home-expanded for matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([e9c264d](https://github.com/gotgenes/pi-packages/commit/e9c264de85d327a0bfbcd84401a259cb509a5dfa))
20
+
21
+ ## [10.5.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.0...pi-permission-system-v10.5.1) (2026-06-07)
22
+
23
+
24
+ ### Documentation
25
+
26
+ * correct SkillPermissionChecker comment after resolver rewire ([#341](https://github.com/gotgenes/pi-packages/issues/341)) ([1528382](https://github.com/gotgenes/pi-packages/commit/15283820a920fead92b348410828332b69f0a0d9))
27
+
8
28
  ## [10.5.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.4.0...pi-permission-system-v10.5.0) (2026-06-07)
9
29
 
10
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.5.0",
3
+ "version": "10.5.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,11 +2,12 @@ import type {
2
2
  BeforeAgentStartEventResult,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import type { AgentPrepSession } from "#src/agent-prep-session";
6
5
  import {
7
6
  createActiveToolsCacheKey,
8
7
  createBeforeAgentStartPromptStateKey,
9
8
  } from "#src/before-agent-start-cache";
9
+ import type { PermissionResolver } from "#src/permission-resolver";
10
+ import type { PermissionSession } from "#src/permission-session";
10
11
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
11
12
  import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
12
13
  import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
@@ -35,12 +36,14 @@ export function shouldExposeTool(
35
36
  * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
36
37
  *
37
38
  * Constructor deps:
38
- * - `session` — encapsulates all mutable session state
39
+ * - `session` — encapsulates all mutable session state and lifecycle operations
40
+ * - `resolver` — owns permission-query surface: `getToolPermission`, `getPolicyCacheStamp`, skill check
39
41
  * - `toolRegistry` — Pi tool API subset (getAll + setActive)
40
42
  */
41
43
  export class AgentPrepHandler {
42
44
  constructor(
43
- private readonly session: AgentPrepSession,
45
+ private readonly session: PermissionSession,
46
+ private readonly resolver: PermissionResolver,
44
47
  private readonly toolRegistry: ToolRegistry,
45
48
  ) {}
46
49
 
@@ -63,7 +66,7 @@ export class AgentPrepHandler {
63
66
  }
64
67
  if (
65
68
  shouldExposeTool(toolName, agentName, (t, a) =>
66
- this.session.getToolPermission(t, a),
69
+ this.resolver.getToolPermission(t, a),
67
70
  )
68
71
  ) {
69
72
  allowedTools.push(toolName);
@@ -79,7 +82,9 @@ export class AgentPrepHandler {
79
82
  const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
80
83
  agentName,
81
84
  cwd: ctx.cwd,
82
- permissionStamp: this.session.getPolicyCacheStamp(agentName ?? undefined),
85
+ permissionStamp: this.resolver.getPolicyCacheStamp(
86
+ agentName ?? undefined,
87
+ ),
83
88
  systemPrompt: event.systemPrompt,
84
89
  allowedToolNames: allowedTools,
85
90
  });
@@ -96,7 +101,7 @@ export class AgentPrepHandler {
96
101
  );
97
102
  const skillPromptResult = resolveSkillPromptEntries(
98
103
  toolPromptResult.prompt,
99
- this.session,
104
+ this.resolver,
100
105
  agentName,
101
106
  ctx.cwd,
102
107
  );
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import type { PermissionResolver } from "#src/permission-resolver";
4
+ import type { PermissionSession } from "#src/permission-session";
3
5
  import type { ServiceLifecycle } from "#src/service-lifecycle";
4
- import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
5
6
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
6
7
 
7
8
  /** Minimal subset of SessionStartEvent used by this handler. */
@@ -18,14 +19,16 @@ interface ResourcesDiscoverPayload {
18
19
  * Handles session lifecycle events: start, reload, and shutdown.
19
20
  *
20
21
  * Constructor deps:
21
- * - `session` — encapsulates all mutable session state
22
+ * - `session` — encapsulates all mutable session state and lifecycle operations
23
+ * - `resolver` — owns permission-query surface: `getConfigIssues`
22
24
  * - `serviceLifecycle` — owns the process-global service publication;
23
25
  * `activate` publishes (skipped for registered subagent children) and emits
24
26
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
25
27
  */
26
28
  export class SessionLifecycleHandler {
27
29
  constructor(
28
- private readonly session: SessionLifecycleSession,
30
+ private readonly session: PermissionSession,
31
+ private readonly resolver: PermissionResolver,
29
32
  private readonly serviceLifecycle: ServiceLifecycle,
30
33
  ) {}
31
34
 
@@ -38,7 +41,7 @@ export class SessionLifecycleHandler {
38
41
  this.session.logResolvedConfigPaths();
39
42
 
40
43
  const agentName = this.session.resolveAgentName(ctx);
41
- const policyIssues = this.session.getConfigIssues(agentName ?? undefined);
44
+ const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
42
45
  for (const issue of policyIssues) {
43
46
  this.session.logger.warn(issue);
44
47
  }
@@ -4,11 +4,11 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import { toRecord } from "#src/common";
7
- import type { GateHandlerSession } from "#src/gate-handler-session";
8
7
  import {
9
8
  formatMissingToolNameReason,
10
9
  formatUnknownToolReason,
11
10
  } from "#src/permission-prompts";
11
+ import type { PermissionSession } from "#src/permission-session";
12
12
  import {
13
13
  checkRequestedToolRegistration,
14
14
  getToolNameFromValue,
@@ -31,7 +31,7 @@ interface InputPayload {
31
31
  * Handles permission gate events: tool_call and input.
32
32
  *
33
33
  * Constructor deps:
34
- * - `session` — narrow two-method context role: bind per-event context, resolve agent name
34
+ * - `session` — state/lifecycle owner: bind per-event context, resolve agent name
35
35
  * - `toolRegistry` — Pi tool API subset (getAll + setActive)
36
36
  * - `pipeline` — owns tool-call gate-producer assembly and the run loop
37
37
  * - `skillInputPipeline` — owns skill-input gate assembly (pre-check, notify, run)
@@ -39,7 +39,7 @@ interface InputPayload {
39
39
  */
40
40
  export class PermissionGateHandler {
41
41
  constructor(
42
- private readonly session: GateHandlerSession,
42
+ private readonly session: PermissionSession,
43
43
  private readonly toolRegistry: ToolRegistry,
44
44
  private readonly pipeline: ToolCallGatePipeline,
45
45
  private readonly skillInputPipeline: SkillInputGatePipeline,
package/src/index.ts CHANGED
@@ -157,12 +157,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
157
157
  setActive: (names: string[]) => pi.setActiveTools(names),
158
158
  };
159
159
 
160
- const lifecycle = new SessionLifecycleHandler(session, serviceLifecycle);
161
- const agentPrep = new AgentPrepHandler(session, toolRegistry);
162
160
  const resolver = new PermissionResolver(permissionManager, sessionRules);
163
161
 
162
+ const lifecycle = new SessionLifecycleHandler(
163
+ session,
164
+ resolver,
165
+ serviceLifecycle,
166
+ );
167
+ const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
168
+
164
169
  const reporter = new GateDecisionReporter(session.logger, pi.events);
165
- const gateRunner = new GateRunner(resolver, session, gateway, reporter);
170
+ const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
166
171
  const toolCallGatePipeline = new ToolCallGatePipeline(
167
172
  resolver,
168
173
  session,
@@ -1,6 +1,7 @@
1
- import { toRecord } from "./common";
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+ import { expandHomePath } from "./expand-home";
2
3
  import { createMcpPermissionTargets } from "./mcp-targets";
3
- import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "./path-utils";
4
+ import { PATH_BEARING_TOOLS } from "./path-utils";
4
5
 
5
6
  /**
6
7
  * Construct a surface-appropriate input object from a raw value string.
@@ -66,13 +67,11 @@ export function normalizeInput(
66
67
  input: unknown,
67
68
  configuredMcpServerNames: readonly string[],
68
69
  ): NormalizedInput {
69
- // --- Special surfaces (external_directory) ---
70
+ // --- Special surfaces (path, external_directory) ---
70
71
  if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
71
- const record = toRecord(input);
72
- const pathValue = typeof record.path === "string" ? record.path : null;
73
72
  return {
74
73
  surface: toolName,
75
- values: [pathValue ?? "*"],
74
+ values: [normalizePathSurfaceValue(input)],
76
75
  resultExtras: {},
77
76
  };
78
77
  }
@@ -116,10 +115,9 @@ export function normalizeInput(
116
115
 
117
116
  // --- Path-bearing tools (read, write, edit, grep, find, ls) ---
118
117
  if (PATH_BEARING_TOOLS.has(toolName)) {
119
- const path = getPathBearingToolPath(toolName, input);
120
118
  return {
121
119
  surface: toolName,
122
- values: [path ?? "*"],
120
+ values: [normalizePathSurfaceValue(input)],
123
121
  resultExtras: {},
124
122
  };
125
123
  }
@@ -131,3 +129,17 @@ export function normalizeInput(
131
129
  resultExtras: {},
132
130
  };
133
131
  }
132
+
133
+ /**
134
+ * Extract and home-expand the `input.path` lookup value shared by every path
135
+ * surface (`path`, `external_directory`, and the path-bearing tools).
136
+ *
137
+ * Missing, empty, or whitespace-only paths collapse to the surface catch-all
138
+ * `"*"`; otherwise `~/…` and `$HOME/…` prefixes are expanded to the OS home
139
+ * directory so values match home-anchored patterns symmetrically with how
140
+ * `compileWildcardPattern` expands the patterns themselves (#350).
141
+ */
142
+ function normalizePathSurfaceValue(input: unknown): string {
143
+ const path = getNonEmptyString(toRecord(input).path);
144
+ return path === null ? "*" : expandHomePath(path);
145
+ }
package/src/path-utils.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { homedir } from "node:os";
2
1
  import { join, normalize, resolve, sep } from "node:path";
3
2
 
4
3
  import { getNonEmptyString, toRecord } from "./common";
@@ -15,15 +14,7 @@ export function normalizePathForComparison(
15
14
  }
16
15
 
17
16
  let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
18
-
19
- if (normalizedPath === "~") {
20
- normalizedPath = homedir();
21
- } else if (
22
- normalizedPath.startsWith("~/") ||
23
- normalizedPath.startsWith("~\\")
24
- ) {
25
- normalizedPath = join(homedir(), normalizedPath.slice(2));
26
- }
17
+ normalizedPath = expandHomePath(normalizedPath);
27
18
 
28
19
  const absolutePath = resolve(cwd, normalizedPath);
29
20
  const normalizedAbsolutePath = normalize(absolutePath);
@@ -67,17 +67,14 @@ export class PermissionResolver implements ScopedPermissionResolver {
67
67
  );
68
68
  }
69
69
 
70
- // fallow-ignore-next-line unused-class-member
71
70
  getToolPermission(toolName: string, agentName?: string): PermissionState {
72
71
  return this.permissionManager.getToolPermission(toolName, agentName);
73
72
  }
74
73
 
75
- // fallow-ignore-next-line unused-class-member
76
74
  getConfigIssues(agentName?: string): string[] {
77
75
  return this.permissionManager.getConfigIssues(agentName);
78
76
  }
79
77
 
80
- // fallow-ignore-next-line unused-class-member
81
78
  getPolicyCacheStamp(agentName?: string): string {
82
79
  return this.permissionManager.getPolicyCacheStamp(agentName);
83
80
  }
@@ -4,18 +4,15 @@ import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  } from "./active-agent";
7
- import type { AgentPrepSession } from "./agent-prep-session";
7
+
8
8
  import type { SessionConfigStore } from "./config-store";
9
9
  import type { PermissionSystemExtensionConfig } from "./extension-config";
10
10
  import type { ExtensionPaths } from "./extension-paths";
11
11
  import type { ForwardingController } from "./forwarding-manager";
12
- import type { GateHandlerSession } from "./gate-handler-session";
12
+ import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
13
13
  import type { ScopedPermissionManager } from "./permission-manager";
14
14
  import type { PromptingGatewayLifecycle } from "./prompting-gateway";
15
- import type { Rule } from "./rule";
16
- import type { SessionApproval } from "./session-approval";
17
- import type { SessionApprovalRecorder } from "./session-approval-recorder";
18
- import type { SessionLifecycleSession } from "./session-lifecycle-session";
15
+
19
16
  import type { SessionLogger } from "./session-logger";
20
17
  import type { SessionRules } from "./session-rules";
21
18
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
@@ -23,7 +20,6 @@ import {
23
20
  resolveToolPreviewLimits,
24
21
  type ToolPreviewFormatterOptions,
25
22
  } from "./tool-preview-formatter";
26
- import type { PermissionCheckResult, PermissionState } from "./types";
27
23
 
28
24
  /**
29
25
  * Encapsulates all mutable session state and exposes operations instead of
@@ -40,13 +36,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
40
36
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
41
37
  * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
42
38
  */
43
- export class PermissionSession
44
- implements
45
- SessionApprovalRecorder,
46
- GateHandlerSession,
47
- AgentPrepSession,
48
- SessionLifecycleSession
49
- {
39
+ export class PermissionSession implements ToolCallGateInputs {
50
40
  private context: ExtensionContext | null = null;
51
41
  private skillEntries: SkillPromptEntry[] = [];
52
42
  private knownAgentName: string | null = null;
@@ -84,44 +74,6 @@ export class PermissionSession
84
74
  return this.context;
85
75
  }
86
76
 
87
- // ── Permission checking (delegates to PermissionManager) ───────────────
88
-
89
- checkPermission(
90
- surface: string,
91
- input: unknown,
92
- agentName?: string,
93
- sessionRules?: Rule[],
94
- ): PermissionCheckResult {
95
- return this.permissionManager.checkPermission(
96
- surface,
97
- input,
98
- agentName,
99
- sessionRules,
100
- );
101
- }
102
-
103
- getToolPermission(toolName: string, agentName?: string): PermissionState {
104
- return this.permissionManager.getToolPermission(toolName, agentName);
105
- }
106
-
107
- getConfigIssues(agentName?: string): string[] {
108
- return this.permissionManager.getConfigIssues(agentName);
109
- }
110
-
111
- getPolicyCacheStamp(agentName?: string): string {
112
- return this.permissionManager.getPolicyCacheStamp(agentName);
113
- }
114
-
115
- // ── Session rules (delegates to SessionRules) ──────────────────────────
116
-
117
- getSessionRuleset(): Rule[] {
118
- return this.sessionRules.getRuleset();
119
- }
120
-
121
- recordSessionApproval(approval: SessionApproval): void {
122
- this.sessionRules.record(approval);
123
- }
124
-
125
77
  // ── Session lifecycle ────────────────────────────────────────────────────
126
78
 
127
79
  /**
@@ -212,6 +164,10 @@ export class PermissionSession
212
164
  return this.knownAgentName;
213
165
  }
214
166
 
167
+ // Read by config-modal (`controller.session.lastKnownActiveAgentName`).
168
+ // fallow cannot trace the getter through the command's object-literal
169
+ // wiring, so it reports a false positive here.
170
+ // fallow-ignore-next-line unused-class-member
215
171
  get lastKnownActiveAgentName(): string | null {
216
172
  return this.knownAgentName;
217
173
  }
@@ -2,6 +2,7 @@ import { dirname, sep } from "node:path";
2
2
 
3
3
  import type { Ruleset } from "./rule";
4
4
  import type { SessionApproval } from "./session-approval";
5
+ import type { SessionApprovalRecorder } from "./session-approval-recorder";
5
6
 
6
7
  /**
7
8
  * Ephemeral in-memory store of session-scoped permission approvals.
@@ -11,7 +12,7 @@ import type { SessionApproval } from "./session-approval";
11
12
  *
12
13
  * Cleared on session_shutdown — never persisted to disk.
13
14
  */
14
- export class SessionRules {
15
+ export class SessionRules implements SessionApprovalRecorder {
15
16
  private rules: Ruleset = [];
16
17
 
17
18
  /** Record a wildcard pattern as approved for the given surface. */
@@ -36,7 +37,7 @@ export class SessionRules {
36
37
  * The loop lives here so callers never need to know whether an approval
37
38
  * carries one pattern or many — they just tell the store to record it.
38
39
  */
39
- record(approval: SessionApproval): void {
40
+ recordSessionApproval(approval: SessionApproval): void {
40
41
  for (const pattern of approval.patterns) {
41
42
  this.approve(approval.surface, pattern);
42
43
  }
@@ -8,7 +8,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
8
8
 
9
9
  /**
10
10
  * Narrow interface for the permission checker used by skill prompt resolution.
11
- * Both `PermissionManager` and `PermissionSession` satisfy this structurally.
11
+ * Both `PermissionManager` and `PermissionResolver` satisfy this structurally.
12
12
  */
13
13
  export interface SkillPermissionChecker {
14
14
  checkPermission(
@@ -0,0 +1,89 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { expect, test } from "vitest";
3
+ import {
4
+ createActiveToolsCacheKey,
5
+ createBeforeAgentStartPromptStateKey,
6
+ shouldApplyCachedAgentStartState,
7
+ } from "#src/before-agent-start-cache";
8
+ import { createManager } from "#test/helpers/manager-harness";
9
+
10
+ test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
11
+ const allowedTools = ["read", "mcp"];
12
+ const activeToolsKey = createActiveToolsCacheKey(allowedTools);
13
+ const promptStateKey = createBeforeAgentStartPromptStateKey({
14
+ agentName: "code",
15
+ cwd: "C:/workspace/project",
16
+ permissionStamp: "permissions-v1",
17
+ systemPrompt: "Available tools:\n- read\n- mcp",
18
+ allowedToolNames: allowedTools,
19
+ });
20
+
21
+ expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
22
+ expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
23
+ false,
24
+ );
25
+ expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
26
+ expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
27
+ false,
28
+ );
29
+ });
30
+
31
+ test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
32
+ const { manager, globalConfigPath, cleanup } = createManager({
33
+ permission: { "*": "allow", write: "deny" },
34
+ });
35
+
36
+ try {
37
+ const baselineStamp = manager.getPolicyCacheStamp();
38
+ const baselineKey = createBeforeAgentStartPromptStateKey({
39
+ agentName: null,
40
+ cwd: "C:/workspace/project",
41
+ permissionStamp: baselineStamp,
42
+ systemPrompt: "Available tools:\n- read\n- write",
43
+ allowedToolNames: ["read"],
44
+ });
45
+
46
+ expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
47
+ false,
48
+ );
49
+ expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
50
+
51
+ const updatedConfig = `${JSON.stringify(
52
+ { permission: { "*": "allow", write: "allow" } },
53
+ null,
54
+ 2,
55
+ )}\n`;
56
+
57
+ let updatedStamp = baselineStamp;
58
+ for (
59
+ let attempt = 0;
60
+ attempt < 10 && updatedStamp === baselineStamp;
61
+ attempt += 1
62
+ ) {
63
+ const waitUntil = Date.now() + 2;
64
+ while (Date.now() < waitUntil) {
65
+ // Wait for the filesystem timestamp granularity to advance.
66
+ }
67
+
68
+ writeFileSync(globalConfigPath, updatedConfig, "utf8");
69
+ updatedStamp = manager.getPolicyCacheStamp();
70
+ }
71
+
72
+ expect(updatedStamp).not.toBe(baselineStamp);
73
+
74
+ const invalidatedKey = createBeforeAgentStartPromptStateKey({
75
+ agentName: null,
76
+ cwd: "C:/workspace/project",
77
+ permissionStamp: updatedStamp,
78
+ systemPrompt: "Available tools:\n- read\n- write",
79
+ allowedToolNames: ["read", "write"],
80
+ });
81
+
82
+ expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
83
+ true,
84
+ );
85
+ expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
86
+ } finally {
87
+ cleanup();
88
+ }
89
+ });