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,354 @@
1
+ import {
2
+ createLocalBashOperations,
3
+ getShellConfig,
4
+ type BashOperations,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { createStrongSandboxConfig } from "./config.ts";
7
+ import { createSrtRuntimePort, detectSandboxHost } from "./runtime.ts";
8
+ import {
9
+ ProcessSandboxAlreadyOwnedError,
10
+ SandboxUnavailableError,
11
+ type ProductionSandboxControllerOptions,
12
+ type SandboxController,
13
+ type SandboxControllerOptions,
14
+ type SandboxFailurePhase,
15
+ type SandboxStatus,
16
+ } from "./types.ts";
17
+
18
+ const DEFAULT_PROBE_TIMEOUT_SECONDS = 5;
19
+ const MAX_PROBE_OUTPUT_BYTES = 8 * 1024;
20
+
21
+ let processOwner: SandboxControllerImpl | "poisoned" | undefined;
22
+
23
+ export function createSandboxController(options: SandboxControllerOptions): SandboxController {
24
+ return new SandboxControllerImpl(options);
25
+ }
26
+
27
+ /**
28
+ * Claim the one SRT manager owner allowed in a process. The claim is released
29
+ * only after shutdown has drained command leases and completed reset.
30
+ */
31
+ export function createProcessSandboxController(options: SandboxControllerOptions): SandboxController {
32
+ // These hosts never touch SRT's singleton, so independent ReviewOnly
33
+ // controllers cannot interfere with one another and need no ownership claim.
34
+ if (!hostMayUseSrt(options.host)) return new SandboxControllerImpl(options);
35
+ if (processOwner === "poisoned") {
36
+ throw new ProcessSandboxAlreadyOwnedError(
37
+ "The process-global sandbox runtime is poisoned after reset failure; restart Pi",
38
+ );
39
+ }
40
+ if (processOwner !== undefined) throw new ProcessSandboxAlreadyOwnedError();
41
+
42
+ let controller: SandboxControllerImpl;
43
+ const release = (runtimeReusable: boolean) => {
44
+ if (processOwner === controller) {
45
+ processOwner = runtimeReusable ? undefined : "poisoned";
46
+ }
47
+ options.onClosed?.(runtimeReusable);
48
+ };
49
+ controller = new SandboxControllerImpl({ ...options, onClosed: release });
50
+ processOwner = controller;
51
+ return controller;
52
+ }
53
+
54
+ export function createProductionSandboxController(
55
+ options: ProductionSandboxControllerOptions,
56
+ ): SandboxController {
57
+ return createProcessSandboxController({
58
+ cwd: options.cwd,
59
+ ...(options.additionalDenyWrite === undefined
60
+ ? {}
61
+ : { additionalDenyWrite: options.additionalDenyWrite }),
62
+ host: detectSandboxHost(),
63
+ runtime: createSrtRuntimePort(),
64
+ localOperations: createLocalBashOperations(),
65
+ shell: getShellConfig().shell,
66
+ });
67
+ }
68
+
69
+ class SandboxControllerImpl implements SandboxController {
70
+ readonly operations: BashOperations;
71
+
72
+ private currentStatus: SandboxStatus = Object.freeze({ kind: "new" });
73
+ private readonly cwd: string;
74
+ private readonly options: SandboxControllerOptions;
75
+ private readonly probeTimeoutSeconds: number;
76
+ private startPromise: Promise<SandboxStatus> | undefined;
77
+ private shutdownPromise: Promise<void> | undefined;
78
+ private shutdownRequested = false;
79
+ private runtimeNeedsReset = false;
80
+ private activeExecutions = 0;
81
+ private drainPromise: Promise<void> | undefined;
82
+ private resolveDrain: (() => void) | undefined;
83
+
84
+ constructor(options: SandboxControllerOptions) {
85
+ this.options = options;
86
+ this.cwd = options.cwd;
87
+ this.probeTimeoutSeconds = options.probeTimeoutSeconds ?? DEFAULT_PROBE_TIMEOUT_SECONDS;
88
+ if (!Number.isFinite(this.probeTimeoutSeconds) || this.probeTimeoutSeconds <= 0) {
89
+ throw new TypeError("probeTimeoutSeconds must be a positive finite number");
90
+ }
91
+ this.operations = {
92
+ exec: (command, cwd, executionOptions) =>
93
+ this.executeSandboxed(command, cwd, executionOptions),
94
+ };
95
+ }
96
+
97
+ start(): Promise<SandboxStatus> {
98
+ if (this.startPromise !== undefined) return this.startPromise;
99
+ if (this.currentStatus.kind !== "new") return Promise.resolve(this.status());
100
+
101
+ this.setStatus({ kind: "initializing" });
102
+ this.startPromise = this.performStart().catch((error: unknown) =>
103
+ this.fail("initialization", `Unexpected sandbox startup failure: ${errorMessage(error)}`),
104
+ );
105
+ return this.startPromise;
106
+ }
107
+
108
+ status(): SandboxStatus {
109
+ return cloneStatus(this.currentStatus);
110
+ }
111
+
112
+ shutdown(): Promise<void> {
113
+ this.shutdownRequested = true;
114
+ this.shutdownPromise ??= this.performShutdown();
115
+ return this.shutdownPromise;
116
+ }
117
+
118
+ private async performStart(): Promise<SandboxStatus> {
119
+ const { host, runtime } = this.options;
120
+ if (host.platform !== "darwin" && host.platform !== "linux") {
121
+ this.setStatus({ kind: "review-only", reason: "unsupported-os", platform: host.platform });
122
+ return this.status();
123
+ }
124
+ if (host.platform === "linux" && host.wslVersion === "1") {
125
+ this.setStatus({ kind: "review-only", reason: "wsl1", platform: host.platform });
126
+ return this.status();
127
+ }
128
+ try {
129
+ if (!runtime.isSupportedPlatform()) {
130
+ return this.fail("platform", "Sandbox Runtime does not support this macOS/Linux host");
131
+ }
132
+ } catch (error) {
133
+ return this.fail("platform", errorMessage(error));
134
+ }
135
+
136
+ let strongConfig: ReturnType<typeof createStrongSandboxConfig>;
137
+ try {
138
+ strongConfig = createStrongSandboxConfig(this.cwd, {
139
+ ...(this.options.additionalDenyWrite === undefined
140
+ ? {}
141
+ : { additionalDenyWrite: this.options.additionalDenyWrite }),
142
+ });
143
+ } catch (error) {
144
+ return this.fail("configuration", errorMessage(error));
145
+ }
146
+
147
+ let dependencies;
148
+ try {
149
+ dependencies = runtime.checkDependencies();
150
+ } catch (error) {
151
+ return this.fail("dependencies", errorMessage(error));
152
+ }
153
+ const fatalWarnings = dependencies.warnings.filter(isFatalDependencyWarning);
154
+ if (dependencies.errors.length > 0 || fatalWarnings.length > 0) {
155
+ return this.fail(
156
+ "dependencies",
157
+ [...dependencies.errors, ...fatalWarnings].join(", ") || "Sandbox dependencies are unavailable",
158
+ );
159
+ }
160
+
161
+ // Set before awaiting initialize: a rejected initialization may have
162
+ // partially created SRT's process-global resources.
163
+ this.runtimeNeedsReset = true;
164
+ try {
165
+ await runtime.initialize(strongConfig.config);
166
+ } catch (error) {
167
+ return this.fail("initialization", errorMessage(error));
168
+ }
169
+
170
+ try {
171
+ const output = new TailBuffer(MAX_PROBE_OUTPUT_BYTES);
172
+ const result = await this.runWrapped("true", this.cwd, {
173
+ onData: (data) => output.append(data),
174
+ timeout: this.probeTimeoutSeconds,
175
+ });
176
+ if (result.exitCode !== 0) {
177
+ const detail = output.text().trim();
178
+ throw new Error(
179
+ detail.length > 0
180
+ ? `Sandbox probe exited with code ${String(result.exitCode)}: ${detail}`
181
+ : `Sandbox probe exited with code ${String(result.exitCode)}`,
182
+ );
183
+ }
184
+ } catch (error) {
185
+ const probeError = errorMessage(error);
186
+ try {
187
+ await runtime.reset();
188
+ this.runtimeNeedsReset = false;
189
+ } catch (resetError) {
190
+ return this.fail(
191
+ "probe",
192
+ `${probeError}; sandbox reset also failed: ${errorMessage(resetError)}`,
193
+ );
194
+ }
195
+ return this.fail("probe", probeError);
196
+ }
197
+
198
+ this.setStatus({ kind: "sandboxed", warnings: Object.freeze([...dependencies.warnings]) });
199
+ return this.status();
200
+ }
201
+
202
+ private async executeSandboxed(
203
+ command: string,
204
+ cwd: string,
205
+ executionOptions: Parameters<BashOperations["exec"]>[2],
206
+ ): Promise<{ exitCode: number | null }> {
207
+ const release = this.acquireExecution();
208
+ try {
209
+ return await this.runWrapped(command, cwd, executionOptions);
210
+ } finally {
211
+ release();
212
+ }
213
+ }
214
+
215
+ private async runWrapped(
216
+ command: string,
217
+ cwd: string,
218
+ executionOptions: Parameters<BashOperations["exec"]>[2],
219
+ ): Promise<{ exitCode: number | null }> {
220
+ let wrappedSuccessfully = false;
221
+ try {
222
+ let wrapped: string;
223
+ try {
224
+ wrapped = await this.options.runtime.wrap(
225
+ command,
226
+ this.options.shell,
227
+ executionOptions.signal,
228
+ );
229
+ } catch (error) {
230
+ // No child was handed to Pi, so this call is safe to fail directly.
231
+ // Mark the process-global sandbox unhealthy so every later lease is
232
+ // denied rather than repeatedly attempting a broken containment path.
233
+ this.fail("runtime", `Sandbox command wrapping failed: ${errorMessage(error)}`);
234
+ throw error;
235
+ }
236
+ wrappedSuccessfully = true;
237
+ return await this.options.localOperations.exec(wrapped, cwd, executionOptions);
238
+ } finally {
239
+ // On Linux, a successful wrap increments SRT's global mount-point lease.
240
+ // A rejected wrap decrements internally, so cleanup must not run then.
241
+ if (wrappedSuccessfully) {
242
+ try {
243
+ this.options.runtime.cleanupAfterCommand();
244
+ } catch (error) {
245
+ this.fail("runtime", `Sandbox command cleanup failed: ${errorMessage(error)}`);
246
+ throw error;
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ private acquireExecution(): () => void {
253
+ if (this.shutdownRequested || this.currentStatus.kind !== "sandboxed") {
254
+ throw new SandboxUnavailableError(this.status());
255
+ }
256
+ this.activeExecutions += 1;
257
+ let released = false;
258
+ return () => {
259
+ if (released) return;
260
+ released = true;
261
+ this.activeExecutions -= 1;
262
+ if (this.activeExecutions === 0) {
263
+ this.resolveDrain?.();
264
+ this.resolveDrain = undefined;
265
+ this.drainPromise = undefined;
266
+ }
267
+ };
268
+ }
269
+
270
+ private async performShutdown(): Promise<void> {
271
+ try {
272
+ if (this.startPromise !== undefined) await this.startPromise;
273
+ if (this.currentStatus.kind === "closed") return;
274
+ this.setStatus({ kind: "closing" });
275
+ await this.waitForDrain();
276
+ if (this.runtimeNeedsReset) {
277
+ try {
278
+ await this.options.runtime.reset();
279
+ this.runtimeNeedsReset = false;
280
+ } finally {
281
+ // Status closes either way; the onClosed hook below decides whether
282
+ // process-global ownership is reusable or permanently poisoned.
283
+ this.setStatus({ kind: "closed" });
284
+ }
285
+ } else {
286
+ this.setStatus({ kind: "closed" });
287
+ }
288
+ } finally {
289
+ if (this.currentStatus.kind !== "closed") this.setStatus({ kind: "closed" });
290
+ // If reset failed, SRT may still retain the old initialization promise
291
+ // and policy. Permanently poison ownership rather than ever labeling a
292
+ // later controller with a stale policy as sandboxed.
293
+ this.options.onClosed?.(!this.runtimeNeedsReset);
294
+ }
295
+ }
296
+
297
+ private waitForDrain(): Promise<void> {
298
+ if (this.activeExecutions === 0) return Promise.resolve();
299
+ if (this.drainPromise === undefined) {
300
+ this.drainPromise = new Promise<void>((resolveDrain) => {
301
+ this.resolveDrain = resolveDrain;
302
+ });
303
+ }
304
+ return this.drainPromise;
305
+ }
306
+
307
+ private fail(phase: SandboxFailurePhase, error: string): SandboxStatus {
308
+ this.setStatus({ kind: "failed", phase, error });
309
+ return this.status();
310
+ }
311
+
312
+ private setStatus(status: SandboxStatus): void {
313
+ this.currentStatus = Object.freeze(status);
314
+ }
315
+ }
316
+
317
+ function hostMayUseSrt(host: SandboxControllerOptions["host"]): boolean {
318
+ return (
319
+ (host.platform === "darwin" || host.platform === "linux") &&
320
+ !(host.platform === "linux" && host.wslVersion === "1")
321
+ );
322
+ }
323
+
324
+ function isFatalDependencyWarning(warning: string): boolean {
325
+ return /\bseccomp\b|unix[- ]socket/iu.test(warning);
326
+ }
327
+
328
+ function errorMessage(error: unknown): string {
329
+ return error instanceof Error ? error.message : String(error);
330
+ }
331
+
332
+ function cloneStatus(status: SandboxStatus): SandboxStatus {
333
+ if (status.kind === "sandboxed") {
334
+ return { kind: "sandboxed", warnings: [...status.warnings] };
335
+ }
336
+ return { ...status };
337
+ }
338
+
339
+ class TailBuffer {
340
+ private value = Buffer.alloc(0);
341
+
342
+ constructor(private readonly maxBytes: number) {}
343
+
344
+ append(chunk: Buffer): void {
345
+ this.value = Buffer.concat([this.value, chunk]);
346
+ if (this.value.byteLength > this.maxBytes) {
347
+ this.value = this.value.subarray(this.value.byteLength - this.maxBytes);
348
+ }
349
+ }
350
+
351
+ text(): string {
352
+ return this.value.toString("utf8");
353
+ }
354
+ }
@@ -0,0 +1,26 @@
1
+ export {
2
+ createProductionSandboxController,
3
+ createProcessSandboxController,
4
+ createSandboxController,
5
+ } from "./controller.ts";
6
+ export {
7
+ PROTECTED_WORKSPACE_NAMES,
8
+ createStrongSandboxConfig,
9
+ type StrongSandboxConfig,
10
+ type StrongSandboxConfigOptions,
11
+ } from "./config.ts";
12
+ export { createSrtRuntimePort, detectSandboxHost } from "./runtime.ts";
13
+ export {
14
+ ProcessSandboxAlreadyOwnedError,
15
+ SandboxUnavailableError,
16
+ backendForSandboxStatus,
17
+ type ProductionSandboxControllerOptions,
18
+ type SandboxController,
19
+ type SandboxControllerOptions,
20
+ type SandboxDependencyReport,
21
+ type SandboxFailurePhase,
22
+ type SandboxHost,
23
+ type SandboxReviewOnlyReason,
24
+ type SandboxRuntimePort,
25
+ type SandboxStatus,
26
+ } from "./types.ts";
@@ -0,0 +1,28 @@
1
+ import {
2
+ SandboxManager,
3
+ getWslVersion,
4
+ type SandboxRuntimeConfig,
5
+ } from "@anthropic-ai/sandbox-runtime";
6
+ import type { SandboxHost, SandboxRuntimePort } from "./types.ts";
7
+
8
+ /** Production-only adapter. It exposes no SRT ask callback. */
9
+ export function createSrtRuntimePort(): SandboxRuntimePort {
10
+ return {
11
+ isSupportedPlatform: () => SandboxManager.isSupportedPlatform(),
12
+ checkDependencies: () => SandboxManager.checkDependencies(),
13
+ initialize: async (config: SandboxRuntimeConfig) => {
14
+ await SandboxManager.initialize(config, undefined, false);
15
+ },
16
+ wrap: (command, shell, signal) =>
17
+ SandboxManager.wrapWithSandbox(command, shell, undefined, signal),
18
+ cleanupAfterCommand: () => SandboxManager.cleanupAfterCommand(),
19
+ reset: () => SandboxManager.reset(),
20
+ };
21
+ }
22
+
23
+ export function detectSandboxHost(): SandboxHost {
24
+ const wslVersion = getWslVersion();
25
+ return wslVersion === undefined
26
+ ? { platform: process.platform }
27
+ : { platform: process.platform, wslVersion };
28
+ }
@@ -0,0 +1,125 @@
1
+ import type { BashOperations } from "@earendil-works/pi-coding-agent";
2
+ import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
3
+ import type { EnforcementBackend } from "../domain.ts";
4
+
5
+ export type SandboxFailurePhase =
6
+ | "platform"
7
+ | "configuration"
8
+ | "dependencies"
9
+ | "initialization"
10
+ | "probe"
11
+ | "runtime";
12
+
13
+ export type SandboxReviewOnlyReason = "unsupported-os" | "wsl1";
14
+
15
+ export type SandboxStatus =
16
+ | { readonly kind: "new" }
17
+ | { readonly kind: "initializing" }
18
+ | {
19
+ readonly kind: "sandboxed";
20
+ readonly warnings: readonly string[];
21
+ }
22
+ | {
23
+ readonly kind: "review-only";
24
+ readonly reason: SandboxReviewOnlyReason;
25
+ readonly platform: NodeJS.Platform;
26
+ }
27
+ | {
28
+ readonly kind: "failed";
29
+ readonly phase: SandboxFailurePhase;
30
+ readonly error: string;
31
+ }
32
+ | { readonly kind: "closing" }
33
+ | { readonly kind: "closed" };
34
+
35
+ export interface SandboxDependencyReport {
36
+ readonly errors: readonly string[];
37
+ readonly warnings: readonly string[];
38
+ }
39
+
40
+ /**
41
+ * Narrow seam around Sandbox Runtime's process-global manager.
42
+ *
43
+ * Deliberately absent from this interface: SRT's ask callback, live config
44
+ * updates, and raw manager state. The extension has no human-approval path and
45
+ * owns one immutable sandbox configuration for the manager's whole lifetime.
46
+ */
47
+ export interface SandboxRuntimePort {
48
+ isSupportedPlatform(): boolean;
49
+ checkDependencies(): SandboxDependencyReport;
50
+ initialize(config: SandboxRuntimeConfig): Promise<void>;
51
+ wrap(command: string, shell: string, signal: AbortSignal | undefined): Promise<string>;
52
+ cleanupAfterCommand(): void;
53
+ reset(): Promise<void>;
54
+ }
55
+
56
+ export interface SandboxHost {
57
+ readonly platform: NodeJS.Platform;
58
+ readonly wslVersion?: string;
59
+ }
60
+
61
+ export interface SandboxController {
62
+ /** Sandboxed operations are callable only after start() returns Sandboxed. */
63
+ readonly operations: BashOperations;
64
+ start(): Promise<SandboxStatus>;
65
+ status(): SandboxStatus;
66
+ shutdown(): Promise<void>;
67
+ }
68
+
69
+ export interface SandboxControllerOptions {
70
+ readonly cwd: string;
71
+ /** Absolute extension-owned durable state/lock paths to deny beneath writable roots. */
72
+ readonly additionalDenyWrite?: readonly string[];
73
+ readonly host: SandboxHost;
74
+ readonly runtime: SandboxRuntimePort;
75
+ readonly localOperations: BashOperations;
76
+ readonly shell: string;
77
+ readonly probeTimeoutSeconds?: number;
78
+ /** Internal lifecycle hook: false permanently poisons process-global reuse. */
79
+ readonly onClosed?: (runtimeReusable: boolean) => void;
80
+ }
81
+
82
+ export interface ProductionSandboxControllerOptions {
83
+ readonly cwd: string;
84
+ readonly additionalDenyWrite?: readonly string[];
85
+ }
86
+
87
+ export function backendForSandboxStatus(status: Readonly<SandboxStatus>): EnforcementBackend | null {
88
+ if (status.kind === "sandboxed") return "sandboxed";
89
+ if (status.kind === "review-only") return "review-only";
90
+ return null;
91
+ }
92
+
93
+ export class SandboxUnavailableError extends Error {
94
+ readonly status: SandboxStatus;
95
+
96
+ constructor(status: SandboxStatus) {
97
+ super(unavailableMessage(status));
98
+ this.name = "SandboxUnavailableError";
99
+ this.status = status;
100
+ }
101
+ }
102
+
103
+ export class ProcessSandboxAlreadyOwnedError extends Error {
104
+ constructor(message = "The process-global sandbox runtime already has an active owner") {
105
+ super(message);
106
+ this.name = "ProcessSandboxAlreadyOwnedError";
107
+ }
108
+ }
109
+
110
+ function unavailableMessage(status: SandboxStatus): string {
111
+ switch (status.kind) {
112
+ case "failed":
113
+ return `Sandbox is unavailable (${status.phase}): ${status.error}`;
114
+ case "review-only":
115
+ return `Sandbox is unavailable on ${status.platform}; Auto must use model review`;
116
+ case "closing":
117
+ case "closed":
118
+ return "Sandbox is shutting down";
119
+ case "new":
120
+ case "initializing":
121
+ return "Sandbox is not ready";
122
+ case "sandboxed":
123
+ return "Sandbox is unavailable";
124
+ }
125
+ }