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,580 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import lockfile from "proper-lockfile";
6
+ import {
7
+ CONFIG_VERSION,
8
+ DEFAULT_GLOBAL_CONFIG,
9
+ assertRevision,
10
+ cloneGlobalConfig,
11
+ cloneReviewerSelection,
12
+ isModelThinkingLevel,
13
+ nextRevision,
14
+ reviewerSelectionsEqual,
15
+ } from "../domain.ts";
16
+ import type { GlobalConfig, GlobalState, ReviewerSelection } from "../domain.ts";
17
+
18
+ export interface DurableHandle {
19
+ writeFile(data: string, options: { encoding: "utf8" }): Promise<void>;
20
+ sync(): Promise<void>;
21
+ close(): Promise<void>;
22
+ }
23
+
24
+ export interface ConfigFileSystem {
25
+ mkdir(path: string, options: { recursive: true; mode: number }): Promise<unknown>;
26
+ readFile(path: string, encoding: "utf8"): Promise<string>;
27
+ open(path: string, flags: string | number, mode?: number): Promise<DurableHandle>;
28
+ rename(from: string, to: string): Promise<void>;
29
+ unlink(path: string): Promise<void>;
30
+ }
31
+
32
+ export interface ExclusiveLock {
33
+ run<T>(path: string, operation: () => Promise<T>): Promise<T>;
34
+ }
35
+
36
+ export interface GlobalConfigStoreOptions {
37
+ configPath: string;
38
+ fileSystem?: ConfigFileSystem;
39
+ lock?: ExclusiveLock;
40
+ createId?: () => string;
41
+ }
42
+
43
+ export interface GlobalConfigMutation {
44
+ enabled: boolean;
45
+ reviewer: ReviewerSelection | null;
46
+ }
47
+
48
+ export class ConfigFaultError extends Error {
49
+ readonly state: GlobalState;
50
+
51
+ constructor(state: GlobalState) {
52
+ super(state.health === "fault" ? state.error : "global configuration is not faulted");
53
+ this.name = "ConfigFaultError";
54
+ this.state = state;
55
+ }
56
+ }
57
+
58
+ export class GlobalConfigStore {
59
+ readonly configPath: string;
60
+ readonly revisionPath: string;
61
+ readonly revisionRecoveryPath: string;
62
+ private readonly fileSystem: ConfigFileSystem;
63
+ private readonly lock: ExclusiveLock;
64
+ private readonly createId: () => string;
65
+
66
+ constructor(options: GlobalConfigStoreOptions) {
67
+ if (options.configPath.length === 0) throw new TypeError("configPath must not be empty");
68
+ this.configPath = options.configPath;
69
+ this.revisionPath = `${options.configPath}.revision`;
70
+ this.revisionRecoveryPath = `${options.configPath}.revision.recovery`;
71
+ this.fileSystem = options.fileSystem ?? NODE_CONFIG_FILE_SYSTEM;
72
+ this.lock = options.lock ?? PROPER_FILE_LOCK;
73
+ this.createId = options.createId ?? randomUUID;
74
+ }
75
+
76
+ async read(): Promise<GlobalState> {
77
+ const directory = dirname(this.configPath);
78
+ await this.fileSystem.mkdir(directory, { recursive: true, mode: 0o700 });
79
+ return this.lock.run(directory, async () =>
80
+ (await readDurableSnapshot(
81
+ this.configPath,
82
+ this.revisionPath,
83
+ this.revisionRecoveryPath,
84
+ this.fileSystem,
85
+ )).state,
86
+ );
87
+ }
88
+
89
+ async setEnabled(enabled: boolean): Promise<GlobalConfig> {
90
+ return this.update((current) => ({ enabled, reviewer: current.reviewer }));
91
+ }
92
+
93
+ async setReviewer(reviewer: ReviewerSelection | null): Promise<GlobalConfig> {
94
+ validateReviewer(reviewer);
95
+ const capturedReviewer = cloneReviewerSelection(reviewer);
96
+ return this.update((current) => ({ enabled: current.enabled, reviewer: capturedReviewer }));
97
+ }
98
+
99
+ async update(
100
+ mutation: (current: Readonly<GlobalConfig>) => Readonly<GlobalConfigMutation>,
101
+ ): Promise<GlobalConfig> {
102
+ const directory = dirname(this.configPath);
103
+ await this.fileSystem.mkdir(directory, { recursive: true, mode: 0o700 });
104
+
105
+ return this.lock.run(directory, async () => {
106
+ const snapshot = await readDurableSnapshot(
107
+ this.configPath,
108
+ this.revisionPath,
109
+ this.revisionRecoveryPath,
110
+ this.fileSystem,
111
+ );
112
+ const observed = snapshot.state;
113
+ if (observed.health === "fault") throw new ConfigFaultError(observed);
114
+
115
+ const current = cloneGlobalConfig(observed.config);
116
+ const desired = mutation(cloneGlobalConfig(current));
117
+ validateMutation(desired);
118
+
119
+ if (
120
+ current.enabled === desired.enabled &&
121
+ reviewerSelectionsEqual(current.reviewer, desired.reviewer)
122
+ ) {
123
+ return current;
124
+ }
125
+
126
+ const next: GlobalConfig = {
127
+ version: CONFIG_VERSION,
128
+ enabled: desired.enabled,
129
+ reviewer: cloneReviewerSelection(desired.reviewer),
130
+ revision: nextRevision(snapshot.revisionFloor),
131
+ };
132
+ await this.writeCommitted(next);
133
+ return cloneGlobalConfig(next);
134
+ });
135
+ }
136
+
137
+ async repair(mutation: Readonly<GlobalConfigMutation>): Promise<GlobalConfig> {
138
+ validateMutation(mutation);
139
+ const capturedMutation: GlobalConfigMutation = {
140
+ enabled: mutation.enabled,
141
+ reviewer: cloneReviewerSelection(mutation.reviewer),
142
+ };
143
+ const directory = dirname(this.configPath);
144
+ await this.fileSystem.mkdir(directory, { recursive: true, mode: 0o700 });
145
+
146
+ return this.lock.run(directory, async () => {
147
+ const snapshot = await readDurableSnapshot(
148
+ this.configPath,
149
+ this.revisionPath,
150
+ this.revisionRecoveryPath,
151
+ this.fileSystem,
152
+ );
153
+ const observed = snapshot.state;
154
+ if (observed.health !== "fault") {
155
+ return this.updateWithoutLock(
156
+ observed.config,
157
+ capturedMutation,
158
+ snapshot.revisionFloor,
159
+ );
160
+ }
161
+
162
+ const repairFloor = revisionRepairFloor(snapshot.artifacts);
163
+ const repaired: GlobalConfig = {
164
+ version: CONFIG_VERSION,
165
+ enabled: capturedMutation.enabled,
166
+ reviewer: cloneReviewerSelection(capturedMutation.reviewer),
167
+ revision: nextRevision(repairFloor),
168
+ };
169
+ await this.writeCommitted(repaired);
170
+ return cloneGlobalConfig(repaired);
171
+ });
172
+ }
173
+
174
+ private async updateWithoutLock(
175
+ currentValue: Readonly<GlobalConfig>,
176
+ mutation: Readonly<GlobalConfigMutation>,
177
+ revisionFloor: number,
178
+ ): Promise<GlobalConfig> {
179
+ const current = cloneGlobalConfig(currentValue);
180
+ if (
181
+ current.enabled === mutation.enabled &&
182
+ reviewerSelectionsEqual(current.reviewer, mutation.reviewer)
183
+ ) {
184
+ return current;
185
+ }
186
+ const next: GlobalConfig = {
187
+ version: CONFIG_VERSION,
188
+ enabled: mutation.enabled,
189
+ reviewer: cloneReviewerSelection(mutation.reviewer),
190
+ revision: nextRevision(revisionFloor),
191
+ };
192
+ await this.writeCommitted(next);
193
+ return cloneGlobalConfig(next);
194
+ }
195
+
196
+ private async writeCommitted(config: Readonly<GlobalConfig>): Promise<void> {
197
+ const revisionContents = `${String(config.revision)}\n`;
198
+ // The recovery watermark is published first. A crash at either metadata
199
+ // rename is observable as Fault and an explicit repair uses the larger
200
+ // surviving valid watermark. Readers share the writer lock, so they never
201
+ // observe this intentionally staged publication while it is in progress.
202
+ await this.writeAtomicFile(this.revisionRecoveryPath, revisionContents);
203
+ await this.writeAtomicFile(this.revisionPath, revisionContents);
204
+ await this.writeAtomicFile(this.configPath, `${serializeGlobalConfig(config)}\n`);
205
+ }
206
+
207
+ private async writeAtomicFile(targetPath: string, contents: string): Promise<void> {
208
+ const directory = dirname(targetPath);
209
+ const temporaryPath = `${targetPath}.${process.pid}.${this.createId()}.tmp`;
210
+ let handle: DurableHandle | undefined;
211
+ let renamed = false;
212
+
213
+ try {
214
+ handle = await this.fileSystem.open(
215
+ temporaryPath,
216
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
217
+ 0o600,
218
+ );
219
+ await handle.writeFile(contents, { encoding: "utf8" });
220
+ await handle.sync();
221
+ await handle.close();
222
+ handle = undefined;
223
+ await this.fileSystem.rename(temporaryPath, targetPath);
224
+ renamed = true;
225
+ await bestEffortDirectorySync(directory, this.fileSystem);
226
+ } finally {
227
+ if (handle !== undefined) await handle.close().catch(() => undefined);
228
+ if (!renamed) await this.fileSystem.unlink(temporaryPath).catch(() => undefined);
229
+ }
230
+ }
231
+ }
232
+
233
+ export async function readGlobalConfig(
234
+ configPath: string,
235
+ fileSystem: ConfigFileSystem = NODE_CONFIG_FILE_SYSTEM,
236
+ ): Promise<GlobalState> {
237
+ return (
238
+ await readDurableSnapshot(
239
+ configPath,
240
+ `${configPath}.revision`,
241
+ `${configPath}.revision.recovery`,
242
+ fileSystem,
243
+ )
244
+ ).state;
245
+ }
246
+
247
+ type ConfigArtifact =
248
+ | { readonly kind: "missing" }
249
+ | { readonly kind: "valid"; readonly config: GlobalConfig }
250
+ | { readonly kind: "fault"; readonly error: string; readonly revisionHint?: number };
251
+
252
+ type RevisionArtifact =
253
+ | { readonly kind: "missing" }
254
+ | { readonly kind: "valid"; readonly value: number }
255
+ | { readonly kind: "fault"; readonly error: string };
256
+
257
+ interface DurableArtifacts {
258
+ readonly config: ConfigArtifact;
259
+ readonly counter: RevisionArtifact;
260
+ readonly recovery: RevisionArtifact;
261
+ }
262
+
263
+ interface DurableSnapshot {
264
+ readonly state: GlobalState;
265
+ readonly revisionFloor: number;
266
+ readonly artifacts: DurableArtifacts;
267
+ }
268
+
269
+ async function readDurableSnapshot(
270
+ configPath: string,
271
+ revisionPath: string,
272
+ revisionRecoveryPath: string,
273
+ fileSystem: ConfigFileSystem,
274
+ ): Promise<DurableSnapshot> {
275
+ const [config, counter, recovery] = await Promise.all([
276
+ readConfigArtifact(configPath, fileSystem),
277
+ readRevisionArtifact(revisionPath, "durable revision counter", fileSystem),
278
+ readRevisionArtifact(
279
+ revisionRecoveryPath,
280
+ "durable revision recovery watermark",
281
+ fileSystem,
282
+ ),
283
+ ]);
284
+ const artifacts: DurableArtifacts = { config, counter, recovery };
285
+ const validRevisionValues = revisionValues(artifacts);
286
+ const revisionFloor = Math.max(0, ...validRevisionValues);
287
+ const errors: string[] = [];
288
+
289
+ if (config.kind === "fault") errors.push(config.error);
290
+ if (counter.kind === "fault") errors.push(counter.error);
291
+ if (recovery.kind === "fault") errors.push(recovery.error);
292
+
293
+ const counterMissing = counter.kind === "missing";
294
+ const recoveryMissing = recovery.kind === "missing";
295
+ if (counterMissing !== recoveryMissing) {
296
+ errors.push("durable revision metadata is incomplete");
297
+ } else if (counter.kind === "valid" && recovery.kind === "valid") {
298
+ if (counter.value !== recovery.value) {
299
+ errors.push(
300
+ `durable revision metadata disagrees (${counter.value} != ${recovery.value})`,
301
+ );
302
+ }
303
+ }
304
+
305
+ if (config.kind !== "missing" && counterMissing && recoveryMissing) {
306
+ errors.push("durable revision metadata is missing");
307
+ }
308
+ if (config.kind === "missing" && (!counterMissing || !recoveryMissing)) {
309
+ errors.push("global permission state is missing while durable revision metadata exists");
310
+ }
311
+ if (
312
+ config.kind === "valid" &&
313
+ counter.kind === "valid" &&
314
+ recovery.kind === "valid" &&
315
+ counter.value < config.config.revision
316
+ ) {
317
+ errors.push(
318
+ `durable revision metadata ${counter.value} precedes config revision ${config.config.revision}`,
319
+ );
320
+ }
321
+ if (
322
+ counter.kind === "valid" &&
323
+ recovery.kind === "valid" &&
324
+ counter.value === Number.MAX_SAFE_INTEGER
325
+ ) {
326
+ errors.push("durable revision space is exhausted");
327
+ }
328
+
329
+ if (errors.length > 0) {
330
+ const revisionHint = Math.max(
331
+ 0,
332
+ ...validRevisionValues,
333
+ config.kind === "fault" ? (config.revisionHint ?? 0) : 0,
334
+ );
335
+ const state: GlobalState = {
336
+ health: "fault",
337
+ error: errors.join("; "),
338
+ ...(revisionHint === 0 ? {} : { revisionHint }),
339
+ ...(config.kind === "valid"
340
+ ? { recoverableConfig: cloneGlobalConfig(config.config) }
341
+ : {}),
342
+ };
343
+ return { state, revisionFloor, artifacts };
344
+ }
345
+
346
+ if (config.kind === "missing") {
347
+ return {
348
+ state: { health: "missing", config: cloneGlobalConfig(DEFAULT_GLOBAL_CONFIG) },
349
+ revisionFloor,
350
+ artifacts,
351
+ };
352
+ }
353
+
354
+ if (config.kind === "valid") {
355
+ return {
356
+ state: { health: "valid", config: cloneGlobalConfig(config.config) },
357
+ revisionFloor,
358
+ artifacts,
359
+ };
360
+ }
361
+ throw new Error("unreachable durable-state composition");
362
+ }
363
+
364
+ async function readConfigArtifact(
365
+ configPath: string,
366
+ fileSystem: ConfigFileSystem,
367
+ ): Promise<ConfigArtifact> {
368
+ let contents: string;
369
+ try {
370
+ contents = await fileSystem.readFile(configPath, "utf8");
371
+ } catch (error) {
372
+ if (isErrorCode(error, "ENOENT")) {
373
+ return { kind: "missing" };
374
+ }
375
+ return {
376
+ kind: "fault",
377
+ error: describeError("cannot read global permission state", error),
378
+ };
379
+ }
380
+
381
+ let parsed: unknown;
382
+ try {
383
+ parsed = JSON.parse(contents) as unknown;
384
+ } catch (error) {
385
+ return {
386
+ kind: "fault",
387
+ error: describeError("invalid global permission JSON", error),
388
+ };
389
+ }
390
+
391
+ const revisionHint = extractRevisionHint(parsed);
392
+ try {
393
+ return { kind: "valid", config: parseGlobalConfig(parsed) };
394
+ } catch (error) {
395
+ return {
396
+ kind: "fault",
397
+ error: describeError("invalid global permission state", error),
398
+ ...(revisionHint === undefined ? {} : { revisionHint }),
399
+ };
400
+ }
401
+ }
402
+
403
+ async function readRevisionArtifact(
404
+ path: string,
405
+ label: string,
406
+ fileSystem: ConfigFileSystem,
407
+ ): Promise<RevisionArtifact> {
408
+ let contents: string;
409
+ try {
410
+ contents = await fileSystem.readFile(path, "utf8");
411
+ } catch (error) {
412
+ if (isErrorCode(error, "ENOENT")) return { kind: "missing" };
413
+ return { kind: "fault", error: describeError(`cannot read ${label}`, error) };
414
+ }
415
+
416
+ const normalized = contents.trim();
417
+ if (!/^(?:0|[1-9][0-9]*)$/u.test(normalized)) {
418
+ return { kind: "fault", error: `invalid ${label}: expected a non-negative integer` };
419
+ }
420
+ const value = Number(normalized);
421
+ try {
422
+ assertRevision(value, label);
423
+ } catch (error) {
424
+ return { kind: "fault", error: describeError(`invalid ${label}`, error) };
425
+ }
426
+ return { kind: "valid", value };
427
+ }
428
+
429
+ function revisionValues(artifacts: DurableArtifacts): number[] {
430
+ const values: number[] = [];
431
+ if (artifacts.config.kind === "valid") values.push(artifacts.config.config.revision);
432
+ if (artifacts.counter.kind === "valid") values.push(artifacts.counter.value);
433
+ if (artifacts.recovery.kind === "valid") values.push(artifacts.recovery.value);
434
+ return values;
435
+ }
436
+
437
+ function revisionRepairFloor(artifacts: DurableArtifacts): number {
438
+ const validMetadata = [artifacts.counter, artifacts.recovery].filter(
439
+ (artifact): artifact is Extract<RevisionArtifact, { kind: "valid" }> =>
440
+ artifact.kind === "valid",
441
+ );
442
+ if (validMetadata.length === 0) {
443
+ throw new ConfigFaultError({
444
+ health: "fault",
445
+ error:
446
+ "revision metadata cannot be repaired safely because neither durable watermark is valid",
447
+ });
448
+ }
449
+
450
+ const configFloor =
451
+ artifacts.config.kind === "valid"
452
+ ? artifacts.config.config.revision
453
+ : artifacts.config.kind === "fault"
454
+ ? (artifacts.config.revisionHint ?? 0)
455
+ : 0;
456
+ return Math.max(configFloor, 0, ...validMetadata.map((artifact) => artifact.value));
457
+ }
458
+
459
+ export function parseGlobalConfig(value: unknown): GlobalConfig {
460
+ if (!isPlainRecord(value)) throw new TypeError("configuration must be a plain object");
461
+ assertExactKeys(value, ["enabled", "reviewer", "revision", "version"], "configuration");
462
+ if (value.version !== CONFIG_VERSION) throw new TypeError(`unsupported configuration version ${String(value.version)}`);
463
+ if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
464
+ assertRevision(value.revision, "global revision");
465
+
466
+ return {
467
+ version: CONFIG_VERSION,
468
+ enabled: value.enabled,
469
+ reviewer: parseReviewer(value.reviewer),
470
+ revision: value.revision,
471
+ };
472
+ }
473
+
474
+ export function serializeGlobalConfig(config: Readonly<GlobalConfig>): string {
475
+ const parsed = parseGlobalConfig(config);
476
+ return JSON.stringify({
477
+ version: CONFIG_VERSION,
478
+ enabled: parsed.enabled,
479
+ reviewer: parsed.reviewer,
480
+ revision: parsed.revision,
481
+ });
482
+ }
483
+
484
+ const NODE_CONFIG_FILE_SYSTEM: ConfigFileSystem = {
485
+ mkdir,
486
+ readFile,
487
+ open,
488
+ rename,
489
+ unlink,
490
+ };
491
+
492
+ const PROPER_FILE_LOCK: ExclusiveLock = {
493
+ async run<T>(path: string, operation: () => Promise<T>): Promise<T> {
494
+ const release = await lockfile.lock(path, {
495
+ realpath: false,
496
+ stale: 30_000,
497
+ update: 10_000,
498
+ retries: { retries: 40, factor: 1.25, minTimeout: 10, maxTimeout: 250 },
499
+ });
500
+ try {
501
+ return await operation();
502
+ } finally {
503
+ await release();
504
+ }
505
+ },
506
+ };
507
+
508
+ async function bestEffortDirectorySync(directory: string, fileSystem: ConfigFileSystem): Promise<void> {
509
+ let handle: DurableHandle | undefined;
510
+ try {
511
+ handle = await fileSystem.open(directory, constants.O_RDONLY);
512
+ await handle.sync();
513
+ } catch {
514
+ // Some supported filesystems do not permit fsync on a directory.
515
+ } finally {
516
+ await handle?.close().catch(() => undefined);
517
+ }
518
+ }
519
+
520
+ function parseReviewer(value: unknown): ReviewerSelection | null {
521
+ if (value === null) return null;
522
+ if (!isPlainRecord(value)) throw new TypeError("reviewer must be null or a plain object");
523
+ assertExactKeys(value, ["modelId", "provider", "thinkingLevel"], "reviewer");
524
+ const reviewer: ReviewerSelection = {
525
+ provider: requireNonEmptyString(value.provider, "reviewer.provider"),
526
+ modelId: requireNonEmptyString(value.modelId, "reviewer.modelId"),
527
+ thinkingLevel: value.thinkingLevel as ReviewerSelection["thinkingLevel"],
528
+ };
529
+ validateReviewer(reviewer);
530
+ return reviewer;
531
+ }
532
+
533
+ function validateReviewer(value: ReviewerSelection | null): void {
534
+ if (value === null) return;
535
+ requireNonEmptyString(value.provider, "reviewer.provider");
536
+ requireNonEmptyString(value.modelId, "reviewer.modelId");
537
+ if (!isModelThinkingLevel(value.thinkingLevel)) {
538
+ throw new TypeError(`invalid reviewer.thinkingLevel ${String(value.thinkingLevel)}`);
539
+ }
540
+ }
541
+
542
+ function validateMutation(value: Readonly<GlobalConfigMutation>): void {
543
+ if (typeof value !== "object" || value === null) throw new TypeError("configuration mutation must be an object");
544
+ if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
545
+ validateReviewer(value.reviewer);
546
+ }
547
+
548
+ function requireNonEmptyString(value: unknown, label: string): string {
549
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`${label} must be a non-empty string`);
550
+ return value;
551
+ }
552
+
553
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
554
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
555
+ const prototype = Object.getPrototypeOf(value);
556
+ return prototype === Object.prototype || prototype === null;
557
+ }
558
+
559
+ function assertExactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
560
+ const actual = Object.keys(value).sort();
561
+ const sortedExpected = [...expected].sort();
562
+ if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
563
+ throw new TypeError(`${label} has unknown or missing fields`);
564
+ }
565
+ }
566
+
567
+ function extractRevisionHint(value: unknown): number | undefined {
568
+ if (!isPlainRecord(value)) return undefined;
569
+ return Number.isSafeInteger(value.revision) && (value.revision as number) >= 0
570
+ ? (value.revision as number)
571
+ : undefined;
572
+ }
573
+
574
+ function describeError(prefix: string, error: unknown): string {
575
+ return `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
576
+ }
577
+
578
+ function isErrorCode(error: unknown, code: string): boolean {
579
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code;
580
+ }
@@ -0,0 +1,2 @@
1
+ export * from "../domain.ts";
2
+ export * from "./config-store.ts";
@@ -0,0 +1,101 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { type Static, Type } from "typebox";
7
+ import {
8
+ GUARDIAN_DENIAL_MESSAGE,
9
+ type GuardianTranscriptItem,
10
+ } from "../guardian/index.ts";
11
+ import type { PermissionEngine } from "../runtime/index.ts";
12
+
13
+ export const GUARDED_BASH_METADATA = Object.freeze({
14
+ kind: "pi-auto-permissions-bash",
15
+ policy: "codex-auto-workspace-write",
16
+ schemaVersion: 1,
17
+ });
18
+
19
+ export const guardedBashSchema = Type.Object({
20
+ command: Type.String({ description: "Bash command to execute" }),
21
+ timeout: Type.Optional(
22
+ Type.Number({ description: "Timeout in seconds (optional, no default timeout)" }),
23
+ ),
24
+ sandbox_permissions: Type.Optional(
25
+ Type.Union([Type.Literal("use_default"), Type.Literal("require_escalated")], {
26
+ description:
27
+ "Use require_escalated only when the fixed Auto sandbox cannot perform a necessary action. Permission review may deny it and never asks the user to override.",
28
+ }),
29
+ ),
30
+ });
31
+
32
+ export type GuardedBashInput = Static<typeof guardedBashSchema>;
33
+
34
+ export interface GuardedBashRuntime {
35
+ readonly engine: PermissionEngine;
36
+ readonly local: ReturnType<typeof createBashToolDefinition>;
37
+ readonly sandboxed: ReturnType<typeof createBashToolDefinition>;
38
+ turnId(toolCallId: string): string;
39
+ transcript(ctx: ExtensionContext): readonly GuardianTranscriptItem[];
40
+ signal(external: AbortSignal | undefined): AbortSignal | undefined;
41
+ refreshBackend(ctx: ExtensionContext): Promise<void> | void;
42
+ }
43
+
44
+ /**
45
+ * Override Pi's bash tool so routing is decided inside the final executor,
46
+ * after schema validation, and the selected executor is entered at most once.
47
+ */
48
+ export function registerGuardedBashTool(
49
+ pi: ExtensionAPI,
50
+ getRuntime: () => GuardedBashRuntime | null,
51
+ ): void {
52
+ const renderer = createBashToolDefinition(process.cwd());
53
+ pi.registerTool({
54
+ ...renderer,
55
+ parameters: guardedBashSchema,
56
+ executionMode: "sequential",
57
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
58
+ const runtime = getRuntime();
59
+ if (runtime === null) throw new Error(GUARDIAN_DENIAL_MESSAGE);
60
+ await runtime.refreshBackend(ctx);
61
+ const reviewSignal = runtime.signal(signal);
62
+
63
+ const decision = await runtime.engine.gate({
64
+ toolCallId,
65
+ turnId: runtime.turnId(toolCallId),
66
+ toolName: "bash",
67
+ input: params,
68
+ cwd: ctx.cwd,
69
+ toolMetadata: GUARDED_BASH_METADATA,
70
+ transcript: () => runtime.transcript(ctx),
71
+ ...(reviewSignal === undefined ? {} : { signal: reviewSignal }),
72
+ });
73
+ if (decision.outcome === "deny") {
74
+ if (decision.interruptTurn) ctx.abort();
75
+ throw new Error(decision.message);
76
+ }
77
+
78
+ const executableParams = stripPermissionParameter(params);
79
+ const executor = decision.route === "sandboxed" ? runtime.sandboxed : runtime.local;
80
+ // Do not await or perform any other asynchronous work between the final
81
+ // binding check in gate() and entering Pi's executor.
82
+ try {
83
+ return await executor.execute(toolCallId, executableParams, reviewSignal, onUpdate, ctx);
84
+ } catch (error) {
85
+ // A wrap/cleanup failure can mark the strong backend unavailable after
86
+ // execution began. Reflect that transition immediately, never replay.
87
+ if (decision.route === "sandboxed") await runtime.refreshBackend(ctx);
88
+ throw error;
89
+ }
90
+ },
91
+ });
92
+ }
93
+
94
+ function stripPermissionParameter(params: GuardedBashInput): {
95
+ command: string;
96
+ timeout?: number;
97
+ } {
98
+ return params.timeout === undefined
99
+ ? { command: params.command }
100
+ : { command: params.command, timeout: params.timeout };
101
+ }