@aliou/pi-guardrails 0.11.2 → 0.12.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 (95) hide show
  1. package/README.md +72 -167
  2. package/extensions/guardrails/commands/examples/index.ts +520 -0
  3. package/extensions/guardrails/commands/onboarding/config.ts +54 -0
  4. package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
  5. package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
  6. package/extensions/guardrails/commands/settings/examples.ts +399 -0
  7. package/extensions/guardrails/commands/settings/index.ts +596 -0
  8. package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
  9. package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
  10. package/extensions/guardrails/commands/settings/utils.ts +108 -0
  11. package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
  12. package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
  13. package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
  14. package/extensions/guardrails/components/onboarding-types.ts +10 -0
  15. package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
  16. package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
  17. package/extensions/guardrails/index.ts +106 -0
  18. package/extensions/guardrails/rules.test.ts +107 -0
  19. package/extensions/guardrails/rules.ts +119 -0
  20. package/extensions/guardrails/targets.test.ts +44 -0
  21. package/extensions/guardrails/targets.ts +66 -0
  22. package/extensions/path-access/grants.test.ts +47 -0
  23. package/extensions/path-access/grants.ts +68 -0
  24. package/extensions/path-access/index.ts +143 -0
  25. package/extensions/path-access/prompt.ts +196 -0
  26. package/extensions/path-access/rules.test.ts +46 -0
  27. package/extensions/path-access/rules.ts +37 -0
  28. package/extensions/path-access/targets.test.ts +40 -0
  29. package/extensions/path-access/targets.ts +19 -0
  30. package/extensions/permission-gate/grants.ts +21 -0
  31. package/extensions/permission-gate/index.ts +122 -0
  32. package/extensions/permission-gate/prompt.ts +222 -0
  33. package/extensions/permission-gate/rules.test.ts +132 -0
  34. package/extensions/permission-gate/rules.ts +72 -0
  35. package/package.json +18 -20
  36. package/schema.json +286 -0
  37. package/src/core/check.test.ts +169 -0
  38. package/src/core/check.ts +38 -0
  39. package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
  40. package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
  41. package/src/core/commands/index.ts +15 -0
  42. package/src/core/index.ts +13 -0
  43. package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
  44. package/src/core/paths/index.ts +14 -0
  45. package/src/core/shell/command-args.test.ts +142 -0
  46. package/src/{utils → core/shell}/command-args.ts +71 -0
  47. package/src/core/shell/index.ts +2 -0
  48. package/src/core/types.ts +55 -0
  49. package/src/shared/config/defaults.ts +118 -0
  50. package/src/shared/config/index.ts +17 -0
  51. package/src/shared/config/loader.ts +64 -0
  52. package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
  53. package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
  54. package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
  55. package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
  56. package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
  57. package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
  58. package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
  59. package/src/shared/config/migration/index.ts +44 -0
  60. package/src/shared/config/migration/version.ts +7 -0
  61. package/src/shared/config/types.ts +141 -0
  62. package/src/shared/events.ts +100 -0
  63. package/src/shared/index.ts +6 -0
  64. package/src/shared/matching.test.ts +86 -0
  65. package/src/{utils → shared}/matching.ts +4 -4
  66. package/src/{utils → shared/paths}/bash-paths.test.ts +32 -2
  67. package/src/{utils → shared/paths}/bash-paths.ts +4 -4
  68. package/src/shared/paths/index.ts +1 -0
  69. package/src/shared/warnings.ts +17 -0
  70. package/docs/defaults.md +0 -140
  71. package/docs/examples.md +0 -170
  72. package/src/commands/onboarding.ts +0 -390
  73. package/src/commands/settings-command.ts +0 -1616
  74. package/src/config.ts +0 -392
  75. package/src/hooks/index.ts +0 -11
  76. package/src/hooks/path-access.ts +0 -395
  77. package/src/hooks/permission-gate/index.test.ts +0 -332
  78. package/src/hooks/permission-gate/index.ts +0 -595
  79. package/src/hooks/policies.ts +0 -322
  80. package/src/index.ts +0 -96
  81. package/src/lib/executor.ts +0 -280
  82. package/src/lib/index.ts +0 -16
  83. package/src/lib/model-resolver.ts +0 -47
  84. package/src/lib/timing.ts +0 -42
  85. package/src/lib/types.ts +0 -115
  86. package/src/utils/command-args.test.ts +0 -83
  87. package/src/utils/events.ts +0 -32
  88. package/src/utils/migration.test.ts +0 -58
  89. package/src/utils/migration.ts +0 -340
  90. package/src/utils/warnings.ts +0 -7
  91. /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
  92. /package/src/{utils → core/paths}/path.test.ts +0 -0
  93. /package/src/{utils → core/paths}/path.ts +0 -0
  94. /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
  95. /package/src/{utils/glob-expander.ts → shared/glob.ts} +0 -0
