pi-codex-auto-review 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/NOTICE +4 -2
  2. package/README.ko.md +85 -0
  3. package/README.md +51 -52
  4. package/dist/approval-commands.d.ts +3 -3
  5. package/dist/approval-commands.js +161 -34
  6. package/dist/approval-commands.js.map +1 -1
  7. package/dist/approval-settings.d.ts +1 -1
  8. package/dist/approval-settings.js +17 -10
  9. package/dist/approval-settings.js.map +1 -1
  10. package/dist/approvals.d.ts +10 -9
  11. package/dist/approvals.js +196 -75
  12. package/dist/approvals.js.map +1 -1
  13. package/dist/audit.d.ts +1 -1
  14. package/dist/audit.js +37 -13
  15. package/dist/audit.js.map +1 -1
  16. package/dist/cli.js +63 -34
  17. package/dist/cli.js.map +1 -1
  18. package/dist/context-files.d.ts +2 -2
  19. package/dist/context-files.js +40 -19
  20. package/dist/context-files.js.map +1 -1
  21. package/dist/contracts.d.ts +39 -39
  22. package/dist/contracts.js +117 -51
  23. package/dist/contracts.js.map +1 -1
  24. package/dist/index.d.ts +9 -9
  25. package/dist/index.js +147 -43
  26. package/dist/index.js.map +1 -1
  27. package/dist/pi-host.js +3 -3
  28. package/dist/pi-host.js.map +1 -1
  29. package/dist/policy/domains.js +17 -7
  30. package/dist/policy/domains.js.map +1 -1
  31. package/dist/policy/index.d.ts +7 -7
  32. package/dist/policy/index.js +382 -139
  33. package/dist/policy/index.js.map +1 -1
  34. package/dist/policy/paths.js +31 -18
  35. package/dist/policy/paths.js.map +1 -1
  36. package/dist/policy/rule-engine.d.ts +2 -0
  37. package/dist/policy/rule-engine.js +283 -0
  38. package/dist/policy/rule-engine.js.map +1 -0
  39. package/dist/policy/rule-types.d.ts +30 -0
  40. package/dist/policy/rule-types.js +2 -0
  41. package/dist/policy/rule-types.js.map +1 -0
  42. package/dist/policy/rules-sync.d.ts +1 -0
  43. package/dist/policy/rules-sync.js +14 -0
  44. package/dist/policy/rules-sync.js.map +1 -0
  45. package/dist/policy/rules-worker.d.ts +1 -0
  46. package/dist/policy/rules-worker.js +14 -0
  47. package/dist/policy/rules-worker.js.map +1 -0
  48. package/dist/policy/rules.d.ts +4 -31
  49. package/dist/policy/rules.js +126 -38
  50. package/dist/policy/rules.js.map +1 -1
  51. package/dist/policy/shell.js +70 -41
  52. package/dist/policy/shell.js.map +1 -1
  53. package/dist/policy/starlark/builtins.d.ts +6 -0
  54. package/dist/policy/starlark/builtins.js +764 -0
  55. package/dist/policy/starlark/builtins.js.map +1 -0
  56. package/dist/policy/starlark/evaluator.d.ts +18 -0
  57. package/dist/policy/starlark/evaluator.js +518 -0
  58. package/dist/policy/starlark/evaluator.js.map +1 -0
  59. package/dist/policy/starlark/lexer.d.ts +14 -0
  60. package/dist/policy/starlark/lexer.js +189 -0
  61. package/dist/policy/starlark/lexer.js.map +1 -0
  62. package/dist/policy/starlark/parser.d.ts +140 -0
  63. package/dist/policy/starlark/parser.js +508 -0
  64. package/dist/policy/starlark/parser.js.map +1 -0
  65. package/dist/policy/starlark/validation.d.ts +4 -0
  66. package/dist/policy/starlark/validation.js +183 -0
  67. package/dist/policy/starlark/validation.js.map +1 -0
  68. package/dist/policy/starlark/values.d.ts +46 -0
  69. package/dist/policy/starlark/values.js +363 -0
  70. package/dist/policy/starlark/values.js.map +1 -0
  71. package/dist/reports.d.ts +1 -1
  72. package/dist/reports.js +66 -30
  73. package/dist/reports.js.map +1 -1
  74. package/dist/review/context.d.ts +16 -3
  75. package/dist/review/context.js +254 -48
  76. package/dist/review/context.js.map +1 -1
  77. package/dist/review/investigation.d.ts +2 -2
  78. package/dist/review/investigation.js +57 -16
  79. package/dist/review/investigation.js.map +1 -1
  80. package/dist/review/lifecycle.d.ts +1 -1
  81. package/dist/review/lifecycle.js +72 -29
  82. package/dist/review/lifecycle.js.map +1 -1
  83. package/dist/review/policy.js +27 -5
  84. package/dist/review/policy.js.map +1 -1
  85. package/dist/review/upstream-policy.js +1 -1
  86. package/dist/review/upstream-policy.js.map +1 -1
  87. package/dist/review/user-input.d.ts +5 -0
  88. package/dist/review/user-input.js +54 -0
  89. package/dist/review/user-input.js.map +1 -0
  90. package/dist/reviewer.d.ts +16 -6
  91. package/dist/reviewer.js +258 -70
  92. package/dist/reviewer.js.map +1 -1
  93. package/dist/signals.js +24 -9
  94. package/dist/signals.js.map +1 -1
  95. package/dist/startup.d.ts +4 -4
  96. package/dist/startup.js +139 -41
  97. package/dist/startup.js.map +1 -1
  98. package/dist/tools/controller.d.ts +19 -13
  99. package/dist/tools/controller.js +460 -118
  100. package/dist/tools/controller.js.map +1 -1
  101. package/dist/tools/environment.d.ts +1 -0
  102. package/dist/tools/environment.js +5 -0
  103. package/dist/tools/environment.js.map +1 -1
  104. package/dist/tools/executor.d.ts +1 -1
  105. package/dist/tools/executor.js +45 -28
  106. package/dist/tools/executor.js.map +1 -1
  107. package/dist/tools/external.d.ts +9 -8
  108. package/dist/tools/external.js +22 -11
  109. package/dist/tools/external.js.map +1 -1
  110. package/dist/tools/mcp.d.ts +6 -6
  111. package/dist/tools/mcp.js +208 -79
  112. package/dist/tools/mcp.js.map +1 -1
  113. package/dist/tools/mutation-queue.js.map +1 -1
  114. package/docs/publishing.ko.md +114 -0
  115. package/docs/publishing.md +49 -47
  116. package/docs/security/dependencies.ko.md +54 -0
  117. package/docs/security/dependencies.md +27 -26
  118. package/docs/testing/auto-review-protection.ko.md +158 -0
  119. package/docs/testing/auto-review-protection.md +105 -97
  120. package/docs/usage.ko.md +242 -0
  121. package/docs/usage.md +127 -111
  122. package/package.json +106 -95
  123. package/dist/native/darwin-arm64/artifact.json +0 -5
  124. package/dist/native/darwin-arm64/pi-guard-execpolicy +0 -0
  125. package/dist/native/linux-x64/artifact.json +0 -5
  126. package/dist/native/linux-x64/pi-guard-execpolicy +0 -0
  127. package/native/execpolicy/LICENSE +0 -201
  128. package/native/execpolicy/NOTICE +0 -6
