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.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +304 -0
  4. package/THIRD_PARTY_NOTICES.md +70 -0
  5. package/docs/implementation-plan.md +311 -0
  6. package/docs/invariants.md +555 -0
  7. package/package.json +59 -0
  8. package/src/canonical.ts +314 -0
  9. package/src/commands/index.ts +362 -0
  10. package/src/domain.ts +198 -0
  11. package/src/extension.ts +430 -0
  12. package/src/guardian/circuit-breaker.ts +102 -0
  13. package/src/guardian/index.ts +74 -0
  14. package/src/guardian/policy.ts +135 -0
  15. package/src/guardian/prompt.ts +379 -0
  16. package/src/guardian/reviewer.ts +599 -0
  17. package/src/guardian/types.ts +149 -0
  18. package/src/guardian/verdict.ts +211 -0
  19. package/src/pi/index.ts +13 -0
  20. package/src/pi/model-reviewer.ts +235 -0
  21. package/src/pi/transcript.ts +524 -0
  22. package/src/policy/dangerous-command.ts +312 -0
  23. package/src/policy/path-policy.ts +501 -0
  24. package/src/runtime/index.ts +1 -0
  25. package/src/runtime/permission-engine.ts +474 -0
  26. package/src/sandbox/config.ts +188 -0
  27. package/src/sandbox/controller.ts +354 -0
  28. package/src/sandbox/index.ts +26 -0
  29. package/src/sandbox/runtime.ts +28 -0
  30. package/src/sandbox/types.ts +125 -0
  31. package/src/state/config-store.ts +580 -0
  32. package/src/state/index.ts +2 -0
  33. package/src/tools/bash.ts +101 -0
  34. package/src/tools/gate.ts +74 -0
  35. package/src/tools/index.ts +2 -0
  36. package/vendor/openai-codex/NOTICE +6 -0
  37. package/vendor/openai-codex/README.md +17 -0
  38. package/vendor/openai-codex/guardian/policy.md +42 -0
  39. package/vendor/openai-codex/guardian/policy_template.md +58 -0
