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.
- package/NOTICE +4 -2
- package/README.ko.md +85 -0
- package/README.md +51 -52
- package/dist/approval-commands.d.ts +3 -3
- package/dist/approval-commands.js +161 -34
- package/dist/approval-commands.js.map +1 -1
- package/dist/approval-settings.d.ts +1 -1
- package/dist/approval-settings.js +17 -10
- package/dist/approval-settings.js.map +1 -1
- package/dist/approvals.d.ts +10 -9
- package/dist/approvals.js +196 -75
- package/dist/approvals.js.map +1 -1
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +37 -13
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +63 -34
- package/dist/cli.js.map +1 -1
- package/dist/context-files.d.ts +2 -2
- package/dist/context-files.js +40 -19
- package/dist/context-files.js.map +1 -1
- package/dist/contracts.d.ts +39 -39
- package/dist/contracts.js +117 -51
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.js +147 -43
- package/dist/index.js.map +1 -1
- package/dist/pi-host.js +3 -3
- package/dist/pi-host.js.map +1 -1
- package/dist/policy/domains.js +17 -7
- package/dist/policy/domains.js.map +1 -1
- package/dist/policy/index.d.ts +7 -7
- package/dist/policy/index.js +382 -139
- package/dist/policy/index.js.map +1 -1
- package/dist/policy/paths.js +31 -18
- package/dist/policy/paths.js.map +1 -1
- package/dist/policy/rule-engine.d.ts +2 -0
- package/dist/policy/rule-engine.js +283 -0
- package/dist/policy/rule-engine.js.map +1 -0
- package/dist/policy/rule-types.d.ts +30 -0
- package/dist/policy/rule-types.js +2 -0
- package/dist/policy/rule-types.js.map +1 -0
- package/dist/policy/rules-sync.d.ts +1 -0
- package/dist/policy/rules-sync.js +14 -0
- package/dist/policy/rules-sync.js.map +1 -0
- package/dist/policy/rules-worker.d.ts +1 -0
- package/dist/policy/rules-worker.js +14 -0
- package/dist/policy/rules-worker.js.map +1 -0
- package/dist/policy/rules.d.ts +4 -31
- package/dist/policy/rules.js +126 -38
- package/dist/policy/rules.js.map +1 -1
- package/dist/policy/shell.js +70 -41
- package/dist/policy/shell.js.map +1 -1
- package/dist/policy/starlark/builtins.d.ts +6 -0
- package/dist/policy/starlark/builtins.js +764 -0
- package/dist/policy/starlark/builtins.js.map +1 -0
- package/dist/policy/starlark/evaluator.d.ts +18 -0
- package/dist/policy/starlark/evaluator.js +518 -0
- package/dist/policy/starlark/evaluator.js.map +1 -0
- package/dist/policy/starlark/lexer.d.ts +14 -0
- package/dist/policy/starlark/lexer.js +189 -0
- package/dist/policy/starlark/lexer.js.map +1 -0
- package/dist/policy/starlark/parser.d.ts +140 -0
- package/dist/policy/starlark/parser.js +508 -0
- package/dist/policy/starlark/parser.js.map +1 -0
- package/dist/policy/starlark/validation.d.ts +4 -0
- package/dist/policy/starlark/validation.js +183 -0
- package/dist/policy/starlark/validation.js.map +1 -0
- package/dist/policy/starlark/values.d.ts +46 -0
- package/dist/policy/starlark/values.js +363 -0
- package/dist/policy/starlark/values.js.map +1 -0
- package/dist/reports.d.ts +1 -1
- package/dist/reports.js +66 -30
- package/dist/reports.js.map +1 -1
- package/dist/review/context.d.ts +16 -3
- package/dist/review/context.js +254 -48
- package/dist/review/context.js.map +1 -1
- package/dist/review/investigation.d.ts +2 -2
- package/dist/review/investigation.js +57 -16
- package/dist/review/investigation.js.map +1 -1
- package/dist/review/lifecycle.d.ts +1 -1
- package/dist/review/lifecycle.js +72 -29
- package/dist/review/lifecycle.js.map +1 -1
- package/dist/review/policy.js +27 -5
- package/dist/review/policy.js.map +1 -1
- package/dist/review/upstream-policy.js +1 -1
- package/dist/review/upstream-policy.js.map +1 -1
- package/dist/review/user-input.d.ts +5 -0
- package/dist/review/user-input.js +54 -0
- package/dist/review/user-input.js.map +1 -0
- package/dist/reviewer.d.ts +16 -6
- package/dist/reviewer.js +258 -70
- package/dist/reviewer.js.map +1 -1
- package/dist/signals.js +24 -9
- package/dist/signals.js.map +1 -1
- package/dist/startup.d.ts +4 -4
- package/dist/startup.js +139 -41
- package/dist/startup.js.map +1 -1
- package/dist/tools/controller.d.ts +19 -13
- package/dist/tools/controller.js +460 -118
- package/dist/tools/controller.js.map +1 -1
- package/dist/tools/environment.d.ts +1 -0
- package/dist/tools/environment.js +5 -0
- package/dist/tools/environment.js.map +1 -1
- package/dist/tools/executor.d.ts +1 -1
- package/dist/tools/executor.js +45 -28
- package/dist/tools/executor.js.map +1 -1
- package/dist/tools/external.d.ts +9 -8
- package/dist/tools/external.js +22 -11
- package/dist/tools/external.js.map +1 -1
- package/dist/tools/mcp.d.ts +6 -6
- package/dist/tools/mcp.js +208 -79
- package/dist/tools/mcp.js.map +1 -1
- package/dist/tools/mutation-queue.js.map +1 -1
- package/docs/publishing.ko.md +114 -0
- package/docs/publishing.md +49 -47
- package/docs/security/dependencies.ko.md +54 -0
- package/docs/security/dependencies.md +27 -26
- package/docs/testing/auto-review-protection.ko.md +158 -0
- package/docs/testing/auto-review-protection.md +105 -97
- package/docs/usage.ko.md +242 -0
- package/docs/usage.md +127 -111
- package/package.json +106 -95
- package/dist/native/darwin-arm64/artifact.json +0 -5
- package/dist/native/darwin-arm64/pi-guard-execpolicy +0 -0
- package/dist/native/linux-x64/artifact.json +0 -5
- package/dist/native/linux-x64/pi-guard-execpolicy +0 -0
- package/native/execpolicy/LICENSE +0 -201
- package/native/execpolicy/NOTICE +0 -6
package/dist/policy/index.js
CHANGED
|
@@ -1,84 +1,160 @@
|
|
|
1
|
-
import { readFile, stat } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { fileURLToPath } from
|
|
5
|
-
import { createProfile, decision, digest,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
export { canonicalPath, isWithin,
|
|
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:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 !==
|
|
21
|
-
throw new GuardError(
|
|
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(
|
|
41
|
+
throw new GuardError("INVALID_SETTINGS", `Unknown setting: ${key}`);
|
|
26
42
|
const settings = { ...DEFAULT_SETTINGS, ...raw };
|
|
27
|
-
if (![
|
|
28
|
-
throw new GuardError(
|
|
29
|
-
for (const key of [
|
|
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(
|
|
32
|
-
if (!Array.isArray(settings.allowedDomains) ||
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 !==
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
114
|
+
throw new GuardError("INVALID_SETTINGS", "Invalid command rules");
|
|
61
115
|
for (const rule of settings.commandRules) {
|
|
62
|
-
if (!rule ||
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
129
|
+
return validateSettings(JSON.parse(await readFile(path, "utf8")));
|
|
72
130
|
}
|
|
73
131
|
catch (error) {
|
|
74
|
-
throw new GuardError(
|
|
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 = [
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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,
|
|
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 !==
|
|
175
|
+
if (error.code !== "ENOENT")
|
|
100
176
|
throw error;
|
|
101
177
|
}
|
|
102
|
-
for (const name of [
|
|
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 !==
|
|
185
|
+
if (error.code !== "ENOENT")
|
|
110
186
|
throw error;
|
|
111
187
|
}
|
|
112
188
|
}
|
|
113
|
-
readOnlyPaths.push(await canonicalPath(join(root,
|
|
114
|
-
hardWrite.push(...[
|
|
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({
|
|
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() {
|
|
127
|
-
|
|
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 => ({
|
|
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) => ({
|
|
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({
|
|
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,
|
|
146
|
-
if (action.policyRevision !== this.revision ||
|
|
147
|
-
|
|
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,
|
|
150
|
-
if (action.tool ===
|
|
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 !==
|
|
154
|
-
return decision(action,
|
|
155
|
-
const target = await canonicalPath(await resolveToolPath(input, action.cwd, action.tool ===
|
|
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 ===
|
|
158
|
-
if (profile.denyRead.some(p => isWithin(target, p)) ||
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
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,
|
|
164
|
-
return decision(action,
|
|
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 !==
|
|
170
|
-
return decision(action,
|
|
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 ===
|
|
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.
|
|
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) => [
|
|
184
|
-
|
|
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,
|
|
187
|
-
const prompted = matches
|
|
188
|
-
|
|
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 &&
|
|
192
|
-
|
|
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 ||
|
|
196
|
-
|
|
197
|
-
|
|
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) ||
|
|
200
|
-
|
|
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 = [
|
|
208
|
-
|
|
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
|
|
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 &&
|
|
391
|
+
if (isSafeRead &&
|
|
392
|
+
!["echo", "printf", "true", "false", "pwd"].includes(executable))
|
|
214
393
|
reads.push(...operands);
|
|
215
|
-
if ([
|
|
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 ([
|
|
405
|
+
if (["cp", "mv"].includes(executable)) {
|
|
218
406
|
reads.push(...operands.slice(0, -1));
|
|
219
407
|
writes.push(...operands.slice(-1));
|
|
220
|
-
if (executable ===
|
|
408
|
+
if (executable === "mv")
|
|
221
409
|
writes.push(...operands.slice(0, -1));
|
|
222
410
|
}
|
|
223
|
-
if (executable ===
|
|
224
|
-
|
|
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,
|
|
230
|
-
if (!profile.writeRoots.some(
|
|
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,
|
|
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 && [
|
|
245
|
-
const urls = item.argv
|
|
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,
|
|
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 [
|
|
258
|
-
delta[key] = [
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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({
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 [
|
|
272
|
-
|
|
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
|
}
|