@@ -1,84 +1,160 @@
1
- import { readFile, stat } from 'node:fs/promises';
2
- import { basename, dirname, join, resolve } from 'node:path';
3
- import { homedir, tmpdir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
5
- import { createProfile, decision, digest, immutable, GuardError, EMPTY_DELTA, TOOL_NAMES } from '../contracts.js';
6
- import { canonicalPath, isWithin, resolveToolPath } from './paths.js';
7
- import { analyzeShell, matchesPrefix } from './shell.js';
8
- import { reviewPolicy } from '../review/policy.js';
9
- import { matchesDomain, isNetworkHost, isDomainPattern, normalizeHost } from './domains.js';
10
- import { evaluateRules, ruleCommands, matchesRule } from './rules.js';
11
- export { canonicalPath, isWithin, analyzeShell, matchesPrefix, resolveToolPath };
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createProfile, decision, digest, EMPTY_DELTA, GuardError, immutable, TOOL_NAMES, } from "../contracts.js";
6
+ import { reviewPolicy } from "../review/policy.js";
7
+ import { isDomainPattern, isNetworkHost, matchesDomain, normalizeHost, } from "./domains.js";
8
+ import { canonicalPath, isWithin, resolveToolPath } from "./paths.js";
9
+ import { evaluateRules, matchesRule, ruleCommands, } from "./rules.js";
10
+ import { analyzeShell, matchesPrefix } from "./shell.js";
11
+ export { analyzeShell, canonicalPath, isWithin, matchesPrefix, resolveToolPath, };
12
12
  export const DEFAULT_SETTINGS = immutable({
13
- mode: 'workspace-write', commandRules: [], allowedDomains: [], reviewTimeoutMs: 20_000, approvalTimeoutMs: 60_000, executionTimeoutSeconds: 120, trustedTools: [],
14
- approvalPolicy: 'on-request', approvalsReviewer: 'auto_review', reviewModel: null, reviewPolicy: null,
15
- reviewMaxRounds: 4, reviewMaxOutputTokens: 2048, reviewContextChars: 60000,
16
- ruleFiles: [], writableRoots: [], excludeSlashTmp: false, excludeTmpdir: false,
17
- projectDocMaxBytes: 32768, projectDocFallbackFilenames: [], projectRootMarkers: null,
13
+ mode: "workspace-write",
14
+ commandRules: [],
15
+ allowedDomains: [],
16
+ reviewTimeoutMs: 20_000,
17
+ approvalTimeoutMs: 60_000,
18
+ executionTimeoutSeconds: 120,
19
+ trustedTools: [],
20
+ approvalPolicy: "on-request",
21
+ approvalsReviewer: "auto_review",
22
+ reviewModel: null,
23
+ reviewPolicy: null,
24
+ reviewMaxRounds: 4,
25
+ reviewMaxOutputTokens: 2048,
26
+ reviewContextChars: 60000,
27
+ ruleFiles: [],
28
+ writableRoots: [],
29
+ excludeSlashTmp: false,
30
+ excludeTmpdir: false,
31
+ projectDocMaxBytes: 32768,
32
+ projectDocFallbackFilenames: [],
33
+ projectRootMarkers: null,
18
34
  });
