pi-better-sandbox 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/deny-rules.ts ADDED
@@ -0,0 +1,623 @@
1
+ /**
2
+ * The one place write-deny rules are validated, stored, and put into force.
3
+ *
4
+ * `/sandbox deny list|add|remove|reset` and the `/sandbox rules` page are two
5
+ * presentations of this module and nothing else: neither validates a path,
6
+ * touches the override file, or talks to the controller on its own. That is
7
+ * what keeps the slash commands and the settings page from drifting apart.
8
+ *
9
+ * Three ideas carry the design.
10
+ *
11
+ * **Templates, not paths.** A rule is stored as the human typed it (normalized)
12
+ * — `.env` stays relative, `~/.aws` stays home-relative, `/etc/hosts` stays
13
+ * absolute — and is resolved against the open project every time it is applied
14
+ * or displayed. That is what "one global template set, not a per-project
15
+ * database" means: the same relative rule denies the same relative path in
16
+ * every project, while the UI always shows the canonical absolute path it
17
+ * currently resolves to.
18
+ *
19
+ * **Nothing is written until a rule changes.** Installing the package writes no
20
+ * settings file; the packaged defaults live in source (`policy.ts`). The
21
+ * override file appears on the first `add` or `remove`, and `reset` deletes it
22
+ * again, which is exactly what "restore the defaults from the installed package
23
+ * version" requires — a copy of the defaults on disk would go stale the next
24
+ * time the package shipped different ones.
25
+ *
26
+ * **Validation reuses enforcement.** Every check below resolves through
27
+ * `resolveDenyWriteTemplate` and decides containment with `evaluateWriteAccess`
28
+ * — the same functions the write/edit guard and the kernel profile builders use
29
+ * — so a rule can never be accepted by validation and then mean something else
30
+ * to the sandbox.
31
+ */
32
+
33
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
34
+ import { dirname, join, normalize, sep } from "node:path";
35
+
36
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
37
+
38
+ import {
39
+ PACKAGED_DENY_WRITE_TEMPLATES,
40
+ type PolicySeams,
41
+ resolveDenyWriteTemplate,
42
+ } from "./policy.ts";
43
+ import {
44
+ type CompiledSandboxWritePolicy,
45
+ evaluateWriteAccess,
46
+ } from "./shared-sandbox-core.ts";
47
+ import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./state.ts";
48
+
49
+ /** The override file, alongside the extension settings pi's own example uses. */
50
+ export const DENY_RULES_FILE_NAME = "pi-better-sandbox.json";
51
+
52
+ /** Bumped only if the stored shape ever has to change incompatibly. */
53
+ export const DENY_RULES_FORMAT_VERSION = 1;
54
+
55
+ /** Why a rule change was refused. Every kind carries an actionable message. */
56
+ export type DenyRuleErrorKind =
57
+ | "malformed"
58
+ | "unsafe"
59
+ | "duplicate"
60
+ | "overlapping"
61
+ | "unknown"
62
+ | "no-project"
63
+ | "unreadable-override";
64
+
65
+ /** A refused rule change. The message is written to be shown to the human. */
66
+ export class DenyRuleError extends Error {
67
+ readonly kind: DenyRuleErrorKind;
68
+
69
+ constructor(kind: DenyRuleErrorKind, message: string) {
70
+ super(message);
71
+ this.name = "DenyRuleError";
72
+ this.kind = kind;
73
+ }
74
+ }
75
+
76
+ /** One stored rule, together with what it currently denies in the open project. */
77
+ export type DenyRule = {
78
+ /** As stored: relative, `~`-relative, or absolute. */
79
+ readonly template: string;
80
+ /** The canonical absolute path it resolves to in this project. */
81
+ readonly path: string;
82
+ };
83
+
84
+ /** A stored rule that cannot be applied to the open project, and why. */
85
+ export type InertDenyRule = {
86
+ readonly template: string;
87
+ readonly reason: string;
88
+ };
89
+
90
+ /** Everything a surface needs to render the rule set after a read or a change. */
91
+ export type DenyRuleReport = {
92
+ /** The stored template set, sorted. */
93
+ readonly templates: readonly string[];
94
+ /** Those templates resolved against the open project, sorted by path. */
95
+ readonly rules: readonly DenyRule[];
96
+ /** Templates held out of the effective policy for this project, and why. */
97
+ readonly inert: readonly InertDenyRule[];
98
+ /** Whether the rules come from the packaged defaults or a user override. */
99
+ readonly origin: "packaged" | "override";
100
+ /** Where the user override lives, whether or not it exists yet. */
101
+ readonly overridePath: string;
102
+ /** The effective sandbox status after the rules were applied. */
103
+ readonly status: ForegroundSandboxStatus;
104
+ /** One line describing what the caller just did. */
105
+ readonly summary: string;
106
+ /** Present when the override file exists but could not be understood. */
107
+ readonly overrideProblem: string | undefined;
108
+ };
109
+
110
+ export type DenyRuleStoreSeams = {
111
+ /**
112
+ * The pi agent directory the override lives under. Defaults to the SDK's
113
+ * `getAgentDir()` (`$PI_CODING_AGENT_DIR`, else `~/.pi/agent`). Injected in
114
+ * tests so no test ever reads or writes the developer's real pi state.
115
+ */
116
+ agentDir?: () => string;
117
+ };
118
+
119
+ export type DenyRuleSeams = PolicySeams & DenyRuleStoreSeams;
120
+
121
+ /** Where the user override lives. Reading it is the only reason to need this. */
122
+ export function denyRuleOverridePath(seams: DenyRuleStoreSeams = {}): string {
123
+ return join((seams.agentDir ?? getAgentDir)(), "extensions", DENY_RULES_FILE_NAME);
124
+ }
125
+
126
+ /**
127
+ * Normalize one entry into the template that will be stored.
128
+ *
129
+ * This is where malformed input is refused. Everything that survives is a
130
+ * concrete path in one of the three supported shapes, with redundant `./`,
131
+ * doubled separators, and a trailing separator removed so `.git/hooks/` and
132
+ * `.git/hooks` can never both be stored as separate rules.
133
+ */
134
+ export function normalizeDenyRuleTemplate(entry: string): string {
135
+ const trimmed = entry.trim();
136
+ if (trimmed === "") {
137
+ throw new DenyRuleError("malformed", "A write-denied path may not be empty.");
138
+ }
139
+ if (/[\0\r\n]/.test(trimmed)) {
140
+ throw new DenyRuleError(
141
+ "malformed",
142
+ "A write-denied path may not contain line breaks or null bytes.",
143
+ );
144
+ }
145
+ if (/[*?[\]{}]/.test(trimmed)) {
146
+ throw new DenyRuleError(
147
+ "malformed",
148
+ `Deny rules are concrete paths, not patterns, so "${trimmed}" would never match anything. Add the specific file or directory instead.`,
149
+ );
150
+ }
151
+ if (trimmed === "~") return "~";
152
+ if (trimmed.startsWith(`~${sep}`)) {
153
+ const tail = trimTrailingSeparator(trimmed.slice(2));
154
+ // `~/` and `~/.` are the home directory itself, spelled the long way.
155
+ return tail === "." || tail === sep ? "~" : `~${sep}${tail}`;
156
+ }
157
+ return trimTrailingSeparator(trimmed);
158
+ }
159
+
160
+ function trimTrailingSeparator(value: string): string {
161
+ const normalized = normalize(value);
162
+ if (normalized === sep || normalized === "") return sep;
163
+ return normalized.endsWith(sep) ? normalized.slice(0, -1) : normalized;
164
+ }
165
+
166
+ /**
167
+ * Ask the enforcement rule itself whether one canonical rule denies one
168
+ * canonical path.
169
+ *
170
+ * Borrowing `evaluateWriteAccess` rather than re-deriving containment is
171
+ * deliberate: overlap detection and the write guard are then provably the same
172
+ * predicate, so no rule can be accepted as distinct and later behave as a
173
+ * duplicate.
174
+ */
175
+ function ruleDenies(canonicalRule: string, canonicalTarget: string, seams: DenyRuleSeams): boolean {
176
+ const policy: CompiledSandboxWritePolicy = {
177
+ writableRoot: sep,
178
+ denyWrite: [canonicalRule],
179
+ home: sep,
180
+ };
181
+ return !evaluateWriteAccess(canonicalTarget, policy, seams).allowed;
182
+ }
183
+
184
+ /** Resolve a template set into displayable rules, sorted by effective path. */
185
+ export function describeDenyRules(
186
+ templates: readonly string[],
187
+ projectRoot: string,
188
+ seams: DenyRuleSeams = {},
189
+ ): DenyRule[] {
190
+ return templates
191
+ .map((template) => ({
192
+ template,
193
+ path: resolveDenyWriteTemplate(template, projectRoot, seams),
194
+ }))
195
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
196
+ }
197
+
198
+ /**
199
+ * The template set with the rules that cannot apply to this project removed.
200
+ *
201
+ * A rule that resolves to the project root or one of its ancestors would deny
202
+ * the entire writable root — every write in the project would fail and the
203
+ * sandbox would look broken rather than protective. `add` refuses such a rule
204
+ * outright, but a global template added in one project can become an ancestor
205
+ * in another, so the set is filtered again on every load and the held-out rules
206
+ * are reported instead of silently dropped.
207
+ */
208
+ export function partitionDenyRules(
209
+ templates: readonly string[],
210
+ projectRoot: string,
211
+ seams: DenyRuleSeams = {},
212
+ ): { applicable: string[]; inert: InertDenyRule[] } {
213
+ const applicable: string[] = [];
214
+ const inert: InertDenyRule[] = [];
215
+ for (const template of templates) {
216
+ let path: string;
217
+ try {
218
+ path = resolveDenyWriteTemplate(template, projectRoot, seams);
219
+ } catch (error) {
220
+ inert.push({ template, reason: messageOf(error) });
221
+ continue;
222
+ }
223
+ if (ruleDenies(path, projectRoot, seams)) {
224
+ inert.push({
225
+ template,
226
+ reason: `it resolves to ${path}, which contains this project root, so applying it would make the whole project unwritable.`,
227
+ });
228
+ continue;
229
+ }
230
+ applicable.push(template);
231
+ }
232
+ return { applicable, inert };
233
+ }
234
+
235
+ /**
236
+ * Validate an added rule against the rules already stored, and return the new
237
+ * template set.
238
+ *
239
+ * Overlap is refused in both directions rather than silently subsumed: a rule
240
+ * already covered by another is redundant, and a rule that would swallow
241
+ * existing rules would quietly retire rules the human never named. Both errors
242
+ * say which stored rule is in the way and what to do about it.
243
+ */
244
+ export function planDenyRuleAddition(
245
+ entry: string,
246
+ templates: readonly string[],
247
+ projectRoot: string,
248
+ seams: DenyRuleSeams = {},
249
+ ): { template: string; path: string; templates: string[] } {
250
+ const template = normalizeDenyRuleTemplate(entry);
251
+ const path = resolveDenyWriteTemplate(template, projectRoot, seams);
252
+
253
+ if (ruleDenies(path, projectRoot, seams)) {
254
+ throw new DenyRuleError(
255
+ "unsafe",
256
+ `${template} resolves to ${path}, which contains the project root ${projectRoot}. Denying it would make every write in the project fail. Deny a path inside the project instead, or use /sandbox off if you meant to turn protection off.`,
257
+ );
258
+ }
259
+
260
+ for (const existing of describeDenyRules(templates, projectRoot, seams)) {
261
+ if (existing.path === path) {
262
+ throw new DenyRuleError(
263
+ "duplicate",
264
+ existing.template === template
265
+ ? `${path} is already write-denied by the rule ${template}.`
266
+ : `${template} resolves to ${path}, which the rule ${existing.template} already denies.`,
267
+ );
268
+ }
269
+ if (ruleDenies(existing.path, path, seams)) {
270
+ throw new DenyRuleError(
271
+ "overlapping",
272
+ `${path} is already inside the write-denied directory ${existing.path} (rule ${existing.template}), so the new rule would change nothing.`,
273
+ );
274
+ }
275
+ if (ruleDenies(path, existing.path, seams)) {
276
+ throw new DenyRuleError(
277
+ "overlapping",
278
+ `${path} would cover the narrower rule ${existing.template} (${existing.path}). Remove that rule first with /sandbox deny remove ${existing.template}, then add this one.`,
279
+ );
280
+ }
281
+ }
282
+
283
+ return { template, path, templates: [...templates, template].sort() };
284
+ }
285
+
286
+ /**
287
+ * Validate a removal and return the new template set.
288
+ *
289
+ * A rule can be named either the way it is stored or by the canonical absolute
290
+ * path the UI displays, because those are the two strings a human has actually
291
+ * seen.
292
+ */
293
+ export function planDenyRuleRemoval(
294
+ entry: string,
295
+ templates: readonly string[],
296
+ projectRoot: string,
297
+ seams: DenyRuleSeams = {},
298
+ ): { template: string; path: string; templates: string[] } {
299
+ const wanted = normalizeDenyRuleTemplate(entry);
300
+ const rules = describeDenyRules(templates, projectRoot, seams);
301
+ const wantedPath = tryResolve(wanted, projectRoot, seams);
302
+
303
+ const match =
304
+ rules.find((rule) => rule.template === wanted) ??
305
+ (wantedPath === undefined ? undefined : rules.find((rule) => rule.path === wantedPath));
306
+
307
+ if (match === undefined) {
308
+ const known = rules.length === 0 ? "none" : rules.map((rule) => rule.template).join(", ");
309
+ throw new DenyRuleError(
310
+ "unknown",
311
+ `${wanted} is not a write-deny rule. Current rules: ${known}.`,
312
+ );
313
+ }
314
+
315
+ return {
316
+ template: match.template,
317
+ path: match.path,
318
+ templates: templates.filter((template) => template !== match.template).sort(),
319
+ };
320
+ }
321
+
322
+ function tryResolve(
323
+ template: string,
324
+ projectRoot: string,
325
+ seams: DenyRuleSeams,
326
+ ): string | undefined {
327
+ try {
328
+ return resolveDenyWriteTemplate(template, projectRoot, seams);
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /** The persisted shape. Read defensively; every field is validated on load. */
335
+ type DenyRuleOverrideFile = {
336
+ version: number;
337
+ denyWrite: string[];
338
+ };
339
+
340
+ /**
341
+ * Read the user override, or `undefined` when none exists.
342
+ *
343
+ * Throws `DenyRuleError("unreadable-override")` when the file exists but cannot
344
+ * be understood. The caller keeps the packaged defaults in force and refuses to
345
+ * overwrite the file until the human resets it, so a typo in a hand-edited
346
+ * override is never silently converted into a lost rule set.
347
+ */
348
+ export function readDenyRuleOverride(
349
+ seams: DenyRuleStoreSeams = {},
350
+ ): readonly string[] | undefined {
351
+ const path = denyRuleOverridePath(seams);
352
+ let raw: string;
353
+ try {
354
+ raw = readFileSync(path, "utf8");
355
+ } catch (error) {
356
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
357
+ throw new DenyRuleError(
358
+ "unreadable-override",
359
+ `The write-deny override at ${path} could not be read: ${messageOf(error)}`,
360
+ );
361
+ }
362
+
363
+ let parsed: unknown;
364
+ try {
365
+ parsed = JSON.parse(raw);
366
+ } catch (error) {
367
+ throw new DenyRuleError(
368
+ "unreadable-override",
369
+ `The write-deny override at ${path} is not valid JSON: ${messageOf(error)}`,
370
+ );
371
+ }
372
+
373
+ const denyWrite = (parsed as Partial<DenyRuleOverrideFile> | null)?.denyWrite;
374
+ if (!Array.isArray(denyWrite) || denyWrite.some((entry) => typeof entry !== "string")) {
375
+ throw new DenyRuleError(
376
+ "unreadable-override",
377
+ `The write-deny override at ${path} must contain a "denyWrite" array of path strings.`,
378
+ );
379
+ }
380
+
381
+ // Entries are normalized on the way in so a hand-edited file behaves exactly
382
+ // as the same paths typed at the command would.
383
+ try {
384
+ return denyWrite.map((entry) => normalizeDenyRuleTemplate(entry)).sort();
385
+ } catch (error) {
386
+ throw new DenyRuleError(
387
+ "unreadable-override",
388
+ `The write-deny override at ${path} contains an entry that is not a usable path: ${messageOf(error)}`,
389
+ );
390
+ }
391
+ }
392
+
393
+ /** Write the user override. This is the only thing that creates the file. */
394
+ export function writeDenyRuleOverride(
395
+ templates: readonly string[],
396
+ seams: DenyRuleStoreSeams = {},
397
+ ): string {
398
+ const path = denyRuleOverridePath(seams);
399
+ mkdirSync(dirname(path), { recursive: true });
400
+ const contents = `${JSON.stringify(
401
+ { version: DENY_RULES_FORMAT_VERSION, denyWrite: [...templates].sort() } satisfies DenyRuleOverrideFile,
402
+ undefined,
403
+ 2,
404
+ )}\n`;
405
+ // Written through a temporary file so a reader never observes a half-written
406
+ // rule set, and a failed write leaves the previous rules intact.
407
+ const pending = `${path}.${process.pid}.tmp`;
408
+ writeFileSync(pending, contents, "utf8");
409
+ renameSync(pending, path);
410
+ return path;
411
+ }
412
+
413
+ /** Delete the user override. Returns whether there was one to delete. */
414
+ export function clearDenyRuleOverride(seams: DenyRuleStoreSeams = {}): boolean {
415
+ const path = denyRuleOverridePath(seams);
416
+ try {
417
+ rmSync(path);
418
+ return true;
419
+ } catch (error) {
420
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
421
+ throw new DenyRuleError(
422
+ "unreadable-override",
423
+ `The write-deny override at ${path} could not be removed: ${messageOf(error)}`,
424
+ );
425
+ }
426
+ }
427
+
428
+ function messageOf(error: unknown): string {
429
+ return error instanceof Error ? error.message : String(error);
430
+ }
431
+
432
+ export type DenyRuleManagerDeps = {
433
+ controller: ForegroundSandboxController;
434
+ /** Called after every change so the footer and `pi.events` stay truthful. */
435
+ onStateChange: (status: ForegroundSandboxStatus) => void;
436
+ seams?: DenyRuleSeams;
437
+ };
438
+
439
+ /**
440
+ * The rule set of one pi session: what is stored, what applies here, and what
441
+ * the sandbox is currently enforcing.
442
+ *
443
+ * Every mutating method validates, persists, re-applies to the controller, and
444
+ * announces — in that order — so a surface cannot perform three of those four
445
+ * steps and leave the footer or a consuming extension describing rules that are
446
+ * not in force.
447
+ */
448
+ export class DenyRuleManager {
449
+ readonly #controller: ForegroundSandboxController;
450
+ readonly #onStateChange: (status: ForegroundSandboxStatus) => void;
451
+ readonly #seams: DenyRuleSeams;
452
+ #templates: readonly string[] = PACKAGED_DENY_WRITE_TEMPLATES;
453
+ #origin: "packaged" | "override" = "packaged";
454
+ #overrideProblem: string | undefined;
455
+
456
+ constructor({ controller, onStateChange, seams = {} }: DenyRuleManagerDeps) {
457
+ this.#controller = controller;
458
+ this.#onStateChange = onStateChange;
459
+ this.#seams = seams;
460
+ }
461
+
462
+ /**
463
+ * Re-read the override and put the resulting rules into force.
464
+ *
465
+ * Called at every session start, after the controller has captured the
466
+ * project root, because the same global template set resolves differently in
467
+ * a different project.
468
+ */
469
+ load(): DenyRuleReport {
470
+ try {
471
+ const stored = readDenyRuleOverride(this.#seams);
472
+ this.#templates = stored ?? PACKAGED_DENY_WRITE_TEMPLATES;
473
+ this.#origin = stored === undefined ? "packaged" : "override";
474
+ this.#overrideProblem = undefined;
475
+ } catch (error) {
476
+ // A broken override never reduces protection to nothing: the packaged
477
+ // defaults stay in force and the problem is surfaced instead.
478
+ this.#templates = PACKAGED_DENY_WRITE_TEMPLATES;
479
+ this.#origin = "packaged";
480
+ this.#overrideProblem = messageOf(error);
481
+ }
482
+ return this.#apply(
483
+ this.#origin === "override"
484
+ ? "Loaded the write-deny rules from your override."
485
+ : "Using the packaged write-deny defaults.",
486
+ );
487
+ }
488
+
489
+ /** The current rule set, without re-reading or changing anything. */
490
+ report(): DenyRuleReport {
491
+ return this.#describe(this.#summaryForListing());
492
+ }
493
+
494
+ /** Add one rule, persist the override, and put it into force. */
495
+ add(entry: string): DenyRuleReport {
496
+ this.#requireWritableOverride();
497
+ const projectRoot = this.#requireProjectRoot();
498
+ const plan = planDenyRuleAddition(entry, this.#templates, projectRoot, this.#seams);
499
+ this.#persist(plan.templates);
500
+ return this.#apply(`Write-denied ${plan.path} (rule ${plan.template}).`);
501
+ }
502
+
503
+ /** Remove one rule, persist the override, and put the change into force. */
504
+ remove(entry: string): DenyRuleReport {
505
+ this.#requireWritableOverride();
506
+ const projectRoot = this.#requireProjectRoot();
507
+ const plan = planDenyRuleRemoval(entry, this.#templates, projectRoot, this.#seams);
508
+ this.#persist(plan.templates);
509
+ return this.#apply(`Removed the write-deny rule ${plan.template} (${plan.path}).`);
510
+ }
511
+
512
+ /**
513
+ * Drop the user override and restore the defaults shipped by the installed
514
+ * package version.
515
+ *
516
+ * The defaults are read from source rather than from a copy on disk, so a
517
+ * package upgrade that changes them is picked up by a reset.
518
+ */
519
+ reset(): DenyRuleReport {
520
+ const removed = clearDenyRuleOverride(this.#seams);
521
+ this.#templates = PACKAGED_DENY_WRITE_TEMPLATES;
522
+ this.#origin = "packaged";
523
+ this.#overrideProblem = undefined;
524
+ return this.#apply(
525
+ removed
526
+ ? "Removed your write-deny override and restored the packaged defaults."
527
+ : "There was no write-deny override; the packaged defaults were already in force.",
528
+ );
529
+ }
530
+
531
+ /** Whether a user override currently exists. */
532
+ hasOverride(): boolean {
533
+ return this.#origin === "override";
534
+ }
535
+
536
+ #persist(templates: readonly string[]): void {
537
+ writeDenyRuleOverride(templates, this.#seams);
538
+ this.#templates = templates;
539
+ this.#origin = "override";
540
+ }
541
+
542
+ #requireWritableOverride(): void {
543
+ if (this.#overrideProblem === undefined) return;
544
+ throw new DenyRuleError(
545
+ "unreadable-override",
546
+ `${this.#overrideProblem} The packaged defaults are in force. Fix that file by hand, or run /sandbox deny reset to discard it.`,
547
+ );
548
+ }
549
+
550
+ #requireProjectRoot(): string {
551
+ const projectRoot = this.#controller.status().projectRoot;
552
+ if (projectRoot === undefined) {
553
+ throw new DenyRuleError(
554
+ "no-project",
555
+ "No session has captured a project root yet, so a rule cannot be resolved or displayed.",
556
+ );
557
+ }
558
+ return projectRoot;
559
+ }
560
+
561
+ /** Recompute what applies here, hand it to the controller, and announce. */
562
+ #apply(summary: string): DenyRuleReport {
563
+ const status = this.#controller.setDenyWriteTemplates(this.#partition().applicable);
564
+ this.#onStateChange(status);
565
+ return this.#describe(summary);
566
+ }
567
+
568
+ /**
569
+ * Describe the rule set exactly as it is being enforced.
570
+ *
571
+ * `rules` holds only what is actually in force here and `inert` holds the
572
+ * rest, so no surface can show a stored rule as if it were protecting
573
+ * something when it is not.
574
+ */
575
+ #describe(summary: string): DenyRuleReport {
576
+ const status = this.#controller.status();
577
+ const partition = this.#partition();
578
+ return Object.freeze({
579
+ templates: Object.freeze([...this.#templates].sort()),
580
+ rules: Object.freeze(
581
+ status.projectRoot === undefined
582
+ ? []
583
+ : describeDenyRules(partition.applicable, status.projectRoot, this.#seams),
584
+ ),
585
+ inert: Object.freeze(partition.inert),
586
+ origin: this.#origin,
587
+ overridePath: denyRuleOverridePath(this.#seams),
588
+ status,
589
+ summary,
590
+ overrideProblem: this.#overrideProblem,
591
+ });
592
+ }
593
+
594
+ #partition(): { applicable: string[]; inert: InertDenyRule[] } {
595
+ const projectRoot = this.#controller.status().projectRoot;
596
+ return projectRoot === undefined
597
+ ? { applicable: [...this.#templates], inert: [] }
598
+ : partitionDenyRules(this.#templates, projectRoot, this.#seams);
599
+ }
600
+
601
+ #summaryForListing(): string {
602
+ return this.#origin === "override"
603
+ ? `Write-deny rules from your override at ${denyRuleOverridePath(this.#seams)}.`
604
+ : "Write-deny rules from the packaged defaults; no override has been created.";
605
+ }
606
+ }
607
+
608
+ /** Render a rule set for `/sandbox deny list` and the `/sandbox` report. */
609
+ export function formatDenyRuleReport(report: DenyRuleReport): string {
610
+ const lines = [report.summary, ""];
611
+ if (report.rules.length === 0) lines.push(" (no write-denied paths)");
612
+ else
613
+ for (const rule of report.rules) {
614
+ lines.push(rule.template === rule.path ? ` ${rule.path}` : ` ${rule.path} [${rule.template}]`);
615
+ }
616
+
617
+ if (report.inert.length > 0) {
618
+ lines.push("", "Not applied in this project:");
619
+ for (const rule of report.inert) lines.push(` ${rule.template} — ${rule.reason}`);
620
+ }
621
+ if (report.overrideProblem !== undefined) lines.push("", report.overrideProblem);
622
+ return lines.join("\n");
623
+ }
package/events.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Cross-extension publication of the effective foreground sandbox policy.
3
+ *
4
+ * The contract is deliberately narrow: a frozen status snapshot travels over
5
+ * `pi.events`, and nothing else. No function, no controller, no way to run a
6
+ * command — a consumer can read what the policy is and enforce it itself, and
7
+ * that is all.
8
+ *
9
+ * Consumers snapshot at launch time. `pi.events` has no replay, so an extension
10
+ * that loads late can ask for the current policy on the request channel and
11
+ * receive it on the policy channel.
12
+ */
13
+
14
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
15
+
16
+ import type { ForegroundSandboxStatus } from "./state.ts";
17
+
18
+ /** Channel carrying every effective-policy change. Payload: ForegroundSandboxPolicyEvent. */
19
+ export const FOREGROUND_SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
20
+
21
+ /** Channel a late-loading consumer emits on to ask for the current policy. */
22
+ export const FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
23
+
24
+ /** The immutable payload published on the policy channel. */
25
+ export type ForegroundSandboxPolicyEvent = ForegroundSandboxStatus;
26
+
27
+ /** Deep-freeze a status so a consumer cannot mutate another consumer's copy. */
28
+ export function freezePolicy(status: ForegroundSandboxStatus): ForegroundSandboxPolicyEvent {
29
+ return Object.freeze({ ...status, denyWrite: Object.freeze([...status.denyWrite]) });
30
+ }
31
+
32
+ /** Publish the current effective policy to every subscribed extension. */
33
+ export function publishForegroundSandboxPolicy(
34
+ events: EventBus,
35
+ status: ForegroundSandboxStatus,
36
+ ): ForegroundSandboxPolicyEvent {
37
+ const payload = freezePolicy(status);
38
+ events.emit(FOREGROUND_SANDBOX_POLICY_CHANNEL, payload);
39
+ return payload;
40
+ }
41
+
42
+ /** Subscribe to effective-policy changes. Returns an unsubscribe function. */
43
+ export function subscribeForegroundSandboxPolicy(
44
+ events: EventBus,
45
+ handler: (policy: ForegroundSandboxPolicyEvent) => void,
46
+ ): () => void {
47
+ return events.on(FOREGROUND_SANDBOX_POLICY_CHANNEL, (data) => {
48
+ handler(data as ForegroundSandboxPolicyEvent);
49
+ });
50
+ }
51
+
52
+ /** Ask the sandbox extension to re-publish the current effective policy. */
53
+ export function requestForegroundSandboxPolicy(events: EventBus): void {
54
+ events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
55
+ }