@aliou/pi-guardrails 0.13.3 → 0.14.1

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 (32) hide show
  1. package/README.md +2 -0
  2. package/extensions/guardrails/commands/onboarding/config.ts +5 -1
  3. package/extensions/guardrails/commands/settings/index.ts +9 -2
  4. package/extensions/guardrails/commands/settings/path-list-editor.ts +55 -9
  5. package/extensions/guardrails/index.ts +1 -2
  6. package/extensions/path-access/dynamic-resources.ts +37 -0
  7. package/extensions/path-access/grants.ts +32 -19
  8. package/extensions/path-access/index.ts +21 -0
  9. package/package.json +4 -4
  10. package/schema.json +41 -1
  11. package/src/core/paths/access.ts +9 -9
  12. package/src/core/paths/index.ts +2 -1
  13. package/src/core/paths/path.ts +34 -5
  14. package/src/shared/config/defaults.ts +1 -1
  15. package/src/shared/config/index.ts +1 -0
  16. package/src/shared/config/loader.ts +9 -5
  17. package/src/shared/config/migration/001-v0-format-upgrade.ts +1 -2
  18. package/src/shared/config/migration/002-strip-toolchain-fields.ts +0 -7
  19. package/src/shared/config/migration/003-strip-command-explainer-fields.ts +0 -6
  20. package/src/shared/config/migration/004-env-files-to-policies.ts +19 -6
  21. package/src/shared/config/migration/005-normalize-allowed-paths.ts +10 -6
  22. package/src/shared/config/migration/006-apply-builtin-defaults.ts +0 -5
  23. package/src/shared/config/migration/007-mark-onboarding-done.ts +0 -4
  24. package/src/shared/config/migration/008-normalize-string-booleans.ts +0 -4
  25. package/src/shared/config/migration/009-allow-dev-null.ts +43 -0
  26. package/src/shared/config/migration/010-allowed-paths-objects.ts +65 -0
  27. package/src/shared/config/migration/index.ts +29 -1
  28. package/src/shared/config/migration/version.ts +1 -1
  29. package/src/shared/config/types.ts +15 -2
  30. package/src/shared/index.ts +0 -1
  31. package/src/shared/matching.ts +8 -7
  32. package/src/shared/warnings.ts +0 -17
package/README.md CHANGED
@@ -56,6 +56,8 @@ The `path-access` extension checks tool calls that target paths outside the curr
56
56
 
57
57
  It can allow, block, or ask before Pi accesses files elsewhere on your machine. In ask mode, you can allow one file or a directory once, for the session, or always.
58
58
 