19
35
  export function validateSettings(value) {
20
- if (!value || typeof value !== 'object' || Array.isArray(value))
21
- throw new GuardError('INVALID_SETTINGS', 'Settings must be an object');
36
+ if (!value || typeof value !== "object" || Array.isArray(value))
37
+ throw new GuardError("INVALID_SETTINGS", "Settings must be an object");
22
38
  const raw = value;
23
39
  for (const key of Object.keys(raw))
24
40
  if (!Object.hasOwn(DEFAULT_SETTINGS, key))
25
- throw new GuardError('INVALID_SETTINGS', `Unknown setting: ${key}`);
41
+ throw new GuardError("INVALID_SETTINGS", `Unknown setting: ${key}`);
26
42
  const settings = { ...DEFAULT_SETTINGS, ...raw };
27
- if (!['read-only', 'workspace-write'].includes(settings.mode))
28
- throw new GuardError('INVALID_SETTINGS', 'Unknown mode');
29
- for (const key of ['reviewTimeoutMs', 'approvalTimeoutMs', 'executionTimeoutSeconds'])
43
+ if (!["read-only", "workspace-write"].includes(settings.mode))
44
+ throw new GuardError("INVALID_SETTINGS", "Unknown mode");
45
+ for (const key of [
46
+ "reviewTimeoutMs",
47
+ "approvalTimeoutMs",
48
+ "executionTimeoutSeconds",
49
+ ])
30
50
  if (!Number.isFinite(settings[key]) || settings[key] <= 0)
31
- throw new GuardError('INVALID_SETTINGS', `Invalid ${key}`);
32
- if (!Array.isArray(settings.allowedDomains) || settings.allowedDomains.some(x => !isDomainPattern(x)))
33
- throw new GuardError('INVALID_SETTINGS', 'Invalid domains');
34
- if (!Array.isArray(settings.trustedTools) || settings.trustedTools.some(x => typeof x !== 'string' || !x))
35
- throw new GuardError('INVALID_SETTINGS', 'Invalid trusted tools');
51
+ throw new GuardError("INVALID_SETTINGS", `Invalid ${key}`);
52
+ if (!Array.isArray(settings.allowedDomains) ||
53
+ settings.allowedDomains.some((x) => !isDomainPattern(x)))
54
+ throw new GuardError("INVALID_SETTINGS", "Invalid domains");
55
+ if (!Array.isArray(settings.trustedTools) ||
56
+ settings.trustedTools.some((x) => typeof x !== "string" || !x))
57
+ throw new GuardError("INVALID_SETTINGS", "Invalid trusted tools");
36
58
  const approval = settings.approvalPolicy;
37
- if (approval !== 'on-request' && approval !== 'never' && (!approval || typeof approval !== 'object' || Array.isArray(approval) || Object.keys(approval).some(key => !['sandbox', 'rules', 'mcp_elicitations'].includes(key)) || typeof approval.sandbox !== 'boolean' || typeof approval.rules !== 'boolean' || approval.mcp_elicitations !== undefined && typeof approval.mcp_elicitations !== 'boolean'))
38
- throw new GuardError('INVALID_SETTINGS', 'Invalid approval policy');
39
- if (!['auto_review', 'user'].includes(settings.approvalsReviewer))
40
- throw new GuardError('INVALID_SETTINGS', 'Invalid approvals reviewer');
41
- if (settings.reviewModel !== null && (!settings.reviewModel || typeof settings.reviewModel !== 'object' || Array.isArray(settings.reviewModel) || Object.keys(settings.reviewModel).some(key => !['provider', 'id'].includes(key)) || ![settings.reviewModel.provider, settings.reviewModel.id].every(value => typeof value === 'string' && value.trim().length > 0 && !/[\0\r\n]/.test(value))))
42
- throw new GuardError('INVALID_SETTINGS', 'Invalid review model');
43
- if (settings.reviewPolicy !== null && (typeof settings.reviewPolicy !== 'string' || !settings.reviewPolicy.trim() || settings.reviewPolicy.length > 100000))
44
- throw new GuardError('INVALID_SETTINGS', 'Invalid reviewer policy');
45
- for (const [key, maximum] of [['reviewMaxRounds', 16], ['reviewMaxOutputTokens', 16384], ['reviewContextChars', 500000]])
46
- if (!Number.isInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)
47
- throw new GuardError('INVALID_SETTINGS', `Invalid ${key}`);
48
- for (const key of ['ruleFiles', 'writableRoots'])
49
- if (!Array.isArray(settings[key]) || settings[key].some(value => typeof value !== 'string' || !value || value.includes('\0')))
50
- throw new GuardError('INVALID_SETTINGS', `Invalid ${key}`);
51
- for (const key of ['excludeSlashTmp', 'excludeTmpdir'])
52
- if (typeof settings[key] !== 'boolean')
53
- throw new GuardError('INVALID_SETTINGS', `Invalid ${key}`);
54
- if (!Number.isSafeInteger(settings.projectDocMaxBytes) || settings.projectDocMaxBytes < 0 || settings.projectDocMaxBytes > 1000000)
55
- throw new GuardError('INVALID_SETTINGS', 'Invalid project document byte budget');
56
- for (const values of [settings.projectDocFallbackFilenames, settings.projectRootMarkers ?? []])
57
- if (!Array.isArray(values) || values.some(value => typeof value !== 'string'))
58
- throw new GuardError('INVALID_SETTINGS', 'Invalid context discovery filenames');
59
+ if (approval !== "on-request" &&
60
+ approval !== "never" &&
61
+ (!approval ||
62
+ typeof approval !== "object" ||
63
+ Array.isArray(approval) ||
64
+ Object.keys(approval).some((key) => !["sandbox", "rules", "mcp_elicitations"].includes(key)) ||
65
+ typeof approval.sandbox !== "boolean" ||
66
+ typeof approval.rules !== "boolean" ||
67
+ (approval.mcp_elicitations !== undefined &&
68
+ typeof approval.mcp_elicitations !== "boolean")))
69
+ throw new GuardError("INVALID_SETTINGS", "Invalid approval policy");
70
+ if (!["auto_review", "user"].includes(settings.approvalsReviewer))
71
+ throw new GuardError("INVALID_SETTINGS", "Invalid approvals reviewer");
72
+ if (settings.reviewModel !== null &&
73
+ (!settings.reviewModel ||
74
+ typeof settings.reviewModel !== "object" ||
75
+ Array.isArray(settings.reviewModel) ||
76
+ Object.keys(settings.reviewModel).some((key) => !["provider", "id"].includes(key)) ||
77
+ ![settings.reviewModel.provider, settings.reviewModel.id].every((value) => typeof value === "string" &&
78
+ value.trim().length > 0 &&
79
+ !/[\0\r\n]/.test(value))))
80
+ throw new GuardError("INVALID_SETTINGS", "Invalid review model");
81
+ if (settings.reviewPolicy !== null &&
82
+ (typeof settings.reviewPolicy !== "string" ||
83
+ !settings.reviewPolicy.trim() ||
84
+ settings.reviewPolicy.length > 100000))
85
+ throw new GuardError("INVALID_SETTINGS", "Invalid reviewer policy");
86
+ for (const [key, maximum] of [
87
+ ["reviewMaxRounds", 16],
88
+ ["reviewMaxOutputTokens", 16384],
89
+ ["reviewContextChars", 500000],
90
+ ])
91
+ if (!Number.isInteger(settings[key]) ||
92
+ settings[key] < 1 ||
93
+ settings[key] > maximum)
94
+ throw new GuardError("INVALID_SETTINGS", `Invalid ${key}`);
95
+ for (const key of ["ruleFiles", "writableRoots"])
96
+ if (!Array.isArray(settings[key]) ||
97
+ settings[key].some((value) => typeof value !== "string" || !value || value.includes("\0")))
98
+ throw new GuardError("INVALID_SETTINGS", `Invalid ${key}`);
99
+ for (const key of ["excludeSlashTmp", "excludeTmpdir"])
100
+ if (typeof settings[key] !== "boolean")
101
+ throw new GuardError("INVALID_SETTINGS", `Invalid ${key}`);
102
+ if (!Number.isSafeInteger(settings.projectDocMaxBytes) ||
103
+ settings.projectDocMaxBytes < 0 ||
104
+ settings.projectDocMaxBytes > 1000000)
105
+ throw new GuardError("INVALID_SETTINGS", "Invalid project document byte budget");
106
+ for (const values of [
107
+ settings.projectDocFallbackFilenames,
108
+ settings.projectRootMarkers ?? [],
109
+ ])
110
+ if (!Array.isArray(values) ||
111
+ values.some((value) => typeof value !== "string"))
112
+ throw new GuardError("INVALID_SETTINGS", "Invalid context discovery filenames");
59
113
  if (!Array.isArray(settings.commandRules))
60
- throw new GuardError('INVALID_SETTINGS', 'Invalid command rules');
114
+ throw new GuardError("INVALID_SETTINGS", "Invalid command rules");
61
115
  for (const rule of settings.commandRules) {
62
- if (!rule || !['allow', 'ask', 'deny'].includes(rule.decision) || !Array.isArray(rule.prefix) || !rule.prefix.length || rule.prefix.some((x) => typeof x !== 'string' || !x || /[\0\n]/.test(x)))
63
- throw new GuardError('INVALID_SETTINGS', 'Invalid command rule');
64
- if (Object.keys(rule).some(k => !['prefix', 'decision'].includes(k)))
65
- throw new GuardError('INVALID_SETTINGS', 'Unknown command rule field');
116
+ if (!rule ||
117
+ !["allow", "ask", "deny"].includes(rule.decision) ||
118
+ !Array.isArray(rule.prefix) ||
119
+ !rule.prefix.length ||
120
+ rule.prefix.some((x) => typeof x !== "string" || !x || /[\0\n]/.test(x)))
121
+ throw new GuardError("INVALID_SETTINGS", "Invalid command rule");
122
+ if (Object.keys(rule).some((k) => !["prefix", "decision"].includes(k)))
123
+ throw new GuardError("INVALID_SETTINGS", "Unknown command rule field");
66
124
  }
67
125
  return immutable(JSON.parse(JSON.stringify(settings)));
68
126
  }