@@ -1,11 +1,12 @@
1
- import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
1
+ import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  Input,
4
4
  Key,
5
5
  matchesKey,
6
6
  truncateToWidth,
7
7
  visibleWidth,
8
- } from "@mariozechner/pi-tui";
8
+ } from "@earendil-works/pi-tui";
9
+ import type { Action } from "../../../src/core";
9
10
 
10
11
  /**
11
12
  * A submenu component for editing an array of {pattern, description, regex?} objects.
@@ -16,7 +17,7 @@ import {
16
17
  * Escape to cancel.
17
18
  */
18
19
 
19
- export interface PatternItem {
20
+ export interface EditorPatternItem {
20
21
  pattern: string;
21
22
  description: string;
22
23
  regex?: boolean;
@@ -24,24 +25,24 @@ export interface PatternItem {
24
25
 
25
26
  export interface PatternEditorOptions {
26
27
  label: string;
27
- items: PatternItem[];
28
+ items: EditorPatternItem[];
28
29
  theme: SettingsListTheme;
29
- onSave: (items: PatternItem[]) => void;
30
+ onSave: (items: EditorPatternItem[]) => void;
30
31
  onDone: () => void;
31
32
  /** Context hint for the pattern field label. */
32
- context?: "file" | "command";
33
+ context?: Action["kind"];
33
34
  maxVisible?: number;
34
35
  }
35
36
 
36
37
  type Field = "pattern" | "description" | "regex";
37
38
 
38
39
  export class PatternEditor implements Component {
39
- private items: PatternItem[];
40
+ private items: EditorPatternItem[];
40
41
  private label: string;
41
42
  private theme: SettingsListTheme;
42
- private onSave: (items: PatternItem[]) => void;
43
+ private onSave: (items: EditorPatternItem[]) => void;
43
44
  private onDone: () => void;
44
- private context: "file" | "command";
45
+ private context: Action["kind"];
45
46
  private selectedIndex = 0;
46
47
  private maxVisible: number;
47
48
  private mode: "list" | "add" | "edit" = "list";
@@ -98,7 +99,7 @@ export class PatternEditor implements Component {
98
99
  return;
99
100
  }
100
101
 
101
- const item: PatternItem = {
102
+ const item: EditorPatternItem = {
102
103
  pattern,
103
104
  description: description || pattern,
104
105
  };
@@ -0,0 +1,106 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { checkAction } from "../../src/core";
3
+ import { configLoader } from "../../src/shared/config";
4
+ import {
5
+ createFeatureRequestPayload,
6
+ emitActionBlocked,
7
+ GUARDRAILS_FEATURE_REGISTER_EVENT,
8
+ GUARDRAILS_FEATURE_REQUEST_EVENT,
9
+ type GuardrailsFeatureId,
10
+ type GuardrailsFeatureRegisterPayload,
11
+ } from "../../src/shared/events";
12
+ import { drainPendingWarnings } from "../../src/shared/warnings";
13
+ import { registerGuardrailsExamplesCommand } from "./commands/examples";
14
+ import { registerGuardrailsOnboardingCommand } from "./commands/onboarding";
15
+ import { isOnboardingPending } from "./commands/onboarding/config";
16
+ import { registerGuardrailsSettings } from "./commands/settings";
17
+ import {
18
+ BLOCKED_TOOLS,
19
+ compilePolicies,
20
+ createPolicyRules,
21
+ protectionRank,
22
+ } from "./rules";
23
+ import { extractTargets } from "./targets";
24
+
25
+ function setupPolicyHook(pi: ExtensionAPI): void {
26
+ pi.on("tool_call", async (event, ctx) => {
27
+ const config = configLoader.getConfig();
28
+ if (!config.enabled || !config.features.policies) return;
29
+
30
+ const policies = compilePolicies(config.policies.rules)
31
+ .filter((policy) => BLOCKED_TOOLS[policy.protection].has(event.toolName))
32
+ .sort(
33
+ (a, b) => protectionRank(b.protection) - protectionRank(a.protection),
34
+ );
35
+ if (policies.length === 0) return;
36
+
37
+ const input = event.input as Record<string, unknown>;
38
+ const targets = await extractTargets(
39
+ { toolName: event.toolName, input },
40
+ ctx.cwd,
41
+ policies,
42
+ );
43
+ const rules = createPolicyRules(policies, ctx.cwd);
44
+
45
+ for (const target of targets) {
46
+ const safety = await checkAction(
47
+ { kind: "file", path: target, origin: event.toolName },
48
+ rules,
49
+ );
50
+ if (safety.kind === "safe") continue;
51
+
52
+ emitActionBlocked(pi, {
53
+ feature: "policies",
54
+ action: safety.action,
55
+ reason: safety.reason,
56
+ block: { source: "policy", metadata: safety.metadata },
57
+ context: { toolName: event.toolName, input },
58
+ });
59
+ return { block: true, reason: safety.reason };
60
+ }
61
+ });
62
+ }
63
+
64
+ export default async function guardrails(pi: ExtensionAPI) {
65
+ await configLoader.load();
66
+
67
+ const loadedFeatures = new Set<GuardrailsFeatureId>(["policies"]);
68
+
69
+ pi.events.on(GUARDRAILS_FEATURE_REGISTER_EVENT, (data: unknown) => {
70
+ const payload = data as GuardrailsFeatureRegisterPayload;
71
+ loadedFeatures.add(payload.feature.id);
72
+ });
73
+
74
+ registerGuardrailsSettings(pi, {
75
+ getLoadedFeatures: () => loadedFeatures,
76
+ });
77
+
78
+ registerGuardrailsExamplesCommand(pi);
79
+ if (isOnboardingPending(configLoader.getRawConfig("global"))) {
80
+ registerGuardrailsOnboardingCommand(pi);
81
+ }
82
+ setupPolicyHook(pi);
83
+
84
+ pi.on("session_start", (_event, ctx) => {
85
+ loadedFeatures.clear();
86
+ loadedFeatures.add("policies");
87
+
88
+ pi.events.emit(
89
+ GUARDRAILS_FEATURE_REQUEST_EVENT,
90
+ createFeatureRequestPayload(),
91
+ );
92
+
93
+ const warnings = drainPendingWarnings();
94
+ if (warnings.length === 1) {
95
+ ctx.ui.notify(warnings[0], "warning");
96
+ } else if (warnings.length > 1) {
97
+ ctx.ui.notify(
98
+ [
99
+ "Guardrails warnings:",
100
+ ...warnings.map((warning) => `- ${warning}`),
101
+ ].join("\n"),
102
+ "warning",
103
+ );
104
+ }
105
+ });
106
+ }
@@ -0,0 +1,107 @@
1
+ import { join } from "node:path";
2
+ import { vol } from "memfs";
3
+ import { describe, expect, it } from "vitest";
4
+ import { compilePolicies, createPolicyRules, normalizeTarget } from "./rules";
5
+
6
+ function singleRule(
7
+ cwd: string,
8
+ policy: Parameters<typeof compilePolicies>[0][number],
9
+ ) {
10
+ const [rule] = createPolicyRules(compilePolicies([policy]), cwd);
11
+ return rule;
12
+ }
13
+
14
+ describe("normalizeTarget", () => {
15
+ it("prefers cwd-relative paths for targets inside cwd", () => {
16
+ const cwd = "/repo";
17
+ expect(normalizeTarget("/repo/config/locked.json", cwd)).toBe(
18
+ "config/locked.json",
19
+ );
20
+ });
21
+ });
22
+
23
+ describe("compilePolicies", () => {
24
+ it("skips disabled and empty rules", () => {
25
+ const policies = compilePolicies([
26
+ {
27
+ id: "disabled",
28
+ name: "Disabled",
29
+ enabled: false,
30
+ patterns: [{ pattern: "*.env" }],
31
+ protection: "noAccess",
32
+ },
33
+ { id: "empty", name: "Empty", patterns: [], protection: "noAccess" },
34
+ {
35
+ id: "active",
36
+ name: "Active",
37
+ patterns: [{ pattern: "*.env" }],
38
+ protection: "readOnly",
39
+ },
40
+ ]);
41
+
42
+ expect(policies.map((policy) => policy.id)).toEqual(["active"]);
43
+ });
44
+ });
45
+
46
+ describe("createPolicyRules", () => {
47
+ const cwd = "/repo";
48
+
49
+ it("matches protected files and returns policy metadata", async () => {
50
+ vol.fromJSON({ "/repo/.env": "SECRET=1" });
51
+ const rule = singleRule(cwd, {
52
+ id: "secrets",
53
+ name: "Secrets",
54
+ patterns: [{ pattern: ".env" }],
55
+ protection: "noAccess",
56
+ });
57
+
58
+ await expect(
59
+ rule.check({ kind: "file", path: join(cwd, ".env") }),
60
+ ).resolves.toMatchObject({
61
+ kind: "match",
62
+ metadata: { ruleId: "secrets", protection: "noAccess", path: ".env" },
63
+ });
64
+ });
65
+
66
+ it("passes allowed patterns", async () => {
67
+ vol.fromJSON({ "/repo/.env.example": "SECRET=" });
68
+ const rule = singleRule(cwd, {
69
+ id: "secrets",
70
+ name: "Secrets",
71
+ patterns: [{ pattern: ".env*" }],
72
+ allowedPatterns: [{ pattern: ".env.example" }],
73
+ protection: "noAccess",
74
+ });
75
+
76
+ await expect(
77
+ rule.check({ kind: "file", path: join(cwd, ".env.example") }),
78
+ ).resolves.toEqual({ kind: "pass" });
79
+ });
80
+
81
+ it("passes missing files when onlyIfExists is true", async () => {
82
+ const rule = singleRule(cwd, {
83
+ id: "secrets",
84
+ name: "Secrets",
85
+ patterns: [{ pattern: ".env" }],
86
+ protection: "noAccess",
87
+ });
88
+
89
+ await expect(
90
+ rule.check({ kind: "file", path: join(cwd, ".env") }),
91
+ ).resolves.toEqual({ kind: "pass" });
92
+ });
93
+
94
+ it("matches missing files when onlyIfExists is false", async () => {
95
+ const rule = singleRule(cwd, {
96
+ id: "secrets",
97
+ name: "Secrets",
98
+ patterns: [{ pattern: ".env" }],
99
+ protection: "noAccess",
100
+ onlyIfExists: false,
101
+ });
102
+
103
+ await expect(
104
+ rule.check({ kind: "file", path: join(cwd, ".env") }),
105
+ ).resolves.toMatchObject({ kind: "match" });
106
+ });
107
+ });
@@ -0,0 +1,119 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import type { Action, Rule } from "../../src/core";
4
+ import { expandHomePath } from "../../src/core/paths";
5
+ import type { PolicyRule, Protection } from "../../src/shared/config";
6
+ import {
7
+ type CompiledPattern,
8
+ compileFilePatterns,
9
+ normalizeFilePath,
10
+ } from "../../src/shared/matching";
11
+
12
+ export type PolicyMeta = {
13
+ ruleId: string;
14
+ protection: Protection;
15
+ path: string;
16
+ };
17
+
18
+ export type CompiledPolicy = {
19
+ id: string;
20
+ protection: Protection;
21
+ patterns: CompiledPattern[];
22
+ allowedPatterns: CompiledPattern[];
23
+ onlyIfExists: boolean;
24
+ blockMessage: string;
25
+ };
26
+
27
+ const DEFAULT_BLOCK_MESSAGES: Record<Protection, string> = {
28
+ noAccess:
29
+ "Accessing {file} is not allowed. This file is protected. Ask the user if changes are needed.",
30
+ readOnly:
31
+ "Writing to {file} is not allowed. This file is read-only. Use the read tool to inspect it instead.",
32
+ none: "",
33
+ };
34
+
35
+ export const BLOCKED_TOOLS: Record<Protection, Set<string>> = {
36
+ noAccess: new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]),
37
+ readOnly: new Set(["write", "edit", "bash"]),
38
+ none: new Set(),
39
+ };
40
+
41
+ export function compilePolicies(rules: PolicyRule[]): CompiledPolicy[] {
42
+ return rules
43
+ .filter((rule) => rule.enabled ?? true)
44
+ .filter((rule) => rule.id.trim() && rule.patterns.length > 0)
45
+ .map((rule) => ({
46
+ id: rule.id,
47
+ protection: rule.protection,
48
+ patterns: compileFilePatterns(rule.patterns),
49
+ allowedPatterns: compileFilePatterns(rule.allowedPatterns ?? []),
50
+ onlyIfExists: rule.onlyIfExists ?? true,
51
+ blockMessage:
52
+ rule.blockMessage ?? DEFAULT_BLOCK_MESSAGES[rule.protection],
53
+ }));
54
+ }
55
+
56
+ export function protectionRank(protection: Protection): number {
57
+ if (protection === "noAccess") return 2;
58
+ if (protection === "readOnly") return 1;
59
+ return 0;
60
+ }
61
+
62
+ export function normalizeTarget(filePath: string, cwd: string): string {
63
+ if (filePath === "~" || filePath.startsWith("~/")) {
64
+ return normalizeFilePath(filePath);
65
+ }
66
+
67
+ const expanded = expandHomePath(filePath);
68
+ const absolute = resolve(cwd, expanded);
69
+ const rel = relative(cwd, absolute);
70
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
71
+ return normalizeFilePath(rel || ".");
72
+ }
73
+
74
+ const normalizedHome = normalizeFilePath(expandHomePath("~"));
75
+ const normalizedAbsolute = normalizeFilePath(absolute);
76
+
77
+ if (normalizedAbsolute.startsWith(`${normalizedHome}/`)) {
78
+ return normalizeFilePath(`~/${relative(expandHomePath("~"), absolute)}`);
79
+ }
80
+
81
+ return normalizeFilePath(absolute);
82
+ }
83
+
84
+ async function fileExists(filePath: string, cwd: string): Promise<boolean> {
85
+ try {
86
+ await stat(resolve(cwd, expandHomePath(filePath)));
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function createPolicyRules(
94
+ policies: CompiledPolicy[],
95
+ cwd: string,
96
+ ): Rule<PolicyMeta>[] {
97
+ return policies.map((policy) => ({
98
+ key: `policies.${policy.id}`,
99
+ async check(action: Action) {
100
+ if (action.kind !== "file") return { kind: "pass" };
101
+ const path = normalizeTarget(action.path, cwd);
102
+ if (!policy.patterns.some((pattern) => pattern.test(path))) {
103
+ return { kind: "pass" };
104
+ }
105
+ if (policy.allowedPatterns.some((pattern) => pattern.test(path))) {
106
+ return { kind: "pass" };
107
+ }
108
+ if (policy.onlyIfExists && !(await fileExists(path, cwd))) {
109
+ return { kind: "pass" };
110
+ }
111
+ if (policy.protection === "none") return { kind: "pass" };
112
+ return {
113
+ kind: "match",
114
+ reason: policy.blockMessage.replace("{file}", path),
115
+ metadata: { ruleId: policy.id, protection: policy.protection, path },
116
+ };
117
+ },
118
+ }));
119
+ }
@@ -0,0 +1,44 @@
1
+ import { join } from "node:path";
2
+ import { vol } from "memfs";
3
+ import { describe, expect, it } from "vitest";
4
+ import { compilePolicies } from "./rules";
5
+ import { extractTargets } from "./targets";
6
+
7
+ describe("extractTargets", () => {
8
+ it("returns direct file tool targets", async () => {
9
+ await expect(
10
+ extractTargets(
11
+ { toolName: "read", input: { path: "config/locked.json" } },
12
+ "/repo",
13
+ [],
14
+ ),
15
+ ).resolves.toEqual(["config/locked.json"]);
16
+ });
17
+
18
+ it("extracts only bash targets matching configured policies", async () => {
19
+ const cwd = "/repo";
20
+ vol.fromJSON({
21
+ "/repo/config/locked.json": "{}",
22
+ "/repo/README.md": "hello",
23
+ });
24
+ const policies = compilePolicies([
25
+ {
26
+ id: "locked",
27
+ name: "Locked",
28
+ patterns: [{ pattern: "config/locked.json" }],
29
+ protection: "readOnly",
30
+ },
31
+ ]);
32
+
33
+ await expect(
34
+ extractTargets(
35
+ {
36
+ toolName: "bash",
37
+ input: { command: "cat README.md config/locked.json" },
38
+ },
39
+ cwd,
40
+ policies,
41
+ ),
42
+ ).resolves.toEqual([join("config", "locked.json")]);
43
+ });
44
+ });
@@ -0,0 +1,66 @@
1
+ import { parse } from "@aliou/sh";
2
+ import { maybePathLike } from "../../src/core/paths";
3
+ import { walkCommands, wordToString } from "../../src/core/shell";
4
+ import { expandGlob, hasGlobChars } from "../../src/shared/glob";
5
+ import type { CompiledPolicy } from "./rules";
6
+ import { normalizeTarget } from "./rules";
7
+
8
+ async function expandCandidate(candidate: string): Promise<string[]> {
9
+ if (!hasGlobChars(candidate)) return [candidate];
10
+ const matches = await expandGlob(candidate);
11
+ return matches.length > 0 ? matches : [candidate];
12
+ }
13
+
14
+ export async function extractTargets(
15
+ event: { toolName: string; input: Record<string, unknown> },
16
+ cwd: string,
17
+ policies: CompiledPolicy[],
18
+ ): Promise<string[]> {
19
+ if (
20
+ ["read", "write", "edit", "grep", "find", "ls"].includes(event.toolName)
21
+ ) {
22
+ const target = String(
23
+ event.input.file_path ?? event.input.path ?? "",
24
+ ).trim();
25
+ return target ? [target] : [];
26
+ }
27
+
28
+ if (event.toolName !== "bash") return [];
29
+ const command = String(event.input.command ?? "");
30
+ const targets = new Set<string>();
31
+ const maybeAdd = async (candidate: string) => {
32
+ if (!candidate || candidate.startsWith("-")) return;
33
+ for (const file of await expandCandidate(candidate)) {
34
+ const normalized = normalizeTarget(file, cwd);
35
+ if (
36
+ policies.some((policy) =>
37
+ policy.patterns.some((pattern) => pattern.test(normalized)),
38
+ )
39
+ ) {
40
+ targets.add(file);
41
+ }
42
+ }
43
+ };
44
+
45
+ try {
46
+ const { ast } = parse(command);
47
+ const pending: Promise<void>[] = [];
48
+ walkCommands(ast, (cmd) => {
49
+ const words = (cmd.words ?? []).map(wordToString);
50
+ for (const word of words.slice(1)) pending.push(maybeAdd(word));
51
+ for (const redir of cmd.redirects ?? []) {
52
+ pending.push(maybeAdd(wordToString(redir.target)));
53
+ }
54
+ return false;
55
+ });
56
+ await Promise.all(pending);
57
+ } catch {
58
+ const tokenRegex = /"([^"]+)"|'([^']+)'|`([^`]+)`|([^\s"'`<>|;&]+)/g;
59
+ for (const match of command.matchAll(tokenRegex)) {
60
+ const token = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
61
+ if (maybePathLike(token)) await maybeAdd(token);
62
+ }
63
+ }
64
+
65
+ return [...targets];
66
+ }
@@ -0,0 +1,47 @@
1
+ import { homedir } from "node:os";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ createPendingGrant,
5
+ isGrantTooBroad,
6
+ pendingAllowedPaths,
7
+ resolveAllowedPaths,
8
+ } from "./grants";
9
+
10
+ describe("path access grants", () => {
11
+ it("resolves allowed paths relative to cwd", () => {
12
+ expect(resolveAllowedPaths(["../shared", "logs/"], "/repo/app")).toEqual([
13
+ "/repo/shared",
14
+ "/repo/app/logs/",
15
+ ]);
16
+ });
17
+
18
+ it("converts pending grants to absolute allowed paths", () => {
19
+ expect(
20
+ pendingAllowedPaths([
21
+ {
22
+ storagePath: "/tmp/file.txt",
23
+ absolutePath: "/tmp/file.txt",
24
+ scope: "memory",
25
+ },
26
+ {
27
+ storagePath: "/tmp/logs/",
28
+ absolutePath: "/tmp/logs",
29
+ scope: "local",
30
+ },
31
+ ]),
32
+ ).toEqual(["/tmp/file.txt", "/tmp/logs/"]);
33
+ });
34
+
35
+ it("rejects home grants as too broad", () => {
36
+ expect(isGrantTooBroad(`${homedir()}/`)).toBe(true);
37
+ expect(isGrantTooBroad(`${homedir()}/project`)).toBe(false);
38
+ });
39
+
40
+ it("creates pending grants with storage form", () => {
41
+ expect(createPendingGrant("/tmp/logs", true, "local")).toEqual({
42
+ absolutePath: "/tmp/logs",
43
+ scope: "local",
44
+ storagePath: "/tmp/logs/",
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,68 @@
1
+ import { homedir } from "node:os";
2
+ import { resolveFromCwd, toStorageForm } from "../../src/core/paths";
3
+ import { configLoader } from "../../src/shared/config";
4
+
5
+ export type PendingPathGrant = {
6
+ storagePath: string;
7
+ scope: "memory" | "local";
8
+ absolutePath: string;
9
+ };
10
+
11
+ export function resolveAllowedPaths(
12
+ allowedPaths: string[],
13
+ 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
+ });
20
+ }
21
+
22
+ export function pendingAllowedPaths(grants: PendingPathGrant[]): string[] {
23
+ return grants.map((grant) =>
24
+ grant.storagePath.endsWith("/")
25
+ ? `${grant.absolutePath}/`
26
+ : grant.absolutePath,
27
+ );
28
+ }
29
+
30
+ export function isGrantTooBroad(absPath: string): boolean {
31
+ const normalized = absPath.replace(/[\\/]+$/, "");
32
+ return normalized === "/" || normalized === homedir();
33
+ }
34
+
35
+ export function createPendingGrant(
36
+ absolutePath: string,
37
+ isDirectory: boolean,
38
+ scope: "memory" | "local",
39
+ ): PendingPathGrant {
40
+ return {
41
+ absolutePath,
42
+ scope,
43
+ storagePath: toStorageForm(absolutePath, isDirectory),
44
+ };
45
+ }
46
+
47
+ export async function persistGrant(grant: PendingPathGrant): Promise<void> {
48
+ const raw = (configLoader.getRawConfig(grant.scope) ?? {}) as Record<
49
+ string,
50
+ unknown
51
+ >;
52
+ const pathAccess = (raw.pathAccess ?? {}) as Record<string, unknown>;
53
+ const existing = Array.isArray(pathAccess.allowedPaths)
54
+ ? pathAccess.allowedPaths.filter(
55
+ (path): path is string => typeof path === "string",
56
+ )
57
+ : [];
58
+
59
+ if (existing.includes(grant.storagePath)) return;
60
+
61
+ await configLoader.save(grant.scope, {
62
+ ...raw,
63
+ pathAccess: {
64
+ ...pathAccess,
65
+ allowedPaths: [...existing, grant.storagePath],
66
+ },
67
+ });
68
+ }