pi-auto-permissions 0.1.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/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +304 -0
- package/THIRD_PARTY_NOTICES.md +70 -0
- package/docs/implementation-plan.md +311 -0
- package/docs/invariants.md +555 -0
- package/package.json +59 -0
- package/src/canonical.ts +314 -0
- package/src/commands/index.ts +362 -0
- package/src/domain.ts +198 -0
- package/src/extension.ts +430 -0
- package/src/guardian/circuit-breaker.ts +102 -0
- package/src/guardian/index.ts +74 -0
- package/src/guardian/policy.ts +135 -0
- package/src/guardian/prompt.ts +379 -0
- package/src/guardian/reviewer.ts +599 -0
- package/src/guardian/types.ts +149 -0
- package/src/guardian/verdict.ts +211 -0
- package/src/pi/index.ts +13 -0
- package/src/pi/model-reviewer.ts +235 -0
- package/src/pi/transcript.ts +524 -0
- package/src/policy/dangerous-command.ts +312 -0
- package/src/policy/path-policy.ts +501 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/permission-engine.ts +474 -0
- package/src/sandbox/config.ts +188 -0
- package/src/sandbox/controller.ts +354 -0
- package/src/sandbox/index.ts +26 -0
- package/src/sandbox/runtime.ts +28 -0
- package/src/sandbox/types.ts +125 -0
- package/src/state/config-store.ts +580 -0
- package/src/state/index.ts +2 -0
- package/src/tools/bash.ts +101 -0
- package/src/tools/gate.ts +74 -0
- package/src/tools/index.ts +2 -0
- package/vendor/openai-codex/NOTICE +6 -0
- package/vendor/openai-codex/README.md +17 -0
- package/vendor/openai-codex/guardian/policy.md +42 -0
- package/vendor/openai-codex/guardian/policy_template.md +58 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { lstat, readFile, readlink, realpath } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { Stats } from "node:fs";
|
|
5
|
+
import type { AdmissionDisposition } from "../domain.ts";
|
|
6
|
+
|
|
7
|
+
export const READ_ONLY_FILE_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
8
|
+
export const DIRECT_MUTATION_TOOLS = ["write", "edit"] as const;
|
|
9
|
+
export const DEFAULT_PROTECTED_METADATA_NAMES = [".git", ".agents", ".codex", ".pi"] as const;
|
|
10
|
+
export const MAX_DIRECT_PATH_CODE_UNITS = 32 * 1024;
|
|
11
|
+
const MAX_GIT_POINTER_BYTES = 8 * 1024;
|
|
12
|
+
|
|
13
|
+
export type ReadOnlyFileToolName = (typeof READ_ONLY_FILE_TOOLS)[number];
|
|
14
|
+
export type DirectMutationToolName = (typeof DIRECT_MUTATION_TOOLS)[number];
|
|
15
|
+
|
|
16
|
+
export interface PathPolicyFileSystem {
|
|
17
|
+
lstat(path: string): Promise<Stats>;
|
|
18
|
+
readlink(path: string): Promise<string>;
|
|
19
|
+
realpath(path: string): Promise<string>;
|
|
20
|
+
readFile(path: string, encoding: "utf8"): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StaticPathPolicyOptions {
|
|
24
|
+
cwd: string;
|
|
25
|
+
workspaceRoots: readonly string[];
|
|
26
|
+
temporaryRoots?: readonly string[];
|
|
27
|
+
resolvedGitDirectories?: readonly string[];
|
|
28
|
+
/** Extension-owned control-plane paths that Auto file tools may never mutate. */
|
|
29
|
+
deniedRoots?: readonly string[];
|
|
30
|
+
protectedMetadataNames?: readonly string[];
|
|
31
|
+
fileSystem?: PathPolicyFileSystem;
|
|
32
|
+
maxSymlinks?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DirectFileToolRequest {
|
|
36
|
+
toolName: string;
|
|
37
|
+
input: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PathPolicyDecision {
|
|
41
|
+
disposition: AdmissionDisposition;
|
|
42
|
+
reason: string;
|
|
43
|
+
canonicalTarget?: string;
|
|
44
|
+
writableRoot?: string;
|
|
45
|
+
protectedRoot?: string;
|
|
46
|
+
deniedRoot?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class PathResolutionError extends Error {
|
|
50
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
51
|
+
super(message, options);
|
|
52
|
+
this.name = "PathResolutionError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class StaticPathPolicy {
|
|
57
|
+
readonly cwd: string;
|
|
58
|
+
readonly workspaceRoots: readonly string[];
|
|
59
|
+
readonly temporaryRoots: readonly string[];
|
|
60
|
+
readonly writableRoots: readonly string[];
|
|
61
|
+
readonly protectedRoots: readonly string[];
|
|
62
|
+
readonly deniedRoots: readonly string[];
|
|
63
|
+
private readonly fileSystem: PathPolicyFileSystem;
|
|
64
|
+
private readonly maxSymlinks: number;
|
|
65
|
+
|
|
66
|
+
private constructor(materialized: MaterializedPolicy) {
|
|
67
|
+
this.cwd = materialized.cwd;
|
|
68
|
+
this.workspaceRoots = Object.freeze(materialized.workspaceRoots);
|
|
69
|
+
this.temporaryRoots = Object.freeze(materialized.temporaryRoots);
|
|
70
|
+
this.writableRoots = Object.freeze(materialized.writableRoots);
|
|
71
|
+
this.protectedRoots = Object.freeze(materialized.protectedRoots);
|
|
72
|
+
this.deniedRoots = Object.freeze(materialized.deniedRoots);
|
|
73
|
+
this.fileSystem = materialized.fileSystem;
|
|
74
|
+
this.maxSymlinks = materialized.maxSymlinks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async create(options: StaticPathPolicyOptions): Promise<StaticPathPolicy> {
|
|
78
|
+
return new StaticPathPolicy(await materializePolicy(options));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async classify(request: Readonly<DirectFileToolRequest>): Promise<PathPolicyDecision> {
|
|
82
|
+
if (isReadOnlyFileTool(request.toolName)) {
|
|
83
|
+
return { disposition: "admit", reason: "known read-only file tool" };
|
|
84
|
+
}
|
|
85
|
+
if (!isDirectMutationTool(request.toolName)) {
|
|
86
|
+
return { disposition: "review", reason: "tool is not a known direct file tool" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const requestedPath = extractPath(request.input);
|
|
90
|
+
if (requestedPath === null) {
|
|
91
|
+
return { disposition: "review", reason: "direct mutation has no valid path" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Preserve the lexical identity as well as the resolved identity. This
|
|
95
|
+
// prevents a protected/control-plane path that did not exist at startup
|
|
96
|
+
// from becoming statically writable if it is later replaced by a symlink
|
|
97
|
+
// into an otherwise writable root.
|
|
98
|
+
const lexicalTarget = path.resolve(this.cwd, requestedPath);
|
|
99
|
+
const lexicalDeniedRoot = mostSpecificContainingRoot(lexicalTarget, this.deniedRoots);
|
|
100
|
+
if (lexicalDeniedRoot !== undefined) {
|
|
101
|
+
return {
|
|
102
|
+
disposition: "deny",
|
|
103
|
+
reason: "target is extension-owned permission state",
|
|
104
|
+
canonicalTarget: lexicalTarget,
|
|
105
|
+
deniedRoot: lexicalDeniedRoot,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const lexicalProtectedRoot = mostSpecificContainingRoot(
|
|
109
|
+
lexicalTarget,
|
|
110
|
+
this.protectedRoots,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let canonicalTarget: string;
|
|
114
|
+
try {
|
|
115
|
+
canonicalTarget = await resolveStaticTarget(requestedPath, this.cwd, {
|
|
116
|
+
fileSystem: this.fileSystem,
|
|
117
|
+
maxSymlinks: this.maxSymlinks,
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
disposition: "review",
|
|
122
|
+
reason: `target could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deniedRoot = mostSpecificContainingRoot(canonicalTarget, this.deniedRoots);
|
|
127
|
+
if (deniedRoot !== undefined) {
|
|
128
|
+
return {
|
|
129
|
+
disposition: "deny",
|
|
130
|
+
reason: "target is extension-owned permission state",
|
|
131
|
+
canonicalTarget,
|
|
132
|
+
deniedRoot,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const protectedRoot =
|
|
137
|
+
lexicalProtectedRoot ?? mostSpecificContainingRoot(canonicalTarget, this.protectedRoots);
|
|
138
|
+
if (protectedRoot !== undefined) {
|
|
139
|
+
return {
|
|
140
|
+
disposition: "review",
|
|
141
|
+
reason: "target is protected metadata",
|
|
142
|
+
canonicalTarget,
|
|
143
|
+
protectedRoot,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const writableRoot = mostSpecificContainingRoot(canonicalTarget, this.writableRoots);
|
|
148
|
+
if (writableRoot === undefined) {
|
|
149
|
+
return {
|
|
150
|
+
disposition: "review",
|
|
151
|
+
reason: "target is outside writable roots",
|
|
152
|
+
canonicalTarget,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
disposition: "admit",
|
|
158
|
+
reason: "target is inside a writable root",
|
|
159
|
+
canonicalTarget,
|
|
160
|
+
writableRoot,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function createStaticPathPolicy(options: StaticPathPolicyOptions): Promise<StaticPathPolicy> {
|
|
166
|
+
return StaticPathPolicy.create(options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function classifyDirectFileTool(
|
|
170
|
+
policy: StaticPathPolicy,
|
|
171
|
+
request: Readonly<DirectFileToolRequest>,
|
|
172
|
+
): Promise<PathPolicyDecision> {
|
|
173
|
+
return policy.classify(request);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function resolveStaticTarget(
|
|
177
|
+
requestedPath: string,
|
|
178
|
+
cwd: string,
|
|
179
|
+
options: {
|
|
180
|
+
fileSystem?: PathPolicyFileSystem;
|
|
181
|
+
maxSymlinks?: number;
|
|
182
|
+
} = {},
|
|
183
|
+
): Promise<string> {
|
|
184
|
+
if (requestedPath.length === 0) throw new PathResolutionError("path must not be empty");
|
|
185
|
+
if (requestedPath.length > MAX_DIRECT_PATH_CODE_UNITS) {
|
|
186
|
+
throw new PathResolutionError("path exceeds the static policy input limit");
|
|
187
|
+
}
|
|
188
|
+
if (requestedPath.includes("\0")) throw new PathResolutionError("path contains a NUL byte");
|
|
189
|
+
if (cwd.length === 0) throw new PathResolutionError("cwd must not be empty");
|
|
190
|
+
|
|
191
|
+
const fileSystem = options.fileSystem ?? NODE_PATH_FILE_SYSTEM;
|
|
192
|
+
const maxSymlinks = validateMaxSymlinks(options.maxSymlinks ?? 40);
|
|
193
|
+
let absolute: string;
|
|
194
|
+
if (path.isAbsolute(requestedPath)) {
|
|
195
|
+
absolute = requestedPath;
|
|
196
|
+
} else {
|
|
197
|
+
let canonicalCwd: string;
|
|
198
|
+
try {
|
|
199
|
+
canonicalCwd = await fileSystem.realpath(cwd);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new PathResolutionError(`cannot resolve cwd ${cwd}`, { cause: error });
|
|
202
|
+
}
|
|
203
|
+
absolute = `${canonicalCwd}${canonicalCwd.endsWith(path.sep) ? "" : path.sep}${requestedPath}`;
|
|
204
|
+
}
|
|
205
|
+
return resolveAbsoluteTarget(absolute, fileSystem, maxSymlinks);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function pathIsInside(candidate: string, root: string): boolean {
|
|
209
|
+
const relative = path.relative(root, candidate);
|
|
210
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isReadOnlyFileTool(toolName: string): toolName is ReadOnlyFileToolName {
|
|
214
|
+
return (READ_ONLY_FILE_TOOLS as readonly string[]).includes(toolName);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isDirectMutationTool(toolName: string): toolName is DirectMutationToolName {
|
|
218
|
+
return (DIRECT_MUTATION_TOOLS as readonly string[]).includes(toolName);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface MaterializedPolicy {
|
|
222
|
+
cwd: string;
|
|
223
|
+
workspaceRoots: string[];
|
|
224
|
+
temporaryRoots: string[];
|
|
225
|
+
writableRoots: string[];
|
|
226
|
+
protectedRoots: string[];
|
|
227
|
+
deniedRoots: string[];
|
|
228
|
+
fileSystem: PathPolicyFileSystem;
|
|
229
|
+
maxSymlinks: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function materializePolicy(options: StaticPathPolicyOptions): Promise<MaterializedPolicy> {
|
|
233
|
+
const fileSystem = options.fileSystem ?? NODE_PATH_FILE_SYSTEM;
|
|
234
|
+
const maxSymlinks = validateMaxSymlinks(options.maxSymlinks ?? 40);
|
|
235
|
+
const cwd = await resolveExistingRoot(options.cwd, process.cwd(), fileSystem, maxSymlinks, "cwd");
|
|
236
|
+
if (options.workspaceRoots.length === 0) throw new TypeError("at least one workspace root is required");
|
|
237
|
+
|
|
238
|
+
const workspaceRoots = deduplicate(
|
|
239
|
+
await Promise.all(
|
|
240
|
+
options.workspaceRoots.map((root) => resolveExistingRoot(root, cwd, fileSystem, maxSymlinks, "workspace root")),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
const requestedTemporaryRoots = options.temporaryRoots ?? defaultTemporaryRoots();
|
|
244
|
+
const temporaryRoots = deduplicate(
|
|
245
|
+
await Promise.all(
|
|
246
|
+
requestedTemporaryRoots.map((root) =>
|
|
247
|
+
resolveExistingRoot(root, cwd, fileSystem, maxSymlinks, "temporary root"),
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
const writableRoots = deduplicate([...workspaceRoots, ...temporaryRoots]);
|
|
252
|
+
const protectedNames = options.protectedMetadataNames ?? DEFAULT_PROTECTED_METADATA_NAMES;
|
|
253
|
+
for (const name of protectedNames) validateProtectedName(name);
|
|
254
|
+
|
|
255
|
+
const protectedRoots: string[] = [];
|
|
256
|
+
for (const workspaceRoot of workspaceRoots) {
|
|
257
|
+
for (const name of protectedNames) {
|
|
258
|
+
const lexicalPath = path.join(workspaceRoot, name);
|
|
259
|
+
protectedRoots.push(lexicalPath);
|
|
260
|
+
const resolved = await tryResolveTarget(lexicalPath, workspaceRoot, fileSystem, maxSymlinks);
|
|
261
|
+
if (resolved !== null) protectedRoots.push(resolved);
|
|
262
|
+
}
|
|
263
|
+
const gitDirectory = await detectGitDirectory(workspaceRoot, fileSystem, maxSymlinks);
|
|
264
|
+
if (gitDirectory !== null) protectedRoots.push(gitDirectory);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const gitDirectory of options.resolvedGitDirectories ?? []) {
|
|
268
|
+
protectedRoots.push(await resolveStaticTarget(gitDirectory, cwd, { fileSystem, maxSymlinks }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const deniedRoots = deduplicate(
|
|
272
|
+
(
|
|
273
|
+
await Promise.all(
|
|
274
|
+
(options.deniedRoots ?? []).map(async (root) => [
|
|
275
|
+
path.resolve(cwd, root),
|
|
276
|
+
await resolveStaticTarget(root, cwd, { fileSystem, maxSymlinks }),
|
|
277
|
+
]),
|
|
278
|
+
)
|
|
279
|
+
).flat(),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
cwd,
|
|
284
|
+
workspaceRoots,
|
|
285
|
+
temporaryRoots,
|
|
286
|
+
writableRoots,
|
|
287
|
+
protectedRoots: deduplicate(protectedRoots),
|
|
288
|
+
deniedRoots,
|
|
289
|
+
fileSystem,
|
|
290
|
+
maxSymlinks,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function resolveExistingRoot(
|
|
295
|
+
root: string,
|
|
296
|
+
cwd: string,
|
|
297
|
+
fileSystem: PathPolicyFileSystem,
|
|
298
|
+
maxSymlinks: number,
|
|
299
|
+
label: string,
|
|
300
|
+
): Promise<string> {
|
|
301
|
+
const resolved = await resolveStaticTarget(root, cwd, { fileSystem, maxSymlinks });
|
|
302
|
+
let stats: Stats;
|
|
303
|
+
try {
|
|
304
|
+
stats = await fileSystem.lstat(resolved);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
throw new PathResolutionError(`${label} does not exist: ${resolved}`, { cause: error });
|
|
307
|
+
}
|
|
308
|
+
if (!stats.isDirectory()) throw new PathResolutionError(`${label} is not a directory: ${resolved}`);
|
|
309
|
+
return fileSystem.realpath(resolved);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolveAbsoluteTarget(
|
|
313
|
+
initialAbsolute: string,
|
|
314
|
+
fileSystem: PathPolicyFileSystem,
|
|
315
|
+
maxSymlinks: number,
|
|
316
|
+
): Promise<string> {
|
|
317
|
+
const initialParsed = path.parse(initialAbsolute);
|
|
318
|
+
if (initialParsed.root.length === 0) throw new PathResolutionError("target must be absolute");
|
|
319
|
+
let root = initialParsed.root;
|
|
320
|
+
let resolvedComponents: string[] = [];
|
|
321
|
+
let pendingComponents = splitPathComponents(initialAbsolute, root);
|
|
322
|
+
let followedSymlinks = 0;
|
|
323
|
+
|
|
324
|
+
while (pendingComponents.length > 0) {
|
|
325
|
+
const component = pendingComponents.shift();
|
|
326
|
+
if (component === undefined || component === "" || component === ".") continue;
|
|
327
|
+
if (component === "..") {
|
|
328
|
+
resolvedComponents.pop();
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const current = path.join(root, ...resolvedComponents);
|
|
333
|
+
const candidate = path.join(current, component);
|
|
334
|
+
let stats: Stats;
|
|
335
|
+
try {
|
|
336
|
+
stats = await fileSystem.lstat(candidate);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
339
|
+
throw new PathResolutionError(`cannot inspect ${candidate}`, { cause: error });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const unresolved = [component, ...pendingComponents];
|
|
343
|
+
if (unresolved.includes("..")) {
|
|
344
|
+
throw new PathResolutionError("unresolved path suffix contains '..'");
|
|
345
|
+
}
|
|
346
|
+
let canonicalAncestor: string;
|
|
347
|
+
try {
|
|
348
|
+
canonicalAncestor = await fileSystem.realpath(current);
|
|
349
|
+
} catch (ancestorError) {
|
|
350
|
+
throw new PathResolutionError(`cannot resolve nearest existing ancestor ${current}`, {
|
|
351
|
+
cause: ancestorError,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return path.join(canonicalAncestor, ...unresolved.filter((part) => part !== "" && part !== "."));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!stats.isSymbolicLink()) {
|
|
358
|
+
resolvedComponents.push(component);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
followedSymlinks += 1;
|
|
363
|
+
if (followedSymlinks > maxSymlinks) {
|
|
364
|
+
throw new PathResolutionError(`path exceeds ${maxSymlinks} symbolic links`);
|
|
365
|
+
}
|
|
366
|
+
let linkTarget: string;
|
|
367
|
+
try {
|
|
368
|
+
linkTarget = await fileSystem.readlink(candidate);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw new PathResolutionError(`cannot read symbolic link ${candidate}`, { cause: error });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (path.isAbsolute(linkTarget)) {
|
|
374
|
+
const parsedTarget = path.parse(linkTarget);
|
|
375
|
+
root = parsedTarget.root;
|
|
376
|
+
resolvedComponents = [];
|
|
377
|
+
pendingComponents = [
|
|
378
|
+
...splitPathComponents(linkTarget, parsedTarget.root),
|
|
379
|
+
...pendingComponents,
|
|
380
|
+
];
|
|
381
|
+
} else {
|
|
382
|
+
pendingComponents = [...splitPathComponents(linkTarget, ""), ...pendingComponents];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const resolved = path.join(root, ...resolvedComponents);
|
|
387
|
+
try {
|
|
388
|
+
return await fileSystem.realpath(resolved);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new PathResolutionError(`cannot resolve ${resolved}`, { cause: error });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function splitPathComponents(value: string, root: string): string[] {
|
|
395
|
+
return value.slice(root.length).split(path.sep).filter((part) => part.length > 0);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function detectGitDirectory(
|
|
399
|
+
workspaceRoot: string,
|
|
400
|
+
fileSystem: PathPolicyFileSystem,
|
|
401
|
+
maxSymlinks: number,
|
|
402
|
+
): Promise<string | null> {
|
|
403
|
+
const dotGit = path.join(workspaceRoot, ".git");
|
|
404
|
+
let stats: Stats;
|
|
405
|
+
try {
|
|
406
|
+
stats = await fileSystem.lstat(dotGit);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (isErrorCode(error, "ENOENT")) return null;
|
|
409
|
+
throw new PathResolutionError(`cannot inspect ${dotGit}`, { cause: error });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (stats.isDirectory() || stats.isSymbolicLink()) {
|
|
413
|
+
return resolveStaticTarget(dotGit, workspaceRoot, { fileSystem, maxSymlinks });
|
|
414
|
+
}
|
|
415
|
+
if (!stats.isFile()) return null;
|
|
416
|
+
if (stats.size > MAX_GIT_POINTER_BYTES) {
|
|
417
|
+
throw new PathResolutionError(`Git pointer exceeds ${MAX_GIT_POINTER_BYTES} bytes: ${dotGit}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let contents: string;
|
|
421
|
+
try {
|
|
422
|
+
contents = await fileSystem.readFile(dotGit, "utf8");
|
|
423
|
+
} catch (error) {
|
|
424
|
+
throw new PathResolutionError(`cannot read Git pointer ${dotGit}`, { cause: error });
|
|
425
|
+
}
|
|
426
|
+
const firstLine = contents.split(/\r?\n/u, 1)[0] ?? "";
|
|
427
|
+
const match = /^gitdir:\s*(.+?)\s*$/iu.exec(firstLine);
|
|
428
|
+
if (match?.[1] === undefined) return null;
|
|
429
|
+
return resolveStaticTarget(match[1], workspaceRoot, { fileSystem, maxSymlinks });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function tryResolveTarget(
|
|
433
|
+
target: string,
|
|
434
|
+
cwd: string,
|
|
435
|
+
fileSystem: PathPolicyFileSystem,
|
|
436
|
+
maxSymlinks: number,
|
|
437
|
+
): Promise<string | null> {
|
|
438
|
+
try {
|
|
439
|
+
return await resolveStaticTarget(target, cwd, { fileSystem, maxSymlinks });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof PathResolutionError) return null;
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function extractPath(input: unknown): string | null {
|
|
447
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
|
448
|
+
const value = (input as { path?: unknown }).path;
|
|
449
|
+
return typeof value === "string" &&
|
|
450
|
+
value.length > 0 &&
|
|
451
|
+
value.length <= MAX_DIRECT_PATH_CODE_UNITS &&
|
|
452
|
+
!value.includes("\0")
|
|
453
|
+
? value
|
|
454
|
+
: null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function mostSpecificContainingRoot(candidate: string, roots: readonly string[]): string | undefined {
|
|
458
|
+
return roots
|
|
459
|
+
.filter((root) => pathIsInside(candidate, root))
|
|
460
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function validateProtectedName(name: string): void {
|
|
464
|
+
if (
|
|
465
|
+
name.length === 0 ||
|
|
466
|
+
name === "." ||
|
|
467
|
+
name === ".." ||
|
|
468
|
+
name.includes("\0") ||
|
|
469
|
+
path.isAbsolute(name) ||
|
|
470
|
+
name.includes("/") ||
|
|
471
|
+
name.includes("\\")
|
|
472
|
+
) {
|
|
473
|
+
throw new TypeError(`protected metadata name must be one path component: ${JSON.stringify(name)}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function validateMaxSymlinks(value: number): number {
|
|
478
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1_000) {
|
|
479
|
+
throw new TypeError("maxSymlinks must be an integer from 1 through 1000");
|
|
480
|
+
}
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function defaultTemporaryRoots(): string[] {
|
|
485
|
+
return process.platform === "win32" ? [tmpdir()] : deduplicate([tmpdir(), "/tmp"]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function deduplicate(values: readonly string[]): string[] {
|
|
489
|
+
return [...new Set(values)];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function isErrorCode(error: unknown, code: string): boolean {
|
|
493
|
+
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const NODE_PATH_FILE_SYSTEM: PathPolicyFileSystem = {
|
|
497
|
+
lstat,
|
|
498
|
+
readlink,
|
|
499
|
+
realpath,
|
|
500
|
+
readFile,
|
|
501
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./permission-engine.ts";
|