69
127
  export async function loadSettings(path) {
70
128
  try {
71
- return validateSettings(JSON.parse(await readFile(path, 'utf8')));
129
+ return validateSettings(JSON.parse(await readFile(path, "utf8")));
72
130
  }
73
131
  catch (error) {
74
- throw new GuardError('INVALID_SETTINGS', 'Permission settings could not be loaded', { cause: error });
132
+ throw new GuardError("INVALID_SETTINGS", "Permission settings could not be loaded", { cause: error });
75
133
  }
76
134
  }
77
135
  export async function defaultProfile(cwd, settings, controlPaths = [], trustedExtensionPaths = []) {
78
136
  const workspace = await canonicalPath(cwd, cwd), home = homedir();
79
- const hardRead = [join(home, '.ssh'), join(home, '.aws'), join(home, '.codex'), join(home, '.pi', 'agent'), ...controlPaths, ...settings.ruleFiles];
80
- const hardWrite = [...hardRead, join(workspace, '.pi', 'guard'), fileURLToPath(new URL('../', import.meta.url))];
81
- const roots = [...new Set(await Promise.all([workspace, ...settings.writableRoots, ...(!settings.excludeSlashTmp ? ['/tmp'] : []), ...(!settings.excludeTmpdir ? [tmpdir()] : [])].map(path => canonicalPath(path, cwd))))];
137
+ const hardRead = [
138
+ join(home, ".ssh"),
139
+ join(home, ".aws"),
140
+ join(home, ".codex"),
141
+ join(home, ".pi", "agent"),
142
+ ...controlPaths,
143
+ ...settings.ruleFiles,
144
+ ];
145
+ const hardWrite = [
146
+ ...hardRead,
147
+ join(workspace, ".pi", "guard"),
148
+ fileURLToPath(new URL("../", import.meta.url)),
149
+ ];
150
+ const roots = [
151
+ ...new Set(await Promise.all([
152
+ workspace,
153
+ ...settings.writableRoots,
154
+ ...(!settings.excludeSlashTmp ? ["/tmp"] : []),
155
+ ...(!settings.excludeTmpdir ? [tmpdir()] : []),
156
+ ].map((path) => canonicalPath(path, cwd)))),
157
+ ];
82
158
  const readOnlyPaths = [];
83
159
  for (const input of trustedExtensionPaths) {
84
160
  const path = await canonicalPath(input, cwd);
@@ -86,34 +162,43 @@ export async function defaultProfile(cwd, settings, controlPaths = [], trustedEx
86
162
  hardWrite.push((await stat(path)).isDirectory() ? path : dirname(path));
87
163
  }
88
164
  for (const root of roots) {
89
- const git = join(root, '.git');
165
+ const git = join(root, ".git");
90
166
  readOnlyPaths.push(await canonicalPath(git, cwd));
91
167
  try {
92
168
  if ((await stat(git)).isFile()) {
93
- const pointer = /^gitdir:\s*(.+)\s*$/m.exec(await readFile(git, 'utf8'));
94
- if (pointer)
169
+ const pointer = /^gitdir:\s*(.+)\s*$/m.exec(await readFile(git, "utf8"));
170
+ if (pointer?.[1])
95
171
  readOnlyPaths.push(await canonicalPath(resolve(root, pointer[1]), cwd));
96
172
  }
97
173
  }
98
174
  catch (error) {
99
- if (error.code !== 'ENOENT')
175
+ if (error.code !== "ENOENT")
100
176
  throw error;
101
177
  }
102
- for (const name of ['.agents', '.codex']) {
178
+ for (const name of [".agents", ".codex"]) {
103
179
  const path = join(root, name);
104
180
  try {
105
181
  if ((await stat(path)).isDirectory())
106
182
  readOnlyPaths.push(await canonicalPath(path, cwd));
107
183
  }
108
184
  catch (error) {
109
- if (error.code !== 'ENOENT')
185
+ if (error.code !== "ENOENT")
110
186
  throw error;
111
187
  }
112
188
  }
113
- readOnlyPaths.push(await canonicalPath(join(root, '.pi'), cwd));
114
- hardWrite.push(...['mcp.json', 'settings.json', 'extensions', 'guard'].map(name => join(root, '.pi', name)));
189
+ readOnlyPaths.push(await canonicalPath(join(root, ".pi"), cwd));
190
+ hardWrite.push(...["mcp.json", "settings.json", "extensions", "guard"].map((name) => join(root, ".pi", name)));
115
191
  }
116
- return createProfile({ mode: settings.mode, readRoots: ['/'], writeRoots: settings.mode === 'workspace-write' ? roots : [], denyRead: await Promise.all(hardRead.map(p => canonicalPath(p, cwd))), denyWrite: await Promise.all(hardWrite.map(p => canonicalPath(p, cwd))), readOnlyPaths, allowedDomains: [...settings.allowedDomains], deniedDomains: [] });
192
+ return createProfile({
193
+ mode: settings.mode,
194
+ readRoots: ["/"],
195
+ writeRoots: settings.mode === "workspace-write" ? roots : [],
196
+ denyRead: await Promise.all(hardRead.map((p) => canonicalPath(p, cwd))),
197
+ denyWrite: await Promise.all(hardWrite.map((p) => canonicalPath(p, cwd))),
198
+ readOnlyPaths,
199
+ allowedDomains: [...settings.allowedDomains],
200
+ deniedDomains: [],
201
+ });
117
202
  }
118
203
  export class PolicyEngine {
119
204
  settings;
@@ -123,155 +208,313 @@ export class PolicyEngine {
123
208
  compiledRules;
124
209
  matchCache = new Map();
125
210
  effectiveProfile;
126
- get profile() { return this.effectiveProfile; }
127
- get revision() { return digest({ settings: this.settings, profile: this.profile, rules: this.rules, ruleSources: this.ruleSources, compiledRules: this.compiledRules ?? null, reviewPolicyDigest: reviewPolicy(this.settings.reviewPolicy).digest }); }
211
+ get profile() {
212
+ return this.effectiveProfile;
213
+ }
214
+ get revision() {
215
+ return digest({
216
+ settings: this.settings,
217
+ profile: this.profile,
218
+ rules: this.rules,
219
+ ruleSources: this.ruleSources,
220
+ compiledRules: this.compiledRules ?? null,
221
+ reviewPolicyDigest: reviewPolicy(this.settings.reviewPolicy).digest,
222
+ });
223
+ }
128
224
  constructor(settings, profile) {
129
225
  this.settings = settings;
130
226
  this.effectiveProfile = profile;
131
- this.rules = settings.commandRules.map(rule => ({ pattern: rule.prefix, decision: rule.decision === 'ask' ? 'prompt' : rule.decision === 'deny' ? 'forbidden' : 'allow' }));
227
+ this.rules = settings.commandRules.map((rule) => ({
228
+ pattern: rule.prefix,
229
+ decision: rule.decision === "ask"
230
+ ? "prompt"
231
+ : rule.decision === "deny"
232
+ ? "forbidden"
233
+ : "allow",
234
+ }));
132
235
  this.isInitialized = settings.ruleFiles.length === 0;
133
236
  }
134
237
  async initialize(cwd = process.cwd()) {
135
238
  if (this.isInitialized)
136
239
  return;
137
- this.ruleSources = immutable(await Promise.all(this.settings.ruleFiles.map(async (path) => ({ name: resolve(cwd, path), source: await readFile(resolve(cwd, path), 'utf8') }))));
240
+ this.ruleSources = immutable(await Promise.all(this.settings.ruleFiles.map(async (path) => ({
241
+ name: resolve(cwd, path),
242
+ source: await readFile(resolve(cwd, path), "utf8"),
243
+ }))));
138
244
  this.compiledRules = await evaluateRules(this.ruleSources);
139
245
  const allowed = this.compiledRules.allowedDomains, denied = this.compiledRules.deniedDomains;
140
- this.effectiveProfile = createProfile({ ...this.profile, allowedDomains: [...new Set([...this.profile.allowedDomains.filter(host => !denied.includes(host)), ...allowed])], deniedDomains: [...new Set([...this.profile.deniedDomains, ...denied])] });
246
+ this.effectiveProfile = createProfile({
247
+ ...this.profile,
248
+ allowedDomains: [
249
+ ...new Set([
250
+ ...this.profile.allowedDomains.filter((host) => !denied.includes(host)),
251
+ ...allowed,
252
+ ]),
253
+ ],
254
+ deniedDomains: [
255
+ ...new Set([...this.profile.deniedDomains, ...denied]),
256
+ ],
257
+ });
141
258
  this.isInitialized = true;
142
259
  }
143
260
  async evaluate(action, signal) {
144
261
  if (!this.isInitialized)
145
- return decision(action, 'deny', 'Command rules have not been loaded', EMPTY_DELTA, true);
146
- if (action.policyRevision !== this.revision || action.permissionDigest !== digest(this.profile))
147
- return decision(action, 'deny', 'Stale policy or permission profile', EMPTY_DELTA, true);
262
+ return decision(action, "deny", "Command rules have not been loaded", EMPTY_DELTA, true);
263
+ if (action.policyRevision !== this.revision ||
264
+ action.permissionDigest !== digest(this.profile))
265
+ return decision(action, "deny", "Stale policy or permission profile", EMPTY_DELTA, true);
148
266
  if (!TOOL_NAMES.includes(action.tool))
149
- return decision(action, 'ask', 'Unknown tool: annotations do not establish permission');
150
- if (action.tool === 'bash')
267
+ return decision(action, "ask", "Unknown tool: annotations do not establish permission");
268
+ if (action.tool === "bash")
151
269
  return this.shellDecision(action, signal);
152
270
  const input = action.args.path ?? action.cwd;
153
- if (typeof input !== 'string')
154
- return decision(action, 'deny', 'Invalid path input', EMPTY_DELTA, true);
155
- const target = await canonicalPath(await resolveToolPath(input, action.cwd, action.tool === 'read'), action.cwd);
271
+ if (typeof input !== "string")
272
+ return decision(action, "deny", "Invalid path input", EMPTY_DELTA, true);
273
+ const target = await canonicalPath(await resolveToolPath(input, action.cwd, action.tool === "read"), action.cwd);
156
274
  const profile = await this.resolvedProfile(action.cwd);
157
- const isWrite = action.tool === 'write' || action.tool === 'edit';
158
- if (profile.denyRead.some(p => isWithin(target, p)) || (isWrite && profile.denyWrite.some(p => isWithin(target, p))))
159
- return decision(action, 'deny', 'Protected path', EMPTY_DELTA, true);
160
- const canRead = profile.readRoots.some(p => isWithin(target, p));
161
- const canWrite = !isWrite || profile.writeRoots.some(p => isWithin(target, p)) && !(profile.readOnlyPaths ?? []).some(p => isWithin(target, p));
275
+ const isWrite = action.tool === "write" || action.tool === "edit";
276
+ if (profile.denyRead.some((p) => isWithin(target, p)) ||
277
+ (isWrite && profile.denyWrite.some((p) => isWithin(target, p))))
278
+ return decision(action, "deny", "Protected path", EMPTY_DELTA, true);
279
+ const canRead = profile.readRoots.some((p) => isWithin(target, p));
280
+ const canWrite = !isWrite ||
281
+ (profile.writeRoots.some((p) => isWithin(target, p)) &&
282
+ !(profile.readOnlyPaths ?? []).some((p) => isWithin(target, p)));
162
283
  if (canRead && canWrite)
163
- return decision(action, 'allow', 'Within current filesystem permissions');
164
- return decision(action, 'ask', 'Action outside the ordinary approval scope', { readPaths: canRead ? [] : [target], writePaths: canWrite ? [] : [target], domains: [] });
284
+ return decision(action, "allow", "Within current filesystem permissions");
285
+ return decision(action, "ask", "Action outside the ordinary approval scope", {
286
+ readPaths: canRead ? [] : [target],
287
+ writePaths: canWrite ? [] : [target],
288
+ domains: [],
289
+ });
165
290
  }
166
291
  async shellDecision(action, signal) {
167
292
  const profile = await this.resolvedProfile(action.cwd);
168
293
  const command = action.args.command;
169
- if (typeof command !== 'string')
170
- return decision(action, 'deny', 'Invalid command input', EMPTY_DELTA, true);
294
+ if (typeof command !== "string")
295
+ return decision(action, "deny", "Invalid command input", EMPTY_DELTA, true);
171
296
  const analysis = analyzeShell(command);
172
297
  let shouldAsk = !analysis.isSupported;
173
298
  const delta = { readPaths: [], writePaths: [], domains: [] };
174
- const commandGroups = ruleCommands(command, typeof action.args.shellPath === 'string' ? action.args.shellPath : '/bin/bash');
299
+ const commandGroups = ruleCommands(command, typeof action.args.shellPath === "string"
300
+ ? action.args.shellPath
301
+ : "/bin/bash");
175
302
  const key = digest(commandGroups);
176
303
  let compiledMatches = this.matchCache.get(key);
177
304
  if (!compiledMatches && this.ruleSources.length) {
178
305
  compiledMatches = (await evaluateRules(this.ruleSources, commandGroups, signal)).matches;
179
- if (this.matchCache.size >= 256)
180
- this.matchCache.delete(this.matchCache.keys().next().value);
306
+ if (this.matchCache.size >= 256) {
307
+ const oldestKey = this.matchCache.keys().next().value;
308
+ if (oldestKey !== undefined)
309
+ this.matchCache.delete(oldestKey);
310
+ }
181
311
  this.matchCache.set(key, compiledMatches);
182
312
  }
183
- const matches = commandGroups.map((argv, index) => [...this.rules.filter(rule => matchesRule(argv, rule)), ...(compiledMatches?.[index] ?? [])]);
184
- const forbidden = matches.flat().find(rule => rule.decision === 'forbidden');
313
+ const matches = commandGroups.map((argv, index) => [
314
+ ...this.rules.filter((rule) => matchesRule(argv, rule)),
315
+ ...(compiledMatches?.[index] ?? []),
316
+ ]);
317
+ const forbidden = matches
318
+ .flat()
319
+ .find((rule) => rule.decision === "forbidden");
185
320
  if (forbidden)
186
- return decision(action, 'deny', forbidden.justification ?? 'Command forbidden by trusted rule', EMPTY_DELTA, true);
187
- const prompted = matches.flat().find(rule => rule.decision === 'prompt');
188
- const isRuleAllowed = !prompted && matches.length > 0 && matches.every(group => group.some(rule => rule.decision === 'allow'));
321
+ return decision(action, "deny", forbidden.justification ?? "Command forbidden by trusted rule", EMPTY_DELTA, true);
322
+ const prompted = matches
323
+ .flat()
324
+ .find((rule) => rule.decision === "prompt");
325
+ const isRuleAllowed = !prompted &&
326
+ matches.length > 0 &&
327
+ matches.every((group) => group.some((rule) => rule.decision === "allow"));
189
328
  if (prompted)
190
329
  shouldAsk = true;
191
- if (action.args.sandbox_permissions !== undefined && !['use_default', 'require_escalated'].includes(String(action.args.sandbox_permissions)))
192
- return decision(action, 'deny', 'Invalid sandbox permission request', EMPTY_DELTA, true);
330
+ if (action.args.sandbox_permissions !== undefined &&
331
+ !["use_default", "require_escalated"].includes(String(action.args.sandbox_permissions)))
332
+ return decision(action, "deny", "Invalid sandbox permission request", EMPTY_DELTA, true);
193
333
  const explicit = action.args.additional_permissions;
194
334
  if (explicit !== undefined) {
195
- if (!explicit || typeof explicit !== 'object' || Array.isArray(explicit) || Object.keys(explicit).some(key => !['readPaths', 'writePaths', 'domains'].includes(key)))
196
- return decision(action, 'deny', 'Invalid additional permissions', EMPTY_DELTA, true);
197
- for (const key of ['readPaths', 'writePaths', 'domains']) {
335
+ if (!explicit ||
336
+ typeof explicit !== "object" ||
337
+ Array.isArray(explicit) ||
338
+ Object.keys(explicit).some((key) => !["readPaths", "writePaths", "domains"].includes(key)))
339
+ return decision(action, "deny", "Invalid additional permissions", EMPTY_DELTA, true);
340
+ for (const key of ["readPaths", "writePaths", "domains"]) {
198
341
  const values = explicit[key] ?? [];
199
- if (!Array.isArray(values) || values.some(value => typeof value !== 'string' || !value || value.includes('\0')))
200
- return decision(action, 'deny', 'Invalid permission list', EMPTY_DELTA, true);
342
+ if (!Array.isArray(values) ||
343
+ values.some((value) => typeof value !== "string" ||
344
+ !value ||
345
+ value.includes("\0")))
346
+ return decision(action, "deny", "Invalid permission list", EMPTY_DELTA, true);
201
347
  delta[key].push(...values);
202
348
  }
203
349
  shouldAsk = true;
204
350
  }
205
351
  for (const item of analysis.commands) {
206
- const executable = basename(item.argv[0]);
207
- const isSafeRead = ['pwd', 'echo', 'printf', 'ls', 'cat', 'head', 'tail', 'wc', 'rg', 'grep', 'find', 'stat', 'true', 'false'].includes(executable);
208
- if (!isSafeRead && !['touch', 'mkdir', 'rmdir', 'rm', 'truncate', 'tee', 'chmod', 'chown', 'cp', 'mv', 'git', 'curl', 'wget'].includes(executable))
352
+ const executable = basename(item.argv[0] ?? "");
353
+ const isSafeRead = [
354
+ "pwd",
355
+ "echo",
356
+ "printf",
357
+ "ls",
358
+ "cat",
359
+ "head",
360
+ "tail",
361
+ "wc",
362
+ "rg",
363
+ "grep",
364
+ "find",
365
+ "stat",
366
+ "true",
367
+ "false",
368
+ ].includes(executable);
369
+ if (!isSafeRead &&
370
+ ![
371
+ "touch",
372
+ "mkdir",
373
+ "rmdir",
374
+ "rm",
375
+ "truncate",
376
+ "tee",
377
+ "chmod",
378
+ "chown",
379
+ "cp",
380
+ "mv",
381
+ "git",
382
+ "curl",
383
+ "wget",
384
+ ].includes(executable))
209
385
  shouldAsk = true;
210
- const operands = item.argv.slice(1).filter(arg => !arg.startsWith('-'));
386
+ const operands = item.argv
387
+ .slice(1)
388
+ .filter((arg) => !arg.startsWith("-"));
211
389
  const reads = [...item.reads], writes = [...item.writes];
212
390
  if (analysis.isSupported) {
213
- if (isSafeRead && !['echo', 'printf', 'true', 'false', 'pwd'].includes(executable))
391
+ if (isSafeRead &&
392
+ !["echo", "printf", "true", "false", "pwd"].includes(executable))
214
393
  reads.push(...operands);
215
- if (['touch', 'mkdir', 'rmdir', 'rm', 'truncate', 'tee', 'chmod', 'chown'].includes(executable))
394
+ if ([
395
+ "touch",
396
+ "mkdir",
397
+ "rmdir",
398
+ "rm",
399
+ "truncate",
400
+ "tee",
401
+ "chmod",
402
+ "chown",
403
+ ].includes(executable))
216
404
  writes.push(...operands);
217
- if (['cp', 'mv'].includes(executable)) {
405
+ if (["cp", "mv"].includes(executable)) {
218
406
  reads.push(...operands.slice(0, -1));
219
407
  writes.push(...operands.slice(-1));
220
- if (executable === 'mv')
408
+ if (executable === "mv")
221
409
  writes.push(...operands.slice(0, -1));
222
410
  }
223
- if (executable === 'git' && !['status', 'diff', 'log', 'show', 'ls-files', 'rev-parse'].includes(item.argv[1] ?? ''))
224
- writes.push(...(profile.readOnlyPaths ?? []).filter(path => path === join(action.cwd, '.git') || path.includes('/.git/')));
411
+ if (executable === "git" &&
412
+ ![
413
+ "status",
414
+ "diff",
415
+ "log",
416
+ "show",
417
+ "ls-files",
418
+ "rev-parse",
419
+ ].includes(item.argv[1] ?? ""))
420
+ writes.push(...(profile.readOnlyPaths ?? []).filter((path) => path === join(action.cwd, ".git") ||
421
+ path.includes("/.git/")));
225
422
  }
226
423
  for (const path of writes) {
227
424
  const target = await canonicalPath(path, action.cwd);
228
- if ([...profile.denyRead, ...profile.denyWrite].some(root => isWithin(target, root)))
229
- return decision(action, 'deny', 'Command writes a protected path', EMPTY_DELTA, true);
230
- if (!profile.writeRoots.some(root => isWithin(target, root)) || (profile.readOnlyPaths ?? []).some(root => isWithin(target, root))) {
425
+ if ([...profile.denyRead, ...profile.denyWrite].some((root) => isWithin(target, root)))
426
+ return decision(action, "deny", "Command writes a protected path", EMPTY_DELTA, true);
427
+ if (!profile.writeRoots.some((root) => isWithin(target, root)) ||
428
+ (profile.readOnlyPaths ?? []).some((root) => isWithin(target, root))) {
231
429
  shouldAsk = true;
232
430
  delta.writePaths.push(target);
233
431
  }
234
432
  }
235
433
  for (const path of reads) {
236
434
  const target = await canonicalPath(path, action.cwd);
237
- if (profile.denyRead.some(root => isWithin(target, root)))
238
- return decision(action, 'deny', 'Command reads a protected path', EMPTY_DELTA, true);
239
- if (!profile.readRoots.some(root => isWithin(target, root))) {
435
+ if (profile.denyRead.some((root) => isWithin(target, root)))
436
+ return decision(action, "deny", "Command reads a protected path", EMPTY_DELTA, true);
437
+ if (!profile.readRoots.some((root) => isWithin(target, root))) {
240
438
  shouldAsk = true;
241
439
  delta.readPaths.push(target);
242
440
  }
243
441
  }
244
- if (analysis.isSupported && ['curl', 'wget'].includes(executable)) {
245
- const urls = item.argv.slice(1).filter(arg => /^https?:\/\//.test(arg));
442
+ if (analysis.isSupported && ["curl", "wget"].includes(executable)) {
443
+ const urls = item.argv
444
+ .slice(1)
445
+ .filter((arg) => /^https?:\/\//.test(arg));
446
+ // A destination hidden in a config file or a schemeless URL still needs review.
447
+ const hasIndirectDestination = item.argv
448
+ .slice(1)
449
+ .some((arg) => executable === "curl"
450
+ ? /^(?:--config(?:=|$)|-K)/.test(arg)
451
+ : /^(?:--input-file(?:=|$)|-i)/.test(arg));
452
+ if (!urls.length || hasIndirectDestination)
453
+ shouldAsk = true;
246
454
  for (const text of urls) {
247
455
  const domain = normalizeHost(new URL(text).hostname);
248
- if (profile.deniedDomains.some(pattern => matchesDomain(domain, pattern)))
249
- return decision(action, 'deny', 'Network domain denied', EMPTY_DELTA, true);
250
- if (!profile.allowedDomains.some(pattern => matchesDomain(domain, pattern))) {
456
+ if (profile.deniedDomains.some((pattern) => matchesDomain(domain, pattern)))
457
+ return decision(action, "deny", "Network domain denied", EMPTY_DELTA, true);
458
+ if (!profile.allowedDomains.some((pattern) => matchesDomain(domain, pattern))) {
251
459
  delta.domains.push(domain);
252
460
  shouldAsk = true;
253
461
  }
254
462
  }
255
463
  }
256
464
  }
257
- for (const key of ['readPaths', 'writePaths'])
258
- delta[key] = [...new Set(await Promise.all(delta[key].map(path => canonicalPath(path, action.cwd))))];
259
- if (delta.readPaths.some(path => profile.denyRead.some(root => isWithin(path, root))) || delta.writePaths.some(path => [...profile.denyRead, ...profile.denyWrite].some(root => isWithin(path, root))) || delta.domains.some(host => !isNetworkHost(host) || profile.deniedDomains.some(pattern => matchesDomain(host, pattern))))
260
- return decision(action, 'deny', 'Requested permissions target an absolute deny', EMPTY_DELTA, true);
261
- const boundDelta = { ...delta, domains: [...new Set(delta.domains.map(normalizeHost))] };
465
+ for (const key of ["readPaths", "writePaths"])
466
+ delta[key] = [
467
+ ...new Set(await Promise.all(delta[key].map((path) => canonicalPath(path, action.cwd)))),
468
+ ];
469
+ if (delta.readPaths.some((path) => profile.denyRead.some((root) => isWithin(path, root))) ||
470
+ delta.writePaths.some((path) => [...profile.denyRead, ...profile.denyWrite].some((root) => isWithin(path, root))) ||
471
+ delta.domains.some((host) => !isNetworkHost(host) ||
472
+ profile.deniedDomains.some((pattern) => matchesDomain(host, pattern))))
473
+ return decision(action, "deny", "Requested permissions target an absolute deny", EMPTY_DELTA, true);
474
+ const boundDelta = {
475
+ ...delta,
476
+ domains: [...new Set(delta.domains.map(normalizeHost))],
477
+ };
262
478
  if (isRuleAllowed)
263
- return immutable({ ...decision(action, 'allow', 'Trusted prefix rule authorizes this command', boundDelta), authority: { kind: 'command-rule', actionDigest: action.digest, ruleDigest: digest(matches) } });
264
- const isFullRequest = action.args.sandbox_permissions === 'require_escalated' && explicit === undefined;
265
- shouldAsk ||= action.args.sandbox_permissions === 'require_escalated';
266
- const reason = prompted?.justification ?? (shouldAsk ? 'Command requires approval review' : 'Within the ordinary approval scope');
267
- return immutable({ ...decision(action, shouldAsk ? 'ask' : 'allow', reason, boundDelta), approvalCategory: prompted ? 'rules' : 'sandbox', ...(isFullRequest ? { authority: { kind: 'reviewed-command', actionDigest: action.digest } } : {}) });
479
+ return immutable({
480
+ ...decision(action, "allow", "Trusted prefix rule authorizes this command", boundDelta),
481
+ authority: {
482
+ kind: "command-rule",
483
+ actionDigest: action.digest,
484
+ ruleDigest: digest(matches),
485
+ },
486
+ });
487
+ const isFullRequest = action.args.sandbox_permissions === "require_escalated" &&
488
+ explicit === undefined;
489
+ shouldAsk ||= action.args.sandbox_permissions === "require_escalated";
490
+ const reason = prompted?.justification ??
491
+ (shouldAsk
492
+ ? "Command requires approval review"
493
+ : "Within the ordinary approval scope");
494
+ return immutable({
495
+ ...decision(action, shouldAsk ? "ask" : "allow", reason, boundDelta),
496
+ approvalCategory: prompted ? "rules" : "sandbox",
497
+ ...(isFullRequest
498
+ ? {
499
+ authority: {
500
+ kind: "reviewed-command",
501
+ actionDigest: action.digest,
502
+ },
503
+ }
504
+ : {}),
505
+ });
268
506
  }
269
507
  async resolvedProfile(cwd) {
270
508
  const result = { ...this.profile };
271
- for (const key of ['readRoots', 'writeRoots', 'denyRead', 'denyWrite'])
272
- result[key] = await Promise.all(this.profile[key].map(path => canonicalPath(path, cwd)));
509
+ for (const key of [
510
+ "readRoots",
511
+ "writeRoots",
512
+ "denyRead",
513
+ "denyWrite",
514
+ ])
515
+ result[key] = await Promise.all(this.profile[key].map((path) => canonicalPath(path, cwd)));
273
516
  if (this.profile.readOnlyPaths)
274
- result.readOnlyPaths = await Promise.all(this.profile.readOnlyPaths.map(path => canonicalPath(path, cwd)));
517
+ result.readOnlyPaths = await Promise.all(this.profile.readOnlyPaths.map((path) => canonicalPath(path, cwd)));
275
518
  return result;
276
519
  }
277
520
  }