@aliou/pi-guardrails 0.9.5 → 0.10.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.
@@ -258,6 +258,118 @@ const COMMAND_EXAMPLES: Array<{
258
258
  description: "Require confirmation for table drops",
259
259
  pattern: { pattern: "DROP TABLE", description: "SQL table drop" },
260
260
  },
261
+ {
262
+ label: "dbt run",
263
+ description: "Require confirmation for dbt model runs",
264
+ pattern: {
265
+ pattern: "dbt run",
266
+ description: "dbt model execution",
267
+ },
268
+ },
269
+ {
270
+ label: "dbt seed",
271
+ description: "Require confirmation for dbt seed data loading",
272
+ pattern: {
273
+ pattern: "dbt seed",
274
+ description: "dbt seed data loading",
275
+ },
276
+ },
277
+ {
278
+ label: "aws s3 rm",
279
+ description: "Require confirmation for AWS S3 deletions",
280
+ pattern: {
281
+ pattern: "aws s3 rm",
282
+ description: "AWS S3 object deletion",
283
+ },
284
+ },
285
+ {
286
+ label: "aws iam",
287
+ description: "Require confirmation for AWS IAM changes",
288
+ pattern: {
289
+ pattern: "aws iam",
290
+ description: "AWS IAM permission changes",
291
+ },
292
+ },
293
+ {
294
+ label: "aws ec2 terminate",
295
+ description: "Require confirmation for EC2 instance termination",
296
+ pattern: {
297
+ pattern: "aws ec2 terminate-instances",
298
+ description: "AWS EC2 instance termination",
299
+ },
300
+ },
301
+ {
302
+ label: "kubectl apply",
303
+ description: "Require confirmation for k8s resource application",
304
+ pattern: {
305
+ pattern: "kubectl apply",
306
+ description: "Kubernetes resource application",
307
+ },
308
+ },
309
+ {
310
+ label: "kubectl scale",
311
+ description: "Require confirmation for k8s scaling operations",
312
+ pattern: {
313
+ pattern: "kubectl scale",
314
+ description: "Kubernetes scaling operation",
315
+ },
316
+ },
317
+ {
318
+ label: "docker rm",
319
+ description: "Require confirmation for Docker container removal",
320
+ pattern: {
321
+ pattern: "docker rm",
322
+ description: "Docker container removal",
323
+ },
324
+ },
325
+ {
326
+ label: "docker rmi",
327
+ description: "Require confirmation for Docker image removal",
328
+ pattern: {
329
+ pattern: "docker rmi",
330
+ description: "Docker image removal",
331
+ },
332
+ },
333
+ {
334
+ label: "docker compose down",
335
+ description: "Require confirmation for Docker Compose teardown",
336
+ pattern: {
337
+ pattern: "docker compose down",
338
+ description: "Docker Compose service teardown",
339
+ },
340
+ },
341
+ {
342
+ label: "terraform import",
343
+ description: "Require confirmation for Terraform resource import",
344
+ pattern: {
345
+ pattern: "terraform import",
346
+ description: "Terraform resource import",
347
+ },
348
+ },
349
+ {
350
+ label: "gcloud compute delete",
351
+ description: "Require confirmation for GCP compute instance deletion",
352
+ pattern: {
353
+ pattern: "gcloud compute instances delete",
354
+ description: "GCP compute instance deletion",
355
+ },
356
+ },
357
+ {
358
+ label: "gcloud iam",
359
+ description: "Require confirmation for GCP IAM changes",
360
+ pattern: {
361
+ pattern: "gcloud iam",
362
+ description: "GCP IAM permission changes",
363
+ },
364
+ },
365
+ {
366
+ label: "gcloud sql delete",
367
+ description: "Require confirmation for GCP SQL instance deletion",
368
+ pattern: {
369
+ pattern: "gcloud sql instances delete",
370
+ description: "GCP Cloud SQL instance deletion",
371
+ },
372
+ },
261
373
  ];