@@ -0,0 +1,474 @@
1
+ import { canonicalizeAction, CanonicalizationError } from "../canonical.ts";
2
+ import {
3
+ checkpointSession,
4
+ createSessionState,
5
+ effectiveMode,
6
+ setRequestedMode as updateRequestedMode,
7
+ setSessionAlive,
8
+ setSessionBackend,
9
+ type EnforcementBackend,
10
+ type GlobalConfig,
11
+ type GlobalState,
12
+ type PermissionMode,
13
+ type ReviewerSelection,
14
+ type SessionCheckpoint,
15
+ type SessionInitialization,
16
+ type SessionState,
17
+ } from "../domain.ts";
18
+ import {
19
+ GUARDIAN_DENIAL_MESSAGE,
20
+ GuardianReviewEngine,
21
+ guardianReviewBindingsEqual,
22
+ type GuardianReviewBinding,
23
+ type GuardianTranscriptItem,
24
+ } from "../guardian/index.ts";
25
+ import type { DangerousCommandDetector } from "../policy/dangerous-command.ts";
26
+ import type { StaticPathPolicy } from "../policy/path-policy.ts";
27
+ import type { GlobalConfigMutation, GlobalConfigStore } from "../state/config-store.ts";
28
+
29
+ export const SANDBOX_UNAVAILABLE_DENIAL_MESSAGE = GUARDIAN_DENIAL_MESSAGE;
30
+
31
+ export type PermissionExecutionRoute = "passthrough" | "sandboxed" | "local";
32
+
33
+ export interface PermissionAction {
34
+ readonly toolCallId: string;
35
+ readonly turnId: string;
36
+ readonly toolName: string;
37
+ /** Kept live through review so an in-place mutation makes the binding stale. */
38
+ readonly input: unknown;
39
+ readonly cwd: string;
40
+ readonly toolMetadata: unknown;
41
+ /** Static file admission is valid only for Pi's actual built-in definition. */
42
+ readonly builtInFileTool?: boolean;
43
+ /** Built only if this action actually reaches Guardian review. */
44
+ readonly transcript:
45
+ | readonly GuardianTranscriptItem[]
46
+ | (() => readonly GuardianTranscriptItem[]);
47
+ readonly signal?: AbortSignal;
48
+ }
49
+
50
+ export interface PermissionAdmitDecision {
51
+ readonly outcome: "admit";
52
+ readonly route: PermissionExecutionRoute;
53
+ readonly reviewed: boolean;
54
+ }
55
+
56
+ export interface PermissionDenyDecision {
57
+ readonly outcome: "deny";
58
+ readonly message: string;
59
+ readonly reason:
60
+ | "configuration_fault"
61
+ | "invalid_action"
62
+ | "sandbox_unavailable"
63
+ | "review_denied"
64
+ | "stale_binding";
65
+ readonly interruptTurn: boolean;
66
+ }
67
+
68
+ export type PermissionDecision = PermissionAdmitDecision | PermissionDenyDecision;
69
+
70
+ export interface PermissionEngineOptions {
71
+ readonly configStore: Pick<
72
+ GlobalConfigStore,
73
+ "read" | "setEnabled" | "setReviewer" | "repair"
74
+ >;
75
+ readonly pathPolicy: Pick<StaticPathPolicy, "classify">;
76
+ readonly guardian: GuardianReviewEngine;
77
+ readonly sessionId: string;
78
+ readonly sessionInitialization?: SessionInitialization;
79
+ readonly dangerousCommandDetector?: DangerousCommandDetector | null;
80
+ }
81
+
82
+ export interface PermissionStatus {
83
+ readonly label:
84
+ | "Off"
85
+ | "Unrestricted"
86
+ | "Auto"
87
+ | "Auto (review-only)"
88
+ | "Auto (sandbox unavailable)"
89
+ | "Auto (configuration fault)";
90
+ readonly global: GlobalState;
91
+ readonly session: SessionState;
92
+ }
93
+
94
+ export class AutoUnavailableError extends Error {
95
+ constructor(message = "Auto is unavailable until /perm-auto-model selects a model and thinking level") {
96
+ super(message);
97
+ this.name = "AutoUnavailableError";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Pure orchestration boundary for the permission state machine. It classifies
103
+ * and reviews, but never invokes a tool or starts a process. Consequently an
104
+ * executor can be reached only through an explicit `admit` result.
105
+ */
106
+ export class PermissionEngine {
107
+ private readonly configStore: PermissionEngineOptions["configStore"];
108
+ private readonly pathPolicy: Pick<StaticPathPolicy, "classify">;
109
+ private readonly guardian: GuardianReviewEngine;
110
+ private readonly sessionId: string;
111
+ private readonly dangerousCommandDetector: DangerousCommandDetector | null;
112
+ private session: SessionState;
113
+
114
+ constructor(options: PermissionEngineOptions) {
115
+ if (options.sessionId.length === 0) throw new TypeError("sessionId must not be empty");
116
+ this.configStore = options.configStore;
117
+ this.pathPolicy = options.pathPolicy;
118
+ this.guardian = options.guardian;
119
+ this.sessionId = options.sessionId;
120
+ this.dangerousCommandDetector = options.dangerousCommandDetector ?? null;
121
+ this.session = createSessionState(options.sessionInitialization ?? { kind: "fresh" });
122
+ }
123
+
124
+ get sessionState(): SessionState {
125
+ return { ...this.session };
126
+ }
127
+
128
+ get sessionIdentifier(): string {
129
+ return this.sessionId;
130
+ }
131
+
132
+ get checkpoint(): SessionCheckpoint {
133
+ return checkpointSession(this.session);
134
+ }
135
+
136
+ async readGlobal(): Promise<GlobalState> {
137
+ return this.configStore.read();
138
+ }
139
+
140
+ setBackend(backend: EnforcementBackend): void {
141
+ this.session = setSessionBackend(this.session, backend);
142
+ }
143
+
144
+ async setRequestedMode(mode: PermissionMode): Promise<void> {
145
+ if (mode === "auto") {
146
+ const global = await this.configStore.read();
147
+ if (global.health === "fault" || global.config.reviewer === null) {
148
+ throw new AutoUnavailableError(
149
+ global.health === "fault"
150
+ ? "Auto is unavailable while global permission state is faulted"
151
+ : undefined,
152
+ );
153
+ }
154
+ }
155
+ this.session = updateRequestedMode(this.session, mode);
156
+ }
157
+
158
+ async setReviewerAndAuto(selection: ReviewerSelection): Promise<GlobalConfig> {
159
+ const observed = await this.configStore.read();
160
+ const committed =
161
+ observed.health === "fault"
162
+ ? await this.configStore.repair({
163
+ enabled: observed.recoverableConfig?.enabled ?? true,
164
+ reviewer: selection,
165
+ })
166
+ : await this.configStore.setReviewer(selection);
167
+ // This transition occurs only after the complete tuple is durable.
168
+ this.session = updateRequestedMode(this.session, "auto");
169
+ return committed;
170
+ }
171
+
172
+ async setEnabled(enabled: boolean): Promise<GlobalConfig> {
173
+ const observed = await this.configStore.read();
174
+ if (observed.health === "fault") {
175
+ const repair: GlobalConfigMutation = {
176
+ enabled,
177
+ reviewer: observed.recoverableConfig?.reviewer ?? null,
178
+ };
179
+ return this.configStore.repair(repair);
180
+ }
181
+ return this.configStore.setEnabled(enabled);
182
+ }
183
+
184
+ async status(): Promise<PermissionStatus> {
185
+ const global = await this.configStore.read();
186
+ const mode = effectiveMode(global, this.session);
187
+ let label: PermissionStatus["label"];
188
+ if (mode === "disabled") label = "Off";
189
+ else if (mode === "unrestricted" || mode === "unrestricted-unavailable") {
190
+ label = "Unrestricted";
191
+ } else if (mode === "fault") {
192
+ label = "Auto (configuration fault)";
193
+ } else if (this.session.backend === "review-only") {
194
+ label = "Auto (review-only)";
195
+ } else if (this.session.backend === "sandboxed") {
196
+ label = "Auto";
197
+ } else {
198
+ label = "Auto (sandbox unavailable)";
199
+ }
200
+ return { label, global, session: this.sessionState };
201
+ }
202
+
203
+ clearTurn(turnId: string): void {
204
+ this.guardian.clearTurn(turnId);
205
+ }
206
+
207
+ shutdown(): void {
208
+ this.session = setSessionAlive(this.session, false);
209
+ this.dangerousCommandDetector?.close();
210
+ }
211
+
212
+ async gate(action: PermissionAction): Promise<PermissionDecision> {
213
+ if (!this.session.alive) return this.denyAction(action, "stale_binding");
214
+
215
+ let global: GlobalState;
216
+ try {
217
+ global = await this.configStore.read();
218
+ } catch {
219
+ return this.denyAction(action, "configuration_fault");
220
+ }
221
+ const mode = effectiveMode(global, this.session);
222
+
223
+ if (mode === "disabled" || mode === "unrestricted" || mode === "unrestricted-unavailable") {
224
+ return this.admitAction(
225
+ action,
226
+ action.toolName === "bash" ? "local" : "passthrough",
227
+ false,
228
+ );
229
+ }
230
+ if (this.guardian.isTurnInterrupted(action.turnId)) {
231
+ return { ...deny("review_denied"), interruptTurn: true };
232
+ }
233
+ if (mode === "fault") return this.denyAction(action, "configuration_fault");
234
+
235
+ if (action.toolName === "bash") return this.gateBash(action, global);
236
+
237
+ if (action.builtInFileTool === true) {
238
+ try {
239
+ const pathDecision = await this.pathPolicy.classify({
240
+ toolName: action.toolName,
241
+ input: action.input,
242
+ });
243
+ if (pathDecision.disposition === "admit") {
244
+ return this.admitAction(action, "passthrough");
245
+ }
246
+ if (pathDecision.disposition === "deny") {
247
+ return this.denyAction(action, "invalid_action");
248
+ }
249
+ } catch {
250
+ // Classification uncertainty receives Guardian review. The extension
251
+ // startup fallback separately hard-denies all direct mutations when a
252
+ // usable path policy could not be constructed.
253
+ }
254
+ }
255
+
256
+ return this.review(action, global, "passthrough");
257
+ }
258
+
259
+ private async gateBash(action: PermissionAction, global: GlobalState): Promise<PermissionDecision> {
260
+ const input = bashInput(action.input);
261
+ if (input === null) return this.denyAction(action, "invalid_action");
262
+
263
+ const backend = this.session.backend;
264
+ if (backend === null || backend === "unavailable") {
265
+ return this.denyAction(action, "sandbox_unavailable");
266
+ }
267
+ if (backend === "review-only") return this.review(action, global, "local");
268
+
269
+ if (input.sandboxPermissions === "require_escalated") {
270
+ return this.review(action, global, "local");
271
+ }
272
+
273
+ let requiresReview: boolean;
274
+ try {
275
+ // Parser initialization/detection uncertainty deliberately routes to
276
+ // review. It never silently skips the pinned Codex dangerous rule.
277
+ requiresReview =
278
+ this.dangerousCommandDetector === null ||
279
+ this.dangerousCommandDetector.detect(input.command) !== undefined;
280
+ } catch {
281
+ requiresReview = true;
282
+ }
283
+
284
+ // Codex separates approval from containment: danger requires approval,
285
+ // but a default-permission command still runs in the workspace sandbox.
286
+ // Only an explicit escalation is eligible for local execution.
287
+ return requiresReview
288
+ ? this.review(action, global, "sandboxed")
289
+ : this.admitAction(action, "sandboxed");
290
+ }
291
+
292
+ private async review(
293
+ action: PermissionAction,
294
+ capturedGlobal: GlobalState,
295
+ route: PermissionExecutionRoute,
296
+ ): Promise<PermissionDecision> {
297
+ if (
298
+ capturedGlobal.health === "fault" ||
299
+ capturedGlobal.config.reviewer === null ||
300
+ this.session.backend === null
301
+ ) {
302
+ return this.denyAction(
303
+ action,
304
+ capturedGlobal.health === "fault" ? "configuration_fault" : "invalid_action",
305
+ );
306
+ }
307
+
308
+ let canonicalAction: string;
309
+ try {
310
+ canonicalAction = canonicalizeLiveAction(action);
311
+ } catch {
312
+ return this.denyAction(action, "invalid_action");
313
+ }
314
+
315
+ const capturedBinding = bindingFrom(
316
+ canonicalAction,
317
+ capturedGlobal.config,
318
+ this.session,
319
+ this.sessionId,
320
+ );
321
+ if (capturedBinding === null) return this.denyAction(action, "invalid_action");
322
+
323
+ const getCurrentBinding = async (): Promise<GuardianReviewBinding | null> => {
324
+ if (!this.session.alive || this.session.backend === null) return null;
325
+ const currentGlobal = await this.configStore.read();
326
+ if (effectiveMode(currentGlobal, this.session) !== "auto" || currentGlobal.health === "fault") {
327
+ return null;
328
+ }
329
+ let currentCanonicalAction: string;
330
+ try {
331
+ currentCanonicalAction = canonicalizeLiveAction(action);
332
+ } catch {
333
+ return null;
334
+ }
335
+ return bindingFrom(currentCanonicalAction, currentGlobal.config, this.session, this.sessionId);
336
+ };
337
+
338
+ let transcript: readonly GuardianTranscriptItem[];
339
+ try {
340
+ transcript =
341
+ typeof action.transcript === "function" ? action.transcript() : action.transcript;
342
+ } catch {
343
+ return this.denyAction(action, "invalid_action");
344
+ }
345
+
346
+ let result;
347
+ try {
348
+ result = await this.guardian.review({
349
+ turnId: action.turnId,
350
+ binding: capturedBinding,
351
+ transcript,
352
+ ...(action.signal === undefined ? {} : { signal: action.signal }),
353
+ getCurrentBinding,
354
+ });
355
+ } catch {
356
+ return this.denyAction(action, "review_denied");
357
+ }
358
+ if (result.outcome === "deny") {
359
+ return {
360
+ outcome: "deny",
361
+ message: result.message,
362
+ reason: "review_denied",
363
+ interruptTurn: result.interruptTurn,
364
+ };
365
+ }
366
+
367
+ // The reviewer also checks before and after its call. This final check is
368
+ // intentionally owned by the outer gate and is the last await before the
369
+ // caller synchronously enters the selected executor.
370
+ let finalBinding: GuardianReviewBinding | null;
371
+ try {
372
+ finalBinding = await getCurrentBinding();
373
+ } catch {
374
+ return this.denyAction(action, "stale_binding");
375
+ }
376
+ if (finalBinding === null || !guardianReviewBindingsEqual(result.binding, finalBinding)) {
377
+ return this.denyAction(action, "stale_binding");
378
+ }
379
+ return this.admitAction(action, route, true, true);
380
+ }
381
+
382
+ private admitAction(
383
+ action: Pick<PermissionAction, "turnId">,
384
+ route: PermissionExecutionRoute,
385
+ accountAutoDecision = true,
386
+ reviewed = false,
387
+ ): PermissionDecision {
388
+ // Every admission branch linearizes against lifecycle here, after its last
389
+ // asynchronous classification/state read and before returning to Pi.
390
+ if (!this.session.alive) return this.denyAction(action, "stale_binding");
391
+ if (accountAutoDecision) {
392
+ // This check and the breaker reset are synchronous, so no same-turn
393
+ // denial can interleave after the check and before this admission's
394
+ // linearization point.
395
+ if (this.guardian.isTurnInterrupted(action.turnId)) {
396
+ return { ...deny("review_denied"), interruptTurn: true };
397
+ }
398
+ this.guardian.recordPermissionNonDenial(action.turnId);
399
+ }
400
+ return admit(route, reviewed);
401
+ }
402
+
403
+ private denyAction(
404
+ action: Pick<PermissionAction, "turnId">,
405
+ reason: PermissionDenyDecision["reason"],
406
+ ): PermissionDenyDecision {
407
+ const breaker = this.guardian.recordPermissionDenial(action.turnId);
408
+ return { ...deny(reason), interruptTurn: breaker.interruptTurn };
409
+ }
410
+ }
411
+
412
+ function canonicalizeLiveAction(action: PermissionAction): string {
413
+ try {
414
+ return canonicalizeAction({
415
+ toolName: action.toolName,
416
+ arguments: action.input,
417
+ cwd: action.cwd,
418
+ toolMetadata: action.toolMetadata,
419
+ }).json;
420
+ } catch (error) {
421
+ if (error instanceof CanonicalizationError) throw error;
422
+ throw new CanonicalizationError("unsupported", "action could not be canonicalized");
423
+ }
424
+ }
425
+
426
+ function bindingFrom(
427
+ canonicalAction: string,
428
+ global: GlobalConfig,
429
+ session: SessionState,
430
+ sessionId: string,
431
+ ): GuardianReviewBinding | null {
432
+ if (!session.alive || session.backend === null || global.reviewer === null) return null;
433
+ return {
434
+ canonicalAction,
435
+ globalRevision: global.revision,
436
+ sessionRevision: session.revision,
437
+ backend: session.backend,
438
+ sessionId,
439
+ reviewer: { ...global.reviewer },
440
+ };
441
+ }
442
+
443
+ function bashInput(
444
+ value: unknown,
445
+ ): { command: string; sandboxPermissions: "use_default" | "require_escalated" } | null {
446
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
447
+ const record = value as Record<string, unknown>;
448
+ if (typeof record.command !== "string") return null;
449
+ const permission = record.sandbox_permissions;
450
+ if (
451
+ permission !== undefined &&
452
+ permission !== "use_default" &&
453
+ permission !== "require_escalated"
454
+ ) {
455
+ return null;
456
+ }
457
+ return {
458
+ command: record.command,
459
+ sandboxPermissions: permission ?? "use_default",
460
+ };
461
+ }
462
+
463
+ function admit(route: PermissionExecutionRoute, reviewed: boolean): PermissionAdmitDecision {
464
+ return { outcome: "admit", route, reviewed };
465
+ }
466
+
467
+ function deny(reason: PermissionDenyDecision["reason"]): PermissionDenyDecision {
468
+ return {
469
+ outcome: "deny",
470
+ message: GUARDIAN_DENIAL_MESSAGE,
471
+ reason,
472
+ interruptTurn: false,
473
+ };
474
+ }
@@ -0,0 +1,188 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { isAbsolute, parse, resolve } from "node:path";
4
+ import {
5
+ SandboxRuntimeConfigSchema,
6
+ type SandboxRuntimeConfig,
7
+ } from "@anthropic-ai/sandbox-runtime";
8
+
9
+ export const PROTECTED_WORKSPACE_NAMES = [".git", ".agents", ".codex", ".pi"] as const;
10
+
11
+ const MAX_GIT_POINTER_BYTES = 8 * 1024;
12
+
13
+ export interface StrongSandboxConfig {
14
+ readonly config: SandboxRuntimeConfig;
15
+ readonly workspace: string;
16
+ readonly writableRoots: readonly string[];
17
+ readonly protectedPaths: readonly string[];
18
+ }
19
+
20
+ export interface StrongSandboxConfigOptions {
21
+ readonly homeDirectory?: string;
22
+ readonly temporaryDirectory?: string;
23
+ readonly systemTemporaryDirectory?: string;
24
+ /** Extension-owned durable state/lock paths that must never be writable. */
25
+ readonly additionalDenyWrite?: readonly string[];
26
+ }
27
+
28
+ /** Build and schema-validate the one policy used by every strong backend. */
29
+ export function createStrongSandboxConfig(
30
+ cwd: string,
31
+ options: StrongSandboxConfigOptions = {},
32
+ ): StrongSandboxConfig {
33
+ const workspace = canonicalDirectory(cwd, "workspace");
34
+ if (workspace === parse(workspace).root) {
35
+ throw new Error("Refusing to make the filesystem root a writable workspace");
36
+ }
37
+
38
+ const temporaryDirectory = canonicalDirectory(
39
+ options.temporaryDirectory ?? tmpdir(),
40
+ "temporary directory",
41
+ );
42
+ const systemTemporaryDirectory = options.systemTemporaryDirectory ?? "/tmp";
43
+ const writableRoots = unique([
44
+ workspace,
45
+ temporaryDirectory,
46
+ ...(existsSync(systemTemporaryDirectory)
47
+ ? [canonicalDirectory(systemTemporaryDirectory, "system temporary directory")]
48
+ : []),
49
+ ]);
50
+ for (const writableRoot of writableRoots) {
51
+ if (writableRoot === parse(writableRoot).root) {
52
+ throw new Error(`Refusing to make the filesystem root writable: ${writableRoot}`);
53
+ }
54
+ }
55
+
56
+ const homeDirectory = canonicalDirectory(options.homeDirectory ?? homedir(), "home directory");
57
+ const protectedPaths = collectProtectedPaths(
58
+ workspace,
59
+ homeDirectory,
60
+ options.additionalDenyWrite ?? [],
61
+ );
62
+
63
+ // SRT's library API accepts the TypeScript shape without parsing it. Parse
64
+ // here so a typo cannot silently weaken a policy field.
65
+ const config = SandboxRuntimeConfigSchema.parse({
66
+ network: {
67
+ allowedDomains: [],
68
+ deniedDomains: ["*"],
69
+ strictAllowlist: true,
70
+ allowUnixSockets: [],
71
+ allowAllUnixSockets: false,
72
+ allowLocalBinding: false,
73
+ allowMachLookup: [],
74
+ },
75
+ filesystem: {
76
+ denyRead: [],
77
+ allowRead: [],
78
+ allowWrite: writableRoots,
79
+ denyWrite: protectedPaths,
80
+ allowGitConfig: false,
81
+ },
82
+ enableWeakerNestedSandbox: false,
83
+ enableWeakerNetworkIsolation: false,
84
+ allowAppleEvents: false,
85
+ allowPty: false,
86
+ });
87
+
88
+ return {
89
+ config,
90
+ workspace,
91
+ writableRoots: Object.freeze([...writableRoots]),
92
+ protectedPaths: Object.freeze([...protectedPaths]),
93
+ };
94
+ }
95
+
96
+ function collectProtectedPaths(
97
+ workspace: string,
98
+ homeDirectory: string,
99
+ additionalDenyWrite: readonly string[],
100
+ ): string[] {
101
+ const paths: string[] = [];
102
+ for (const name of PROTECTED_WORKSPACE_NAMES) {
103
+ addPathAndExistingTarget(paths, resolve(workspace, name));
104
+ }
105
+
106
+ collectResolvedGitDirectories(paths, resolve(workspace, ".git"));
107
+
108
+ // SRT always prepends these two home-directory locations to its write
109
+ // allowlist. Explicit deny carve-outs are required for workspace+temp.
110
+ addPathAndExistingTarget(paths, resolve(homeDirectory, ".npm/_logs"));
111
+ addPathAndExistingTarget(paths, resolve(homeDirectory, ".claude/debug"));
112
+
113
+ for (const path of additionalDenyWrite) {
114
+ if (!isAbsolute(path)) {
115
+ throw new Error(`Additional deny-write paths must be absolute: ${path}`);
116
+ }
117
+ addPathAndExistingTarget(paths, path);
118
+ }
119
+ return unique(paths);
120
+ }
121
+
122
+ function collectResolvedGitDirectories(paths: string[], dotGitPath: string): void {
123
+ if (!existsSync(dotGitPath)) return;
124
+
125
+ let gitDirectory: string | undefined;
126
+ try {
127
+ const stat = lstatSync(dotGitPath);
128
+ if (stat.isDirectory() || stat.isSymbolicLink()) {
129
+ gitDirectory = canonicalIfExisting(dotGitPath);
130
+ } else if (stat.isFile()) {
131
+ gitDirectory = readPointerPath(dotGitPath, "gitdir:");
132
+ }
133
+ } catch {
134
+ return;
135
+ }
136
+
137
+ if (gitDirectory === undefined) return;
138
+ addPathAndExistingTarget(paths, gitDirectory);
139
+
140
+ const commonDirectory = readPointerPath(resolve(gitDirectory, "commondir"));
141
+ if (commonDirectory !== undefined) addPathAndExistingTarget(paths, commonDirectory);
142
+ }
143
+
144
+ function readPointerPath(file: string, prefix?: string): string | undefined {
145
+ try {
146
+ const stat = statSync(file);
147
+ if (!stat.isFile() || stat.size > MAX_GIT_POINTER_BYTES) return undefined;
148
+ const firstLine = readFileSync(file, "utf8").split(/\r?\n/u, 1)[0];
149
+ if (firstLine === undefined || firstLine.includes("\0")) return undefined;
150
+
151
+ let raw = firstLine.trim();
152
+ if (prefix !== undefined) {
153
+ if (!raw.toLowerCase().startsWith(prefix)) return undefined;
154
+ raw = raw.slice(prefix.length).trim();
155
+ }
156
+ if (raw.length === 0) return undefined;
157
+
158
+ const target = isAbsolute(raw) ? raw : resolve(file, "..", raw);
159
+ return canonicalIfExisting(target);
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ function addPathAndExistingTarget(paths: string[], path: string): void {
166
+ const absolute = resolve(path);
167
+ paths.push(absolute);
168
+ const canonical = canonicalIfExisting(absolute);
169
+ if (canonical !== absolute) paths.push(canonical);
170
+ }
171
+
172
+ function canonicalIfExisting(path: string): string {
173
+ try {
174
+ return realpathSync(path);
175
+ } catch {
176
+ return resolve(path);
177
+ }
178
+ }
179
+
180
+ function canonicalDirectory(path: string, label: string): string {
181
+ const canonical = realpathSync(path);
182
+ if (!statSync(canonical).isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
183
+ return canonical;
184
+ }
185
+
186
+ function unique(values: readonly string[]): string[] {
187
+ return [...new Set(values)];
188
+ }