59
+ Granted paths are stored in `pathAccess.allowedPaths` as explicit `{ kind, path }` entries: `file` matches the exact path, `directory` matches the directory and its descendants. Edit them through `/guardrails:settings` (Path Access → Allowed paths, Tab toggles file/directory) or directly in the settings file. Paths support `~/` for home. Existing configs using the legacy string form (trailing `/` for directories) are migrated automatically.
60
+
59
61
  [![Guardrails path access prompt walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.mp4)
60
62
 
61
63
  ### permission-gate
@@ -2,6 +2,7 @@ import {
2
2
  CURRENT_VERSION,
3
3
  type GuardrailsConfig,
4
4
  } from "../../../../src/shared/config";
5
+ import { DEFAULT_CONFIG } from "../../../../src/shared/config/defaults";
5
6
 
6
7
  export function buildOnboardedConfig(
7
8
  applyBuiltinDefaults: boolean,
@@ -18,7 +19,10 @@ export function buildOnboardedConfig(
18
19
  };
19
20
  if (pathAccessEnabled) {
20
21
  config.features = { ...config.features, pathAccess: true };
21
- config.pathAccess = { mode: "ask" };
22
+ config.pathAccess = {
23
+ mode: "ask",
24
+ allowedPaths: [...DEFAULT_CONFIG.pathAccess.allowedPaths],
25
+ };
22
26
  }
23
27
  return config;
24
28
  }
@@ -14,6 +14,7 @@ import type {
14
14
  SettingsListTheme,
15
15
  } from "@earendil-works/pi-tui";
16
16
  import type {
17
+ AllowedPath,
17
18
  DangerousPattern,
18
19
  GuardrailsConfig,
19
20
  PatternConfig,
@@ -355,7 +356,13 @@ export function registerGuardrailsSettings(
355
356
  return (_val: string, submenuDone: (v?: string) => void) => {
356
357
  const value = getNestedValue(scopedConfig, id);
357
358
  const items = Array.isArray(value)
358
- ? value.filter((path): path is string => typeof path === "string")
359
+ ? value.filter(
360
+ (entry): entry is AllowedPath =>
361
+ typeof entry === "object" &&
362
+ entry !== null &&
363
+ (entry.kind === "file" || entry.kind === "directory") &&
364
+ typeof entry.path === "string",
365
+ )
359
366
  : [];
360
367
  let latestCount = items.length;
361
368
  return new PathListEditor({
@@ -531,7 +538,7 @@ export function registerGuardrailsSettings(
531
538
  id: "pathAccess.allowedPaths",
532
539
  label: "Allowed paths",
533
540
  description:
534
- "Paths always allowed (trailing / for directories). Supports ~/",
541
+ "Paths always allowed outside cwd. Each entry is { kind, path }: file matches exactly, directory matches the tree. Supports ~/",
535
542
  currentValue: count("pathAccess.allowedPaths"),
536
543
  submenu: pathListSubmenu(
537
544
  "pathAccess.allowedPaths",
@@ -5,25 +5,45 @@ import {
5
5
  matchesKey,
6
6
  type SettingsListTheme,
7
7
  } from "@earendil-works/pi-tui";
8
+ import type { AllowedPath } from "../../../../src/core/paths";
9
+
10
+ type PathKind = "file" | "directory";
11
+
12
+ function kindLabel(kind: PathKind): string {
13
+ return kind === "directory" ? "dir" : "file";
14
+ }
15
+
16
+ function isAllowedPath(value: unknown): value is AllowedPath {
17
+ if (!value || typeof value !== "object") return false;
18
+ const entry = value as { kind?: unknown; path?: unknown };
19
+ return (
20
+ (entry.kind === "file" || entry.kind === "directory") &&
21
+ typeof entry.path === "string"
22
+ );
23
+ }
8
24
 
9
25
  export class PathListEditor implements Component {
10
26
  private readonly input = new Input();
11
- private items: string[];
27
+ private items: AllowedPath[];
12
28
  private selectedIndex = 0;
13
29
  private mode: "list" | "add" | "edit" = "list";
14
30
  private editIndex = -1;
31
+ private draftKind: PathKind = "file";
15
32
 
16
33
  constructor(
17
34
  private readonly options: {
18
35
  label: string;
19
- items: string[];
36
+ items: AllowedPath[];
20
37
  theme: SettingsListTheme;
21
- onSave: (items: string[]) => void;
38
+ onSave: (items: AllowedPath[]) => void;
22
39
  onDone: () => void;
23
40
  maxVisible?: number;
24
41
  },
25
42
  ) {
26
- this.items = [...options.items];
43
+ this.items = options.items.filter(isAllowedPath).map((item) => ({
44
+ kind: item.kind,
45
+ path: item.path,
46
+ }));
27
47
  this.input.onSubmit = () => this.submit();
28
48
  this.input.onEscape = () => this.cancel();
29
49
  }
@@ -40,6 +60,9 @@ export class PathListEditor implements Component {
40
60
  this.options.theme.hint(
41
61
  this.mode === "edit" ? " Edit path:" : " New path:",
42
62
  ),
63
+ this.options.theme.hint(
64
+ ` kind: ${kindLabel(this.draftKind)} (Tab to toggle file/dir)`,
65
+ ),
43
66
  "",
44
67
  ...this.input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
45
68
  "",
@@ -65,7 +88,9 @@ export class PathListEditor implements Component {
65
88
  if (!item) continue;
66
89
  const isSelected = i === this.selectedIndex;
67
90
  const prefix = isSelected ? this.options.theme.cursor : " ";
68
- lines.push(prefix + this.options.theme.value(item, isSelected));
91
+ const kind = this.options.theme.hint(`[${kindLabel(item.kind)}]`);
92
+ const value = this.options.theme.value(item.path, isSelected);
93
+ lines.push(`${prefix}${kind} ${value}`);
69
94
  }
70
95
  if (startIndex > 0 || endIndex < this.items.length) {
71
96
  lines.push(
@@ -87,6 +112,10 @@ export class PathListEditor implements Component {
87
112
 
88
113
  handleInput(data: string): void {
89
114
  if (this.mode === "add" || this.mode === "edit") {
115
+ if (matchesKey(data, Key.tab)) {
116
+ this.draftKind = this.draftKind === "file" ? "directory" : "file";
117
+ return;
118
+ }
90
119
  this.input.handleInput(data);
91
120
  return;
92
121
  }
@@ -105,6 +134,7 @@ export class PathListEditor implements Component {
105
134
  : this.selectedIndex + 1;
106
135
  } else if (data === "a" || data === "A") {
107
136
  this.mode = "add";
137
+ this.draftKind = "file";
108
138
  this.input.setValue("");
109
139
  } else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
110
140
  this.startEdit();
@@ -120,7 +150,8 @@ export class PathListEditor implements Component {
120
150
  if (!item) return;
121
151
  this.mode = "edit";
122
152
  this.editIndex = this.selectedIndex;
123
- this.input.setValue(item);
153
+ this.draftKind = item.kind;
154
+ this.input.setValue(item.path);
124
155
  }
125
156
 
126
157
  private submit(): void {
@@ -130,13 +161,15 @@ export class PathListEditor implements Component {
130
161
  return;
131
162
  }
132
163
 
164
+ const entry: AllowedPath = { kind: this.draftKind, path };
165
+
133
166
  if (this.mode === "edit") {
134
- this.items[this.editIndex] = path;
167
+ this.items[this.editIndex] = entry;
135
168
  } else {
136
- this.items.push(path);
169
+ this.items.push(entry);
137
170
  this.selectedIndex = this.items.length - 1;
138
171
  }
139
- this.items = [...new Set(this.items)];
172
+ this.items = dedupe(this.items);
140
173
  this.options.onSave([...this.items]);
141
174
  this.cancel();
142
175
  }
@@ -153,6 +186,19 @@ export class PathListEditor implements Component {
153
186
  private cancel(): void {
154
187
  this.mode = "list";
155
188
  this.editIndex = -1;
189
+ this.draftKind = "file";
156
190
  this.input.setValue("");
157
191
  }
158
192
  }
193
+
194
+ function dedupe(items: AllowedPath[]): AllowedPath[] {
195
+ const seen = new Set<string>();
196
+ const result: AllowedPath[] = [];
197
+ for (const item of items) {
198
+ const key = `${item.kind}:${item.path}`;
199
+ if (seen.has(key)) continue;
200
+ seen.add(key);
201
+ result.push(item);
202
+ }
203
+ return result;
204
+ }
@@ -9,7 +9,6 @@ import {
9
9
  type GuardrailsFeatureId,
10
10
  type GuardrailsFeatureRegisterPayload,
11
11
  } from "../../src/shared/events";
12
- import { drainPendingWarnings } from "../../src/shared/warnings";
13
12
  import { registerGuardrailsExamplesCommand } from "./commands/examples";
14
13
  import { registerGuardrailsOnboardingCommand } from "./commands/onboarding";
15
14
  import { isOnboardingPending } from "./commands/onboarding/config";
@@ -90,7 +89,7 @@ export default async function guardrails(pi: ExtensionAPI) {
90
89
  createFeatureRequestPayload(),
91
90
  );
92
91
 
93
- const warnings = drainPendingWarnings();
92
+ const warnings = configLoader.drainMessages();
94
93
  if (warnings.length === 1) {
95
94
  ctx.ui.notify(warnings[0], "warning");
96
95
  } else if (warnings.length > 1) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve Pi documentation paths dynamically from the running Pi runtime.
3
+ *
4
+ * Pi bakes its docs/examples paths into the system prompt text at launch time,
5
+ * but those paths change between Pi versions (e.g. Nix store hashes), so we
6
+ * resolve them directly from Pi's package asset path helpers instead of
7
+ * scraping the system prompt or persisting concrete paths in config.
8
+ */
9
+ import {
10
+ getDocsPath,
11
+ getExamplesPath,
12
+ getReadmePath,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { AllowedPath } from "../../src/core/paths";
15
+
16
+ /**
17
+ * Resolve Pi documentation paths dynamically from the running Pi runtime.
18
+ *
19
+ * Pi bakes its docs/examples paths into the system prompt text at launch time,
20
+ * but those paths change between Pi versions (e.g. Nix store hashes), so we
21
+ * resolve them directly from Pi's package asset path helpers instead of
22
+ * scraping the system prompt or persisting concrete paths in config.
23
+ *
24
+ * These helpers honor `PI_PACKAGE_DIR` (Nix/Guix) and walk to the package
25
+ * root for npm global installs and Bun binaries, so they always match the
26
+ * Pi version actually running. They depend only on the process environment
27
+ * and are fixed for the process lifetime — resolve once, no per-turn work.
28
+ *
29
+ * The README is a single file grant; docs and examples are directory grants.
30
+ */
31
+ export function piDocumentationPaths(): AllowedPath[] {
32
+ return [
33
+ { kind: "file", path: getReadmePath() },
34
+ { kind: "directory", path: getDocsPath() },
35
+ { kind: "directory", path: getExamplesPath() },
36
+ ];
37
+ }
@@ -1,30 +1,33 @@
1
1
  import { homedir } from "node:os";
2
- import { resolveFromCwd, toStorageForm } from "../../src/core/paths";
2
+ import {
3
+ type AllowedPath,
4
+ resolveFromCwd,
5
+ toStorageGrant,
6
+ } from "../../src/core/paths";
3
7
  import { configLoader } from "../../src/shared/config";
4
8
 
5
9
  export type PendingPathGrant = {
6
- storagePath: string;
10
+ kind: "file" | "directory";
11
+ storageGrant: AllowedPath;
7
12
  scope: "memory" | "local";
8
13
  absolutePath: string;
9
14
  };
10
15
 
11
16
  export function resolveAllowedPaths(
12
- allowedPaths: string[],
17
+ allowedPaths: AllowedPath[],
13
18
  cwd: string,
14
- ): string[] {
15
- return allowedPaths.map((path) => {
16
- const isDir = path.endsWith("/");
17
- const resolved = resolveFromCwd(isDir ? path.slice(0, -1) : path, cwd);
18
- return isDir ? `${resolved}/` : resolved;
19
- });
19
+ ): AllowedPath[] {
20
+ return allowedPaths.map((entry) => ({
21
+ kind: entry.kind,
22
+ path: resolveFromCwd(entry.path, cwd),
23
+ }));
20
24
  }
21
25
 
22
- export function pendingAllowedPaths(grants: PendingPathGrant[]): string[] {
23
- return grants.map((grant) =>
24
- grant.storagePath.endsWith("/")
25
- ? `${grant.absolutePath}/`
26
- : grant.absolutePath,
27
- );
26
+ export function pendingAllowedPaths(grants: PendingPathGrant[]): AllowedPath[] {
27
+ return grants.map((grant) => ({
28
+ kind: grant.kind,
29
+ path: grant.absolutePath,
30
+ }));
28
31
  }
29
32
 
30
33
  export function isGrantTooBroad(absPath: string): boolean {
@@ -38,9 +41,10 @@ export function createPendingGrant(
38
41
  scope: "memory" | "local",
39
42
  ): PendingPathGrant {
40
43
  return {
44
+ kind: isDirectory ? "directory" : "file",
41
45
  absolutePath,
42
46
  scope,
43
- storagePath: toStorageForm(absolutePath, isDirectory),
47
+ storageGrant: toStorageGrant(absolutePath, isDirectory),
44
48
  };
45
49
  }
46
50
 
@@ -52,17 +56,26 @@ export async function persistGrant(grant: PendingPathGrant): Promise<void> {
52
56
  const pathAccess = (raw.pathAccess ?? {}) as Record<string, unknown>;
53
57
  const existing = Array.isArray(pathAccess.allowedPaths)
54
58
  ? pathAccess.allowedPaths.filter(
55
- (path): path is string => typeof path === "string",
59
+ (entry): entry is AllowedPath =>
60
+ typeof entry === "object" &&
61
+ entry !== null &&
62
+ (entry.kind === "file" || entry.kind === "directory") &&
63
+ typeof (entry as { path?: unknown }).path === "string",
56
64
  )
57
65
  : [];
58
66
 
59
- if (existing.includes(grant.storagePath)) return;
67
+ const alreadyPresent = existing.some(
68
+ (entry) =>
69
+ entry.kind === grant.storageGrant.kind &&
70
+ entry.path === grant.storageGrant.path,
71
+ );
72
+ if (alreadyPresent) return;
60
73
 
61
74
  await configLoader.save(grant.scope, {
62
75
  ...raw,
63
76
  pathAccess: {
64
77
  ...pathAccess,
65
- allowedPaths: [...existing, grant.storagePath],
78
+ allowedPaths: [...existing, grant.storageGrant],
66
79
  },
67
80
  });
68
81
  }
@@ -2,6 +2,7 @@ import { dirname } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { checkAction } from "../../src/core";
4
4
  import {
5
+ type AllowedPath,
5
6
  normalizeForDisplay,
6
7
  type PathAccessState,
7
8
  } from "../../src/core/paths";
@@ -13,6 +14,7 @@ import {
13
14
  GUARDRAILS_FEATURE_REGISTER_EVENT,
14
15
  GUARDRAILS_FEATURE_REQUEST_EVENT,
15
16
  } from "../../src/shared/events";
17
+ import { piDocumentationPaths } from "./dynamic-resources";
16
18
  import {
17
19
  createPendingGrant,
18
20
  isGrantTooBroad,
@@ -28,6 +30,23 @@ import { targetsForTool } from "./targets";
28
30
  export default async function pathAccess(pi: ExtensionAPI) {
29
31
  await configLoader.load();
30
32
 
33
+ // Pi docs paths depend only on `PI_PACKAGE_DIR` / the package root and are
34
+ // fixed for the process lifetime, so resolve once at setup.
35
+ const piDocsPaths = piDocumentationPaths();
36
+
37
+ let currentSkillAllowedPaths: AllowedPath[] = [];
38
+
39
+ pi.on("before_agent_start", (event) => {
40
+ const skills = event.systemPromptOptions.skills;
41
+
42
+ if (!skills || skills.length === 0) return;
43
+
44
+ currentSkillAllowedPaths = skills.flatMap((skill) => [
45
+ { kind: "file", path: skill.filePath },
46
+ { kind: "directory", path: skill.baseDir },
47
+ ]);
48
+ });
49
+
31
50
  pi.events.on(GUARDRAILS_FEATURE_REQUEST_EVENT, () => {
32
51
  pi.events.emit(
33
52
  GUARDRAILS_FEATURE_REGISTER_EVENT,
@@ -62,6 +81,8 @@ export default async function pathAccess(pi: ExtensionAPI) {
62
81
  mode: config.pathAccess.mode,
63
82
  allowedPaths: [
64
83
  ...resolveAllowedPaths(config.pathAccess.allowedPaths, ctx.cwd),
84
+ ...piDocsPaths,
85
+ ...currentSkillAllowedPaths,
65
86
  ...pendingAllowedPaths(acceptedGrants),
66
87
  ],
67
88
  hasUI: ctx.hasUI,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.13.3",
3
+ "version": "0.14.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -35,7 +35,7 @@
35
35
  "!extensions/**/*.test.ts"
36
36
  ],
37
37
  "dependencies": {
38
- "@aliou/pi-utils-settings": "^0.15.1",
38
+ "@aliou/pi-utils-settings": "^0.17.0",
39
39
  "@aliou/sh": "^0.1.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -46,8 +46,8 @@
46
46
  "@aliou/biome-plugins": "^0.8.1",
47
47
  "@biomejs/biome": "^2.4.15",
48
48
  "@changesets/cli": "^2.27.11",
49
- "@earendil-works/pi-coding-agent": "0.74.0",
50
- "@earendil-works/pi-tui": "0.74.0",
49
+ "@earendil-works/pi-coding-agent": "0.79.6",
50
+ "@earendil-works/pi-tui": "0.79.6",
51
51
  "@sinclair/typebox": "^0.34.48",
52
52
  "@types/node": "^25.0.10",
53
53
  "husky": "^9.1.7",
package/schema.json CHANGED
@@ -2,6 +2,45 @@
2
2
  "$ref": "#/definitions/GuardrailsConfig",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "definitions": {
5
+ "AllowedPath": {
6
+ "anyOf": [
7
+ {
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "kind": {
11
+ "const": "file",
12
+ "type": "string"
13
+ },
14
+ "path": {
15
+ "type": "string"
16
+ }
17
+ },
18
+ "required": [
19
+ "kind",
20
+ "path"
21
+ ],
22
+ "type": "object"
23
+ },
24
+ {
25
+ "additionalProperties": false,
26
+ "properties": {
27
+ "kind": {
28
+ "const": "directory",
29
+ "type": "string"
30
+ },
31
+ "path": {
32
+ "type": "string"
33
+ }
34
+ },
35
+ "required": [
36
+ "kind",
37
+ "path"
38
+ ],
39
+ "type": "object"
40
+ }
41
+ ],
42
+ "description": "A path grant with an explicit kind.\n\n`file` matches the exact path only. `directory` matches the directory itself and any descendant (boundary/prefix match).\n\nThis replaces the previous trailing-slash convention on a flat `string[]`, where the kind was inferred from whether a path ended in `/`."
43
+ },
5
44
  "DangerousPattern": {
6
45
  "additionalProperties": false,
7
46
  "description": "Permission gate pattern. When regex is false (default), the pattern is matched as substring against the raw command string. When regex is true, uses full regex against the raw string.",
@@ -180,8 +219,9 @@
180
219
  "additionalProperties": false,
181
220
  "properties": {
182
221
  "allowedPaths": {
222
+ "description": "Paths always allowed, regardless of cwd. Each entry carries an explicit `kind`: `file` matches the exact path, `directory` matches the directory and its descendants.",
183
223
  "items": {
184
- "type": "string"
224
+ "$ref": "#/definitions/AllowedPath"
185
225
  },
186
226
  "type": "array"
187
227
  },
@@ -1,4 +1,4 @@
1
- import { isWithinBoundary } from "./path";
1
+ import { type AllowedPath, isWithinBoundary } from "./path";
2
2
 
3
3
  export type PathDecision =
4
4
  | { kind: "allow" }
@@ -8,25 +8,25 @@ export type PathDecision =
8
8
  export interface PathAccessState {
9
9
  cwd: string;
10
10
  mode: "allow" | "ask" | "block";
11
- allowedPaths: string[]; // already resolved to absolute, with trailing / convention
11
+ allowedPaths: AllowedPath[]; // already resolved to absolute
12
12
  hasUI: boolean;
13
13
  }
14
14
 
15
15
  /**
16
16
  * Check if an absolute path is covered by the allowedPaths list.
17
- * - Entries ending in "/" are directory grants (boundary/prefix match).
18
- * - Entries without trailing "/" are exact file grants.
17
+ *
18
+ * `directory` grants match the directory itself and any descendant (boundary
19
+ * match). `file` grants match the exact path only.
19
20
  */
20
21
  export function isPathAllowed(
21
22
  absPath: string,
22
- allowedPaths: string[],
23
+ allowedPaths: AllowedPath[],
23
24
  ): boolean {
24
25
  for (const entry of allowedPaths) {
25
- if (entry.endsWith("/")) {
26
- const dirPath = entry.slice(0, -1);
27
- if (isWithinBoundary(absPath, dirPath)) return true;
26
+ if (entry.kind === "directory") {
27
+ if (isWithinBoundary(absPath, entry.path)) return true;
28
28
  } else {
29
- if (absPath === entry) return true;
29
+ if (absPath === entry.path) return true;
30
30
  }
31
31
  }
32
32
  return false;
@@ -5,10 +5,11 @@ export {
5
5
  type PathDecision,
6
6
  } from "./access";
7
7
  export {
8
+ type AllowedPath,
8
9
  expandHomePath,
9
10
  isWithinBoundary,
10
11
  maybePathLike,
11
12
  normalizeForDisplay,
12
13
  resolveFromCwd,
13
- toStorageForm,
14
+ toStorageGrant,
14
15
  } from "./path";
@@ -1,6 +1,19 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
 
4
+ /**
5
+ * A path grant with an explicit kind.
6
+ *
7
+ * `file` matches the exact path only. `directory` matches the directory itself
8
+ * and any descendant (boundary/prefix match).
9
+ *
10
+ * This replaces the previous trailing-slash convention on a flat `string[]`,
11
+ * where the kind was inferred from whether a path ended in `/`.
12
+ */
13
+ export type AllowedPath =
14
+ | { kind: "file"; path: string }
15
+ | { kind: "directory"; path: string };
16
+
4
17
  /**
5
18
  * Expand a leading tilde to the current user's home directory.
6
19
  * Preserves all other paths unchanged.
@@ -52,9 +65,27 @@ export function normalizeForDisplay(absPath: string, cwd: string): string {
52
65
 
53
66
  /**
54
67
  * Convert an absolute path to storage form for config persistence.
55
- * Uses ~/ for home paths, absolute otherwise. Appends trailing / for directory grants.
68
+ *
69
+ * Uses `~/` for home paths, absolute otherwise, and normalizes separators to
70
+ * forward slash. The kind (file vs directory) is carried explicitly on the
71
+ * returned `AllowedPath` instead of via a trailing slash.
72
+ */
73
+ export function toStorageGrant(
74
+ absPath: string,
75
+ isDirectory: boolean,
76
+ ): AllowedPath {
77
+ return {
78
+ kind: isDirectory ? "directory" : "file",
79
+ path: toStoragePath(absPath),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Normalize an absolute path for storage: expand home to `~/`, collapse
85
+ * backslashes to forward slashes, and strip any trailing slash (the kind now
86
+ * lives on `AllowedPath`, not on the path string).
56
87
  */
57
- export function toStorageForm(absPath: string, isDirectory: boolean): string {
88
+ function toStoragePath(absPath: string): string {
58
89
  const home = homedir();
59
90
  let stored: string;
60
91
  if (
@@ -66,10 +97,8 @@ export function toStorageForm(absPath: string, isDirectory: boolean): string {
66
97
  } else {
67
98
  stored = absPath;
68
99
  }
69
- // Normalize separators to forward slash for storage
70
100
  stored = stored.replace(/\\/g, "/");
71
- if (isDirectory && !stored.endsWith("/")) stored += "/";
72
- if (!isDirectory && stored.endsWith("/")) stored = stored.slice(0, -1);
101
+ stored = stored.replace(/\/+$/, "");
73
102
  return stored;
74
103
  }
75
104
 
@@ -12,7 +12,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
12
12
  },
13
13
  pathAccess: {
14
14
  mode: "ask",
15
- allowedPaths: [],
15
+ allowedPaths: [{ kind: "file", path: "/dev/null" }],
16
16
  },
17
17
  policies: {
18
18
  rules: [
@@ -6,6 +6,7 @@ export {
6
6
  migrations,
7
7
  } from "./migration";
8
8
  export type {
9
+ AllowedPath,
9
10
  DangerousPattern,
10
11
  GuardrailsConfig,
11
12
  PathAccessConfig,
@@ -4,6 +4,7 @@ import {
4
4
  type Scope,
5
5
  } from "@aliou/pi-utils-settings";
6
6
  import pkg from "../../../package.json" with { type: "json" };
7
+ import type { AllowedPath } from "../../core/paths/path";
7
8
  import { DEFAULT_CONFIG } from "./defaults";
8
9
  import { migrations } from "./migration";
9
10
  import type { GuardrailsConfig, PolicyRule, ResolvedConfig } from "./types";
@@ -63,18 +64,21 @@ export function createGuardrailsConfigLoader(): GuardrailsConfigLoader {
63
64
  resolved.permissionGate.useBuiltinMatchers = false;
64
65
  }
65
66
 
66
- const mergedPaths = new Set<string>();
67
+ const mergedPaths = new Map<string, AllowedPath>();
67
68
  for (const paths of [
68
69
  global?.pathAccess?.allowedPaths,
69
70
  local?.pathAccess?.allowedPaths,
70
71
  memory?.pathAccess?.allowedPaths,
71
72
  ]) {
72
- for (const path of paths ?? []) {
73
- const trimmed = path.trim();
74
- if (trimmed) mergedPaths.add(trimmed);
73
+ for (const entry of paths ?? []) {
74
+ if (!entry || typeof entry !== "object") continue;
75
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
76
+ if (!path) continue;
77
+ const kind = entry.kind === "directory" ? "directory" : "file";
78
+ mergedPaths.set(`${kind}:${path}`, { kind, path });
75
79
  }
76
80
  }
77
- resolved.pathAccess.allowedPaths = [...mergedPaths];
81
+ resolved.pathAccess.allowedPaths = [...mergedPaths.values()];
78
82
 
79
83
  return resolved;
80
84
  },
@@ -1,6 +1,5 @@
1
1
  import { copyFile, stat } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
- import { addPendingWarning } from "../../warnings";
4
3
  import type {
5
4
  DangerousPattern,
6
5
  GuardrailsConfig,
@@ -101,7 +100,7 @@ async function backupConfig(configPath: string): Promise<void> {
101
100
  try {
102
101
  await copyFile(configPath, backupPath);
103
102
  } catch (err) {
104
- addPendingWarning(`guardrails: could not back up config: ${err}`);
103
+ console.error(`[guardrails] could not back up config: ${err}`);
105
104
  }
106
105
  }
107
106
  }
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -20,12 +19,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
20
19
  }
21
20
 
22
21
  export function run(config: GuardrailsConfig): GuardrailsConfig {
23
- addPendingWarning(
24
- "[guardrails] preventBrew, preventPython, enforcePackageManager, and packageManager " +
25
- "have been removed from guardrails and moved to @aliou/pi-toolchain. " +
26
- "These fields will be stripped from your config.",
27
- );
28
-
29
22
  const cleaned = structuredClone(config) as Record<string, unknown>;
30
23
  const features = cleaned.features as Record<string, unknown> | undefined;
31
24
  if (features) {
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -23,11 +22,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
23
22
  }
24
23
 
25
24
  export function run(config: GuardrailsConfig): GuardrailsConfig {
26
- addPendingWarning(
27
- "[guardrails] permissionGate.explainCommands, explainModel, and explainTimeout " +
28
- "have been removed. These fields will be stripped from your config.",
29
- );
30
-
31
25
  const cleaned = structuredClone(config) as Record<string, unknown>;
32
26
  const permissionGate = cleaned.permissionGate as
33
27
  | Record<string, unknown>
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -15,7 +14,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
15
14
  const raw = migrated as Record<string, unknown>;
16
15
  const features = raw.features as Record<string, unknown> | undefined;
17
16
  const envFiles = raw.envFiles as Record<string, unknown> | undefined;
18
-
19
17
  if (features?.protectEnvFiles !== undefined) {
20
18
  features.policies = features.protectEnvFiles;
21
19
  delete features.protectEnvFiles;
@@ -62,10 +60,9 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
62
60
  }
63
61
 
64
62
  if (Array.isArray(envFiles.protectedTools)) {
65
- addPendingWarning(
66
- "[guardrails] envFiles.protectedTools is deprecated and has no direct policies equivalent. " +
67
- "The migrated secret-files rule uses protection=noAccess.",
68
- );
63
+ // protectedTools has no policies equivalent; the migrated secret-files
64
+ // rule uses protection=noAccess. The deprecation note is surfaced via
65
+ // the `message` factory exported below.
69
66
  }
70
67
 
71
68
  if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) {
@@ -85,3 +82,19 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
85
82
  raw.version = CURRENT_VERSION;
86
83
  return migrated as GuardrailsConfig;
87
84
  }
85
+
86
+ /**
87
+ * Message for the envFiles-to-policies migration. Only surface the
88
+ * protectedTools deprecation note when the pre-migration config actually had
89
+ * protectedTools set; return undefined otherwise so no message is queued.
90
+ */
91
+ export function message(before: GuardrailsConfig): string | undefined {
92
+ const envFiles = (before as Record<string, unknown>).envFiles as
93
+ | Record<string, unknown>
94
+ | undefined;
95
+ if (!Array.isArray(envFiles?.protectedTools)) return undefined;
96
+ return (
97
+ "envFiles.protectedTools is deprecated and has no direct policies equivalent. " +
98
+ "The migrated secret-files rule uses protection=noAccess."
99
+ );
100
+ }
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -6,7 +5,12 @@ export function shouldRun(config: GuardrailsConfig): boolean {
6
5
  const raw = config as Record<string, unknown>;
7
6
  const pathAccess = raw.pathAccess as Record<string, unknown> | undefined;
8
7
  if (!Array.isArray(pathAccess?.allowedPaths)) return false;
9
- return pathAccess.allowedPaths.some((item) => typeof item !== "string");
8
+ return pathAccess.allowedPaths.some(
9
+ (item) =>
10
+ typeof item === "object" &&
11
+ item !== null &&
12
+ typeof (item as Record<string, unknown>).pattern === "string",
13
+ );
10
14
  }
11
15
 
12
16
  export function run(config: GuardrailsConfig): GuardrailsConfig {
@@ -16,9 +20,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
16
20
 
17
21
  pathAccess.allowedPaths = normalizeAllowedPaths(pathAccess.allowedPaths);
18
22
  migrated.version = CURRENT_VERSION;
19
- addPendingWarning(
20
- "[guardrails] pathAccess.allowedPaths was migrated from pattern objects to path strings.",
21
- );
22
23
  return migrated as GuardrailsConfig;
23
24
  }
24
25
 
@@ -31,8 +32,11 @@ function normalizeAllowedPaths(items: unknown): string[] {
31
32
  if (typeof item === "string") {
32
33
  path = item;
33
34
  } else if (typeof item === "object" && item !== null) {
34
- const pattern = (item as Record<string, unknown>).pattern;
35
+ const obj = item as Record<string, unknown>;
36
+ const pattern = obj.pattern;
37
+ const objectPath = obj.path;
35
38
  if (typeof pattern === "string") path = pattern;
39
+ else if (typeof objectPath === "string") path = objectPath;
36
40
  }
37
41
 
38
42
  const normalized = path?.trim();
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -11,9 +10,5 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
11
10
  migrated.applyBuiltinDefaults = true;
12
11
  migrated.version = CURRENT_VERSION;
13
12
 
14
- addPendingWarning(
15
- "Guardrails config was migrated. `applyBuiltinDefaults` was set to `true` to preserve current behavior.",
16
- );
17
-
18
13
  return migrated;
19
14
  }
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -11,9 +10,6 @@ export function shouldRun(config: GuardrailsConfig): boolean {
11
10
 
12
11
  export function run(config: GuardrailsConfig): GuardrailsConfig {
13
12
  const migrated = structuredClone(config);
14
- addPendingWarning(
15
- "Guardrails config was migrated. Existing setup marked as onboarding-complete.",
16
- );
17
13
  migrated.onboarding = {
18
14
  ...(migrated.onboarding ?? {}),
19
15
  completed: true,
@@ -1,4 +1,3 @@
1
- import { addPendingWarning } from "../../warnings";
2
1
  import type { GuardrailsConfig } from "../types";
3
2
  import { CURRENT_VERSION } from "./version";
4
3
 
@@ -48,9 +47,6 @@ export function run(config: GuardrailsConfig): GuardrailsConfig {
48
47
 
49
48
  if (changed) {
50
49
  migrated.version = CURRENT_VERSION;
51
- addPendingWarning(
52
- "[guardrails] Config migrated: boolean settings stored as strings were converted to true/false.",
53
- );
54
50
  }
55
51
 
56
52
  return migrated as GuardrailsConfig;
@@ -0,0 +1,43 @@
1
+ import type { GuardrailsConfig } from "../types";
2
+ import { CURRENT_VERSION } from "./version";
3
+
4
+ const DEV_NULL = "/dev/null";
5
+
6
+ /**
7
+ * Does an allowedPaths entry (legacy string or { kind, path } object) refer
8
+ * to /dev/null? Format-agnostic so this migration does not re-run on configs
9
+ * already migrated to the object form.
10
+ */
11
+ function includesDevNull(allowedPaths: unknown[] | undefined): boolean {
12
+ return (allowedPaths ?? []).some((entry) => {
13
+ if (typeof entry === "string") return entry === DEV_NULL;
14
+ if (entry && typeof entry === "object") {
15
+ const obj = entry as { path?: unknown };
16
+ return obj.path === DEV_NULL;
17
+ }
18
+ return false;
19
+ });
20
+ }
21
+
22
+ export function shouldRun(config: GuardrailsConfig): boolean {
23
+ return (
24
+ config.onboarding?.completed === true &&
25
+ config.features?.pathAccess === true &&
26
+ config.pathAccess?.mode === "ask" &&
27
+ !includesDevNull(config.pathAccess?.allowedPaths)
28
+ );
29
+ }
30
+
31
+ export function run(config: GuardrailsConfig): GuardrailsConfig {
32
+ const migrated = structuredClone(config);
33
+ const pathAccess = migrated.pathAccess ?? {};
34
+ const allowedPaths = pathAccess.allowedPaths ?? [];
35
+
36
+ migrated.pathAccess = {
37
+ ...pathAccess,
38
+ allowedPaths: [...allowedPaths, { kind: "file", path: DEV_NULL }],
39
+ };
40
+ migrated.version = CURRENT_VERSION;
41
+
42
+ return migrated;
43
+ }
@@ -0,0 +1,65 @@
1
+ import type { GuardrailsConfig } from "../types";
2
+ import { CURRENT_VERSION } from "./version";
3
+
4
+ /**
5
+ * Migrate `pathAccess.allowedPaths` from the legacy flat `string[]` (where the
6
+ * kind was inferred from a trailing `/`) to the explicit `{ kind, path }`
7
+ * object form.
8
+ *
9
+ * - Strings ending in `/` become `{ kind: "directory", path }` (slash stripped).
10
+ * - Other strings become `{ kind: "file", path }`.
11
+ * - Entries already in object form are normalized (kind validated, trailing
12
+ * slash stripped from directory paths).
13
+ *
14
+ * Runtime code only handles the object form; this migration is the exclusive
15
+ * owner of the legacy string shape.
16
+ */
17
+ export function shouldRun(config: GuardrailsConfig): boolean {
18
+ const raw = config as Record<string, unknown>;
19
+ const pathAccess = raw.pathAccess as Record<string, unknown> | undefined;
20
+ if (!Array.isArray(pathAccess?.allowedPaths)) return false;
21
+ return pathAccess.allowedPaths.some((item) => typeof item === "string");
22
+ }
23
+
24
+ export function run(config: GuardrailsConfig): GuardrailsConfig {
25
+ const migrated = structuredClone(config) as Record<string, unknown>;
26
+ const pathAccess = migrated.pathAccess as Record<string, unknown> | undefined;
27
+ if (pathAccess && Array.isArray(pathAccess.allowedPaths)) {
28
+ pathAccess.allowedPaths = (pathAccess.allowedPaths as unknown[])
29
+ .map((item) => toAllowedPath(item))
30
+ .filter(
31
+ (item): item is { kind: "file" | "directory"; path: string } =>
32
+ item !== null,
33
+ );
34
+ }
35
+ migrated.version = CURRENT_VERSION;
36
+ return migrated as GuardrailsConfig;
37
+ }
38
+
39
+ function toAllowedPath(
40
+ item: unknown,
41
+ ): { kind: "file" | "directory"; path: string } | null {
42
+ if (typeof item === "string") {
43
+ const trimmed = item.trim();
44
+ if (!trimmed) return null;
45
+ if (trimmed.endsWith("/")) {
46
+ return { kind: "directory", path: trimmed.slice(0, -1) };
47
+ }
48
+ return { kind: "file", path: trimmed };
49
+ }
50
+
51
+ if (item && typeof item === "object") {
52
+ const obj = item as { kind?: unknown; path?: unknown };
53
+ const path = typeof obj.path === "string" ? obj.path.trim() : "";
54
+ if (!path) return null;
55
+ if (obj.kind === "file" || obj.kind === "directory") {
56
+ const cleanPath =
57
+ obj.kind === "directory" && path.endsWith("/")
58
+ ? path.slice(0, -1)
59
+ : path;
60
+ return { kind: obj.kind, path: cleanPath };
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
@@ -8,6 +8,8 @@ import * as normalizeAllowedPaths from "./005-normalize-allowed-paths";
8
8
  import * as applyBuiltinDefaults from "./006-apply-builtin-defaults";
9
9
  import * as markOnboardingDone from "./007-mark-onboarding-done";
10
10
  import * as normalizeStringBooleans from "./008-normalize-string-booleans";
11
+ import * as allowDevNull from "./009-allow-dev-null";
12
+ import * as allowedPathsObjects from "./010-allowed-paths-objects";
11
13
 
12
14
  export { CURRENT_VERSION } from "./version";
13
15
 
@@ -21,26 +23,52 @@ export const migrations: Migration<GuardrailsConfig>[] = [
21
23
  name: "strip-toolchain-fields",
22
24
  shouldRun: stripToolchainFields.shouldRun,
23
25
  run: stripToolchainFields.run,
26
+ message:
27
+ "preventBrew, preventPython, enforcePackageManager, and packageManager " +
28
+ "have been removed from guardrails and moved to @aliou/pi-toolchain. " +
29
+ "These fields will be stripped from your config.",
24
30
  },
25
31
  {
26
32
  name: "strip-command-explainer-fields",
27
33
  shouldRun: stripCommandExplainerFields.shouldRun,
28
34
  run: stripCommandExplainerFields.run,
35
+ message:
36
+ "permissionGate.explainCommands, explainModel, and explainTimeout " +
37
+ "have been removed. These fields will be stripped from your config.",
29
38
  },
30
39
  {
31
- name: "envFiles-to-policies",
40
+ name: "env-files-to-policies",
32
41
  shouldRun: envFilesToPolicies.shouldRun,
33
42
  run: envFilesToPolicies.run,
43
+ message: envFilesToPolicies.message,
34
44
  },
35
45
  {
36
46
  name: "normalize-allowed-paths",
37
47
  shouldRun: normalizeAllowedPaths.shouldRun,
38
48
  run: normalizeAllowedPaths.run,
49
+ message:
50
+ "pathAccess.allowedPaths was migrated from pattern objects to path strings.",
39
51
  },
40
52
  {
41
53
  name: "normalize-string-booleans",
42
54
  shouldRun: normalizeStringBooleans.shouldRun,
43
55
  run: normalizeStringBooleans.run,
56
+ message:
57
+ "Config migrated: boolean settings stored as strings were converted to true/false.",
58
+ },
59
+ {
60
+ name: "allow-dev-null",
61
+ shouldRun: allowDevNull.shouldRun,
62
+ run: allowDevNull.run,
63
+ message:
64
+ "pathAccess.allowedPaths was migrated to allow /dev/null by default.",
65
+ },
66
+ {
67
+ name: "allowed-paths-objects",
68
+ shouldRun: allowedPathsObjects.shouldRun,
69
+ run: allowedPathsObjects.run,
70
+ message:
71
+ "pathAccess.allowedPaths was migrated from path strings to { kind, path } objects.",
44
72
  },
45
73
  ];
46
74
 
@@ -4,4 +4,4 @@
4
4
  * Keep this independent from package.json version.
5
5
  * Bump only when config schema/default migration markers change.
6
6
  */
7
- export const CURRENT_VERSION = "0.9.0-20260327";
7
+ export const CURRENT_VERSION = "0.13.0-20260619";
@@ -6,6 +6,14 @@
6
6
  */
7
7
  import type { GuardrailsFeatureId } from "../events";
8
8
 
9
+ /**
10
+ * A path grant with an explicit kind. Re-exported from the core path module so
11
+ * config consumers can import it from one place.
12
+ */
13
+ export type { AllowedPath } from "../../core/paths/path";
14
+
15
+ import type { AllowedPath } from "../../core/paths/path";
16
+
9
17
  /**
10
18
  * A pattern with explicit matching mode.
11
19
  * Default: glob for files, substring for commands.
@@ -60,7 +68,12 @@ export type PathAccessMode = "allow" | "ask" | "block";
60
68
 
61
69
  export interface PathAccessConfig {
62
70
  mode?: PathAccessMode;
63
- allowedPaths?: string[];
71
+ /**
72
+ * Paths always allowed, regardless of cwd. Each entry carries an explicit
73
+ * `kind`: `file` matches the exact path, `directory` matches the directory
74
+ * and its descendants.
75
+ */
76
+ allowedPaths?: AllowedPath[];
64
77
  }
65
78
 
66
79
  export interface GuardrailsConfig {
@@ -127,7 +140,7 @@ export interface ResolvedConfig {
127
140
  };
128
141
  pathAccess: {
129
142
  mode: PathAccessMode;
130
- allowedPaths: string[];
143
+ allowedPaths: AllowedPath[];
131
144
  };
132
145
  permissionGate: {
133
146
  patterns: DangerousPattern[];
@@ -3,4 +3,3 @@ export * from "./events";
3
3
  export * from "./glob";
4
4
  export * from "./matching";
5
5
  export * from "./paths";
6
- export * from "./warnings";
@@ -10,7 +10,6 @@
10
10
 
11
11
  import { matchesGlob } from "node:path";
12
12
  import type { PatternConfig } from "./config";
13
- import { addPendingWarning } from "./warnings";
14
13
 
15
14
  export interface CompiledPattern {
16
15
  test: (input: string) => boolean;
@@ -47,9 +46,10 @@ export function compileFilePattern(config: PatternConfig): CompiledPattern {
47
46
  source: config,
48
47
  };
49
48
  } catch {
50
- addPendingWarning(
51
- `Invalid regex in guardrails config: ${config.pattern}`,
52
- );
49
+ // TODO: surface invalid regex to the user via ctx.ui.notify once pattern
50
+ // compilation is pre-cached at extension setup (so it has ctx access and
51
+ // runs once, instead of re-firing on every tool call). For now the
52
+ // pattern silently matches nothing.
53
53
  return { test: () => false, source: config };
54
54
  }
55
55
  }
@@ -80,9 +80,10 @@ export function compileCommandPattern(config: PatternConfig): CompiledPattern {
80
80
  const re = new RegExp(config.pattern);
81
81
  return { test: (input) => re.test(input), source: config };
82
82
  } catch {
83
- addPendingWarning(
84
- `Invalid regex in guardrails config: ${config.pattern}`,
85
- );
83
+ // TODO: surface invalid regex to the user via ctx.ui.notify once pattern
84
+ // compilation is pre-cached at extension setup (so it has ctx access and
85
+ // runs once, instead of re-firing on every tool call). For now the
86
+ // pattern silently matches nothing.
86
87
  return { test: () => false, source: config };
87
88
  }
88
89
  }
@@ -1,17 +0,0 @@
1
- /**
2
- * Module-level warnings queue for messages that arise before any session
3
- * context is available (config loading, migration, pattern compilation).
4
- */
5
- const pendingWarnings: string[] = [];
6
-
7
- export function addPendingWarning(message: string): void {
8
- pendingWarnings.push(message);
9
- }
10
-
11
- export function getPendingWarnings(): readonly string[] {
12
- return pendingWarnings;
13
- }
14
-
15
- export function drainPendingWarnings(): string[] {
16
- return pendingWarnings.splice(0);
17
- }