262
374
 
263
375
  function toKebabCase(input: string): string {
@@ -845,7 +957,7 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
845
957
  buildSections: (
846
958
  tabConfig: GuardrailsConfig | null,
847
959
  _resolved: ResolvedConfig,
848
- { setDraft, theme },
960
+ { setDraft, theme, scope },
849
961
  ): SettingsSection[] => {
850
962
  const settingsTheme = theme;
851
963
  let scopedConfig = structuredClone(tabConfig ?? {}) as GuardrailsConfig;
@@ -1005,9 +1117,11 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1005
1117
  return scopedConfig.permissionGate?.explainTimeout ?? null;
1006
1118
  }
1007
1119
 
1008
- const featureItems = (Object.keys(FEATURE_UI) as FeatureKey[])
1120
+ const featureItems: SettingItem[] = (
1121
+ Object.keys(FEATURE_UI) as FeatureKey[]
1122
+ )
1009
1123
  .filter((key) => key !== "policies")
1010
- .map((key) => {
1124
+ .map((key): SettingItem => {
1011
1125
  const scopedValue = scopedConfig.features?.[key];
1012
1126
  return {
1013
1127
  id: `features.${key}`,
@@ -1023,6 +1137,18 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1023
1137
  };
1024
1138
  });
1025
1139
 
1140
+ if (scope === "global") {
1141
+ featureItems.push({
1142
+ id: "onboarding.run",
1143
+ label: "Onboarding status",
1144
+ description: "Use /guardrails:onboarding to run onboarding",
1145
+ currentValue:
1146
+ scopedConfig.onboarding?.completed === true
1147
+ ? "completed"
1148
+ : "pending",
1149
+ });
1150
+ }
1151
+
1026
1152
  const policyRules = getPolicyRules();
1027
1153
 
1028
1154
  const openPolicyEditor = (
package/src/config.ts CHANGED
@@ -56,6 +56,13 @@ export interface PolicyRule {
56
56
  export interface GuardrailsConfig {
57
57
  version?: string;
58
58
  enabled?: boolean;
59
+ /** Deprecated-defaults bridge: when true, applies built-in policy defaults. */
60
+ applyBuiltinDefaults?: boolean;
61
+ onboarding?: {
62
+ completed?: boolean;
63
+ completedAt?: string;
64
+ version?: string;
65
+ };
59
66
  features?: {
60
67
  policies?: boolean;
61
68
  permissionGate?: boolean;
@@ -90,6 +97,7 @@ export interface GuardrailsConfig {
90
97
  export interface ResolvedConfig {
91
98
  version: string;
92
99
  enabled: boolean;
100
+ applyBuiltinDefaults: boolean;
93
101
  features: {
94
102
  policies: boolean;
95
103
  permissionGate: boolean;
@@ -187,6 +195,7 @@ const migrations: Migration<GuardrailsConfig>[] = [
187
195
  const DEFAULT_CONFIG: ResolvedConfig = {
188
196
  version: CURRENT_VERSION,
189
197
  enabled: true,
198
+ applyBuiltinDefaults: true,
190
199
  features: {
191
200
  policies: true,
192
201
  permissionGate: true,
@@ -217,6 +226,51 @@ const DEFAULT_CONFIG: ResolvedConfig = {
217
226
  "Accessing {file} is not allowed. This file contains secrets. " +
218
227
  "Explain to the user why you want to access this file, and if changes are needed ask the user to make them.",
219
228
  },
229
+ {
230
+ id: "home-ssh",
231
+ description: "SSH directory and keys",
232
+ enabled: false,
233
+ patterns: [
234
+ { pattern: "~/.ssh/**" },
235
+ { pattern: "~/.ssh/*_rsa" },
236
+ { pattern: "~/.ssh/*_ed25519" },
237
+ { pattern: "~/.ssh/*.pem" },
238
+ ],
239
+ allowedPatterns: [{ pattern: "~/.ssh/*.pub" }],
240
+ protection: "noAccess",
241
+ onlyIfExists: true,
242
+ blockMessage:
243
+ "Accessing {file} is not allowed. This file is part of your SSH configuration and may contain private keys or sensitive host information.",
244
+ },
245
+ {
246
+ id: "home-config",
247
+ description: "Sensitive user configuration directories",
248
+ enabled: false,
249
+ patterns: [
250
+ { pattern: "~/.config/gh/**" },
251
+ { pattern: "~/.config/gcloud/**" },
252
+ { pattern: "~/.config/op/**" },
253
+ { pattern: "~/.config/sops/**" },
254
+ ],
255
+ protection: "noAccess",
256
+ onlyIfExists: true,
257
+ blockMessage:
258
+ "Accessing {file} is not allowed. This file is in a sensitive user configuration directory and may contain credentials or tokens.",
259
+ },
260
+ {
261
+ id: "home-gpg",
262
+ description: "GPG keys and configuration",
263
+ enabled: false,
264
+ patterns: [
265
+ { pattern: "~/.gnupg/**" },
266
+ { pattern: "~/*.gpg" },
267
+ { pattern: "~/.gpg-agent.conf" },
268
+ ],
269
+ protection: "noAccess",
270
+ onlyIfExists: true,
271
+ blockMessage:
272
+ "Accessing {file} is not allowed. This file is part of your GPG configuration and may contain private keys or trust settings.",
273
+ },
220
274
  ],
221
275
  },
222
276
  permissionGate: {
@@ -250,8 +304,10 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
250
304
  afterMerge: (resolved, global, local, memory) => {
251
305
  const ruleMap = new Map<string, PolicyRule>();
252
306
 
253
- for (const rule of DEFAULT_CONFIG.policies.rules) {
254
- ruleMap.set(rule.id, rule);
307
+ if (resolved.applyBuiltinDefaults) {
308
+ for (const rule of DEFAULT_CONFIG.policies.rules) {
309
+ ruleMap.set(rule.id, rule);
310
+ }
255
311
  }
256
312
  if (global?.policies?.rules) {
257
313
  for (const rule of global.policies.rules) {
@@ -14,6 +14,8 @@ import {
14
14
  matchesKey,
15
15
  Spacer,
16
16
  Text,
17
+ truncateToWidth,
18
+ visibleWidth,
17
19
  wrapTextWithAnsi,
18
20
  } from "@mariozechner/pi-tui";
19
21
  import type { DangerousPattern, ResolvedConfig } from "../config";
@@ -96,6 +98,250 @@ interface CommandExplanation {
96
98
  provider: string;
97
99
  }
98
100
 
101
+ interface MinimalTheme {
102
+ fg(color: string, text: string): string;
103
+ bg(color: string, text: string): string;
104
+ bold(text: string): string;
105
+ }
106
+
107
+ interface NumberedWrappedRow {
108
+ logicalLineNumber: number;
109
+ rendered: string;
110
+ }
111
+
112
+ interface CommandViewportState {
113
+ maxScrollOffset: number;
114
+ pinnedRows: NumberedWrappedRow[];
115
+ scrollWindowLines: number;
116
+ scrollableRows: NumberedWrappedRow[];
117
+ }
118
+
119
+ const COMMAND_VIEWPORT_LINES = 12;
120
+ const BUILTIN_KEYWORD_PATTERNS = new Set([
121
+ "rm -rf",
122
+ "sudo",
123
+ "dd if=",
124
+ "mkfs.",
125
+ "chmod -R 777",
126
+ "chown -R",
127
+ ]);
128
+
129
+ function buildNumberedWrappedLines(
130
+ command: string,
131
+ contentWidth: number,
132
+ theme: Pick<MinimalTheme, "fg">,
133
+ ): NumberedWrappedRow[] {
134
+ const logicalLines = command.split("\n");
135
+ const lineNumberWidth = Math.max(2, String(logicalLines.length).length);
136
+ const prefixSpacing = 1;
137
+ const textWidth = Math.max(1, contentWidth - lineNumberWidth - prefixSpacing);
138
+ const rows: Array<{ logicalLineNumber: number; rendered: string }> = [];
139
+
140
+ for (const [index, logicalLine] of logicalLines.entries()) {
141
+ const lineNumber = index + 1;
142
+ const wrapped = wrapTextWithAnsi(theme.fg("text", logicalLine), textWidth);
143
+ const wrappedLines = wrapped.length > 0 ? wrapped : [""];
144
+ const prefix = theme.fg(
145
+ "dim",
146
+ String(lineNumber).padStart(lineNumberWidth),
147
+ );
148
+
149
+ for (const line of wrappedLines) {
150
+ rows.push({
151
+ logicalLineNumber: lineNumber,
152
+ rendered: `${prefix} ${line}`,
153
+ });
154
+ }
155
+ }
156
+
157
+ return rows;
158
+ }
159
+
160
+ function getCommandViewportState(
161
+ command: string,
162
+ contentWidth: number,
163
+ theme: Pick<MinimalTheme, "fg">,
164
+ ): CommandViewportState {
165
+ const numberedRows = buildNumberedWrappedLines(command, contentWidth, theme);
166
+ const pinnedRows = numberedRows.filter((row) => row.logicalLineNumber === 1);
167
+ const scrollableRows = numberedRows.filter(
168
+ (row) => row.logicalLineNumber !== 1,
169
+ );
170
+ const scrollWindowLines = Math.max(
171
+ 0,
172
+ COMMAND_VIEWPORT_LINES - pinnedRows.length,
173
+ );
174
+
175
+ return {
176
+ maxScrollOffset: Math.max(0, scrollableRows.length - scrollWindowLines),
177
+ pinnedRows,
178
+ scrollWindowLines,
179
+ scrollableRows,
180
+ };
181
+ }
182
+
183
+ function buildRightAlignedBorder(
184
+ width: number,
185
+ themeLine: (s: string) => string,
186
+ label: string,
187
+ ): string {
188
+ const safeWidth = Math.max(1, width);
189
+ const truncatedLabel = truncateToWidth(label, safeWidth);
190
+ const remaining = safeWidth - visibleWidth(truncatedLabel);
191
+ return themeLine("─".repeat(Math.max(0, remaining)) + truncatedLabel);
192
+ }
193
+
194
+ function createPermissionGateConfirmComponent(
195
+ command: string,
196
+ description: string,
197
+ explanation: CommandExplanation | null,
198
+ ) {
199
+ return (
200
+ tui: { terminal: { rows: number; columns: number }; requestRender(): void },
201
+ theme: MinimalTheme,
202
+ _kb: unknown,
203
+ done: (result: "allow" | "allow-session" | "deny") => void,
204
+ ) => {
205
+ const container = new Container();
206
+ const redBorder = (s: string) => theme.fg("error", s);
207
+ const dimBorder = (s: string) => theme.fg("dim", s);
208
+ let scrollOffset = 0;
209
+
210
+ if (explanation) {
211
+ const explanationBox = new Box(1, 1, (s: string) =>
212
+ theme.bg("customMessageBg", s),
213
+ );
214
+ explanationBox.addChild(
215
+ new Text(
216
+ theme.fg(
217
+ "accent",
218
+ theme.bold(
219
+ `Model explanation (${explanation.modelName} / ${explanation.modelId} / ${explanation.provider})`,
220
+ ),
221
+ ),
222
+ 0,
223
+ 0,
224
+ ),
225
+ );
226
+ explanationBox.addChild(new Spacer(1));
227
+ explanationBox.addChild(
228
+ new Markdown(explanation.text, 0, 0, getMarkdownTheme(), {
229
+ color: (s: string) => theme.fg("text", s),
230
+ }),
231
+ );
232
+ container.addChild(explanationBox);
233
+ }
234
+ container.addChild(new DynamicBorder(redBorder));
235
+ container.addChild(
236
+ new Text(
237
+ theme.fg("error", theme.bold("Dangerous Command Detected")),
238
+ 1,
239
+ 0,
240
+ ),
241
+ );
242
+ container.addChild(new Spacer(1));
243
+ container.addChild(
244
+ new Text(
245
+ theme.fg("warning", `This command contains ${description}:`),
246
+ 1,
247
+ 0,
248
+ ),
249
+ );
250
+ container.addChild(new Spacer(1));
251
+ const commandTopBorder = new Text("", 0, 0);
252
+ container.addChild(commandTopBorder);
253
+ const commandText = new Text("", 1, 0);
254
+ container.addChild(commandText);
255
+ const commandBottomBorder = new Text("", 0, 0);
256
+ container.addChild(commandBottomBorder);
257
+ container.addChild(new Spacer(1));
258
+ container.addChild(new Text(theme.fg("text", "Allow execution?"), 1, 0));
259
+ container.addChild(new Spacer(1));
260
+ container.addChild(
261
+ new Text(
262
+ theme.fg(
263
+ "dim",
264
+ "↑/↓ or j/k: scroll • y/enter: allow • a: session • n/esc: deny",
265
+ ),
266
+ 1,
267
+ 0,
268
+ ),
269
+ );
270
+ container.addChild(new DynamicBorder(redBorder));
271
+
272
+ return {
273
+ render: (width: number) => {
274
+ const contentWidth = Math.max(1, width - 4);
275
+ const {
276
+ maxScrollOffset,
277
+ pinnedRows,
278
+ scrollWindowLines,
279
+ scrollableRows,
280
+ } = getCommandViewportState(command, contentWidth, theme);
281
+ scrollOffset = Math.max(0, Math.min(scrollOffset, maxScrollOffset));
282
+
283
+ const visibleScrollableRows = scrollableRows.slice(
284
+ scrollOffset,
285
+ scrollOffset + scrollWindowLines,
286
+ );
287
+ const visibleRows = [...pinnedRows, ...visibleScrollableRows];
288
+ const linesBelow = Math.max(
289
+ 0,
290
+ scrollableRows.length - (scrollOffset + visibleScrollableRows.length),
291
+ );
292
+
293
+ commandTopBorder.setText(
294
+ buildRightAlignedBorder(
295
+ width,
296
+ dimBorder,
297
+ scrollOffset > 0 ? `↑ ${scrollOffset} more` : "",
298
+ ),
299
+ );
300
+ commandText.setText(visibleRows.map((row) => row.rendered).join("\n"));
301
+ commandBottomBorder.setText(
302
+ buildRightAlignedBorder(
303
+ width,
304
+ dimBorder,
305
+ linesBelow > 0 ? `↓ ${linesBelow} more` : "",
306
+ ),
307
+ );
308
+ return container.render(width);
309
+ },
310
+ invalidate: () => container.invalidate(),
311
+ handleInput: (data: string) => {
312
+ const contentWidth = Math.max(1, tui.terminal.columns - 4);
313
+ const { maxScrollOffset } = getCommandViewportState(
314
+ command,
315
+ contentWidth,
316
+ theme,
317
+ );
318
+
319
+ if (matchesKey(data, Key.up) || data === "k") {
320
+ scrollOffset = Math.max(0, scrollOffset - 1);
321
+ tui.requestRender();
322
+ } else if (matchesKey(data, Key.down) || data === "j") {
323
+ scrollOffset = Math.min(maxScrollOffset, scrollOffset + 1);
324
+ tui.requestRender();
325
+ } else if (
326
+ matchesKey(data, Key.enter) ||
327
+ data === "y" ||
328
+ data === "Y"
329
+ ) {
330
+ done("allow");
331
+ } else if (data === "a" || data === "A") {
332
+ done("allow-session");
333
+ } else if (
334
+ matchesKey(data, Key.escape) ||
335
+ data === "n" ||
336
+ data === "N"
337
+ ) {
338
+ done("deny");
339
+ }
340
+ },
341
+ };
342
+ };
343
+ }
344
+
99
345
  async function explainCommand(
100
346
  command: string,
101
347
  modelSpec: string,
@@ -217,22 +463,13 @@ function findDangerousMatch(
217
463
 
218
464
  // When structural parsing succeeds, skip raw substring fallback for built-in
219
465
  // keyword patterns to avoid false positives in quoted args/messages.
220
- const builtInKeywordPatterns = new Set([
221
- "rm -rf",
222
- "sudo",
223
- "dd if=",
224
- "mkfs.",
225
- "chmod -R 777",
226
- "chown -R",
227
- ]);
228
-
229
466
  for (const cp of compiledPatterns) {
230
467
  const src = cp.source as DangerousPattern;
231
468
  if (
232
469
  useBuiltinMatchers &&
233
470
  parsedSuccessfully &&
234
471
  !src.regex &&
235
- builtInKeywordPatterns.has(src.pattern)
472
+ BUILTIN_KEYWORD_PATTERNS.has(src.pattern)
236
473
  ) {
237
474
  continue;
238
475
  }
@@ -344,101 +581,7 @@ export function setupPermissionGateHook(
344
581
  type ConfirmResult = "allow" | "allow-session" | "deny";
345
582
 
346
583
  const result = await ctx.ui.custom<ConfirmResult>(
347
- (_tui, theme, _kb, done) => {
348
- const container = new Container();
349
- const redBorder = (s: string) => theme.fg("error", s);
350
-
351
- if (explanation) {
352
- const explanationBox = new Box(1, 1, (s: string) =>
353
- theme.bg("customMessageBg", s),
354
- );
355
- explanationBox.addChild(
356
- new Text(
357
- theme.fg(
358
- "accent",
359
- theme.bold(
360
- `Model explanation (${explanation.modelName} / ${explanation.modelId} / ${explanation.provider})`,
361
- ),
362
- ),
363
- 0,
364
- 0,
365
- ),
366
- );
367
- explanationBox.addChild(new Spacer(1));
368
- explanationBox.addChild(
369
- new Markdown(explanation.text, 0, 0, getMarkdownTheme(), {
370
- color: (s: string) => theme.fg("text", s),
371
- }),
372
- );
373
- container.addChild(explanationBox);
374
- }
375
- container.addChild(new DynamicBorder(redBorder));
376
- container.addChild(
377
- new Text(
378
- theme.fg("error", theme.bold("Dangerous Command Detected")),
379
- 1,
380
- 0,
381
- ),
382
- );
383
- container.addChild(new Spacer(1));
384
- container.addChild(
385
- new Text(
386
- theme.fg("warning", `This command contains ${description}:`),
387
- 1,
388
- 0,
389
- ),
390
- );
391
- container.addChild(new Spacer(1));
392
- container.addChild(
393
- new DynamicBorder((s: string) => theme.fg("muted", s)),
394
- );
395
- const commandText = new Text("", 1, 0);
396
- container.addChild(commandText);
397
- container.addChild(
398
- new DynamicBorder((s: string) => theme.fg("muted", s)),
399
- );
400
- container.addChild(new Spacer(1));
401
- container.addChild(
402
- new Text(theme.fg("text", "Allow execution?"), 1, 0),
403
- );
404
- container.addChild(new Spacer(1));
405
- container.addChild(
406
- new Text(
407
- theme.fg(
408
- "dim",
409
- "y/enter: allow • a: allow for session • n/esc: deny",
410
- ),
411
- 1,
412
- 0,
413
- ),
414
- );
415
- container.addChild(new DynamicBorder(redBorder));
416
-
417
- return {
418
- render: (width: number) => {
419
- const wrappedCommand = wrapTextWithAnsi(
420
- theme.fg("text", command),
421
- width - 4,
422
- ).join("\n");
423
- commandText.setText(wrappedCommand);
424
- return container.render(width);
425
- },
426
- invalidate: () => container.invalidate(),
427
- handleInput: (data: string) => {
428
- if (matchesKey(data, Key.enter) || data === "y" || data === "Y") {
429
- done("allow");
430
- } else if (data === "a" || data === "A") {
431
- done("allow-session");
432
- } else if (
433
- matchesKey(data, Key.escape) ||
434
- data === "n" ||
435
- data === "N"
436
- ) {
437
- done("deny");
438
- }
439
- },
440
- };
441
- },
584
+ createPermissionGateConfirmComponent(command, description, explanation),
442
585
  );
443
586
 
444
587
  if (result === "allow-session") {
@@ -10,6 +10,7 @@ import {
10
10
  compileFilePatterns,
11
11
  normalizeFilePath,
12
12
  } from "../utils/matching";
13
+ import { expandHomePath } from "../utils/path";
13
14
  import { walkCommands, wordToString } from "../utils/shell-utils";
14
15
  import { pendingWarnings } from "../utils/warnings";
15
16
 
@@ -37,9 +38,9 @@ interface CompiledRule {
37
38
  enabled: boolean;
38
39
  }
39
40
 
40
- async function fileExists(cwd: string, filePath: string): Promise<boolean> {
41
+ async function fileExists(filePath: string, cwd: string): Promise<boolean> {
41
42
  try {
42
- await stat(resolve(cwd, filePath));
43
+ await stat(resolvePolicyPath(filePath, cwd));
43
44
  return true;
44
45
  } catch {
45
46
  return false;
@@ -118,8 +119,19 @@ function maybePathLike(token: string): boolean {
118
119
  }
119
120
 
120
121
  function normalizeTargetForPolicy(filePath: string, cwd: string): string {
121
- const absolute = resolve(cwd, filePath);
122
+ if (filePath === "~" || filePath.startsWith("~/")) {
123
+ return normalizeFilePath(filePath);
124
+ }
125
+
126
+ const expanded = expandHomePath(filePath);
127
+ const absolute = resolve(cwd, expanded);
122
128
  const rel = relative(cwd, absolute);
129
+ const normalizedHome = normalizeFilePath(expandHomePath("~"));
130
+ const normalizedAbsolute = normalizeFilePath(absolute);
131
+
132
+ if (normalizedAbsolute.startsWith(`${normalizedHome}/`)) {
133
+ return normalizeFilePath(`~/${relative(expandHomePath("~"), absolute)}`);
134
+ }
123
135
 
124
136
  const candidate =
125
137
  rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : absolute;
@@ -127,6 +139,10 @@ function normalizeTargetForPolicy(filePath: string, cwd: string): string {
127
139
  return normalizeFilePath(candidate);
128
140
  }
129
141
 
142
+ function resolvePolicyPath(filePath: string, cwd: string): string {
143
+ return resolve(cwd, expandHomePath(filePath));
144
+ }
145
+
130
146
  function matchesAnyPolicyPattern(
131
147
  filePath: string,
132
148
  rules: CompiledRule[],
@@ -236,7 +252,7 @@ async function getEffectiveProtection(
236
252
  );
237
253
  if (allowed) continue;
238
254
 
239
- if (rule.onlyIfExists && !(await fileExists(cwd, filePath))) continue;
255
+ if (rule.onlyIfExists && !(await fileExists(filePath, cwd))) continue;
240
256
 
241
257
  const rank = protectionRank(rule.protection);
242
258
  if (!bestMatch || rank > bestMatch.rank) {