Skip to content

Commit b6cf364

Browse files
authored
refactor: delegate filesystem enforcement to sandbox extension (#17)
* refactor(plan-ask): delegate filesystem enforcement to sandbox extension * feat(status-bar): add sandbox status display on line 3 * docs: update changelog
1 parent 4a63e28 commit b6cf364

4 files changed

Lines changed: 104 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ All notable changes to agent-stuff are documented here.
3030

3131

3232

33-
## feat/terminal-progress-indicator
33+
34+
35+
## refactor/delegate-filesystem-enforcement
36+
37+
This refactor consolidates filesystem enforcement into a dedicated event-driven architecture (#17). The plan-ask extension now delegates read-only command filtering to the sandbox extension via a shared `readonly` event on `pi.events`, eliminating ~100 lines of duplicated destructive-command pattern matching and reducing the plan-ask module's responsibility to tool restrictions and system prompts only. The sandbox extension listens for readonly state changes and dynamically reconfigures its filesystem allowlist, with an acknowledgment mechanism that warns users if the sandbox extension isn't loaded. Additionally, the status bar now displays sandbox state on a dedicated line 3, surfacing sandbox and readonly modes to users in real-time.
38+
39+
## [1.0.5](https://github.com/kostyay/agent-stuff/pull/16) - 2026-03-03
3440

3541
Introduces a terminal progress indicator extension using OSC 9;4 escape sequences (#16), providing visual feedback in the terminal tab/titlebar with an indeterminate pulse while the agent is working. The indicator automatically clears when the agent finishes or waits for user input, with graceful cleanup on process exit to prevent stuck indicators. Supports multiple terminal emulators including Ghostty, iTerm2, WezTerm, Windows Terminal, and ConEmu, enhancing the user experience during long-running agent operations without requiring explicit status polling.
3642

pi-extensions/plan-ask.ts

Lines changed: 24 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
* - **Plan** — Read-only. Agent explores, asks clarifying questions, builds a plan.
1717
* On completion, offers save/execute/refine options.
1818
*
19+
* Filesystem enforcement is delegated to the sandbox extension via the
20+
* shared `readonly` event on `pi.events`. This extension only manages
21+
* tool restrictions (removing edit/write) and system prompts.
22+
*
1923
* Depends on kbrainstorm extension for the ask_question tool.
2024
*/
2125

@@ -38,104 +42,6 @@ interface ModeState {
3842
originalTools: string[];
3943
}
4044

41-
// ── Safe command filter ──────────────────────────────────────────────────
42-
43-
const DESTRUCTIVE_PATTERNS: RegExp[] = [
44-
/\brm\b/i,
45-
/\brmdir\b/i,
46-
/\bmv\b/i,
47-
/\bcp\b/i,
48-
/\bmkdir\b/i,
49-
/\btouch\b/i,
50-
/\bchmod\b/i,
51-
/\bchown\b/i,
52-
/\bchgrp\b/i,
53-
/\bln\b/i,
54-
/\btee\b/i,
55-
/\btruncate\b/i,
56-
/\bdd\b/i,
57-
/\bshred\b/i,
58-
/(^|[^<])>(?!>)/,
59-
/>>/,
60-
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
61-
/\byarn\s+(add|remove|install|publish)/i,
62-
/\bpnpm\s+(add|remove|install|publish)/i,
63-
/\bpip\s+(install|uninstall)/i,
64-
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
65-
/\bbrew\s+(install|uninstall|upgrade)/i,
66-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
67-
/\bsudo\b/i,
68-
/\bsu\b/i,
69-
/\bkill\b/i,
70-
/\bpkill\b/i,
71-
/\bkillall\b/i,
72-
/\breboot\b/i,
73-
/\bshutdown\b/i,
74-
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
75-
/\bservice\s+\S+\s+(start|stop|restart)/i,
76-
/\b(vim?|nano|emacs|code|subl)\b/i,
77-
];
78-
79-
const SAFE_PATTERNS: RegExp[] = [
80-
/^\s*cat\b/,
81-
/^\s*head\b/,
82-
/^\s*tail\b/,
83-
/^\s*less\b/,
84-
/^\s*more\b/,
85-
/^\s*grep\b/,
86-
/^\s*find\b/,
87-
/^\s*ls\b/,
88-
/^\s*pwd\b/,
89-
/^\s*echo\b/,
90-
/^\s*printf\b/,
91-
/^\s*wc\b/,
92-
/^\s*sort\b/,
93-
/^\s*uniq\b/,
94-
/^\s*diff\b/,
95-
/^\s*file\b/,
96-
/^\s*stat\b/,
97-
/^\s*du\b/,
98-
/^\s*df\b/,
99-
/^\s*tree\b/,
100-
/^\s*which\b/,
101-
/^\s*whereis\b/,
102-
/^\s*type\b/,
103-
/^\s*env\b/,
104-
/^\s*printenv\b/,
105-
/^\s*uname\b/,
106-
/^\s*whoami\b/,
107-
/^\s*id\b/,
108-
/^\s*date\b/,
109-
/^\s*cal\b/,
110-
/^\s*uptime\b/,
111-
/^\s*ps\b/,
112-
/^\s*top\b/,
113-
/^\s*htop\b/,
114-
/^\s*free\b/,
115-
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
116-
/^\s*git\s+ls-/i,
117-
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
118-
/^\s*yarn\s+(list|info|why|audit)/i,
119-
/^\s*node\s+--version/i,
120-
/^\s*python\s+--version/i,
121-
/^\s*curl\s/i,
122-
/^\s*wget\s+-O\s*-/i,
123-
/^\s*jq\b/,
124-
/^\s*sed\s+-n/i,
125-
/^\s*awk\b/,
126-
/^\s*rg\b/,
127-
/^\s*fd\b/,
128-
/^\s*bat\b/,
129-
/^\s*exa\b/,
130-
];
131-
132-
/** Check whether a bash command is safe for read-only modes. */
133-
function isSafeCommand(command: string): boolean {
134-
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
135-
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
136-
return !isDestructive && isSafe;
137-
}
138-
13945
// ── Helpers ──────────────────────────────────────────────────────────────
14046

14147
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
@@ -273,6 +179,23 @@ export default function planAskExtension(pi: ExtensionAPI) {
273179
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg(display.color, `${display.icon} ${display.label}`));
274180
}
275181

182+
/**
183+
* Emit a `readonly` event on the shared event bus.
184+
* Includes an `ack` callback — if no listener calls it (sandbox not loaded),
185+
* warns the user that filesystem writes are not enforced.
186+
*/
187+
function emitReadonly(enabled: boolean, ctx: ExtensionContext): void {
188+
let acknowledged = false;
189+
pi.events.emit("readonly", { enabled, ack: () => { acknowledged = true; } });
190+
if (enabled && !acknowledged) {
191+
ctx.ui.notify(
192+
"⚠️ Sandbox extension not loaded — readonly filesystem enforcement is inactive. " +
193+
"Only tool restrictions (no edit/write) are in effect.",
194+
"warning",
195+
);
196+
}
197+
}
198+
276199
function setMode(newMode: Mode, ctx: ExtensionContext): void {
277200
if (newMode === mode) return;
278201

@@ -286,8 +209,10 @@ export default function planAskExtension(pi: ExtensionAPI) {
286209
modePrompt = "";
287210
pi.setActiveTools(originalTools.length > 0 ? originalTools : DEFAULT_AGENT_TOOLS);
288211
originalTools = [];
212+
emitReadonly(false, ctx);
289213
} else {
290214
pi.setActiveTools(RESTRICTED_TOOLS);
215+
emitReadonly(true, ctx);
291216
}
292217

293218
updateStatus(ctx);
@@ -381,20 +306,6 @@ export default function planAskExtension(pi: ExtensionAPI) {
381306
};
382307
});
383308

384-
// ── Block unsafe bash in restricted modes ────────────────────────
385-
386-
pi.on("tool_call", async (event) => {
387-
if (mode === AGENT || event.toolName !== "bash") return;
388-
389-
const command = event.input.command as string;
390-
if (!isSafeCommand(command)) {
391-
return {
392-
block: true,
393-
reason: `${MODE_DISPLAY[mode].label} mode: command blocked (not in read-only allowlist).\nCommand: ${command}`,
394-
};
395-
}
396-
});
397-
398309
// ── Filter mode-specific context messages ────────────────────────
399310

400311
pi.on("context", async (event) => {
@@ -491,6 +402,7 @@ export default function planAskExtension(pi: ExtensionAPI) {
491402

492403
if (mode !== AGENT) {
493404
pi.setActiveTools(RESTRICTED_TOOLS);
405+
emitReadonly(true, ctx);
494406
}
495407
}
496408

pi-extensions/sandbox/index.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,21 @@ function killTree(child: ReturnType<typeof spawn>) {
204204
// Status helpers
205205
// ---------------------------------------------------------------------------
206206

207-
function updateStatus(ctx: ExtensionContext, config: SandboxConfig, active: boolean) {
207+
function updateStatus(ctx: ExtensionContext, config: SandboxConfig, active: boolean, readonly = false) {
208208
if (!active) {
209209
ctx.ui.setStatus("sandbox", ctx.ui.theme.fg("warning", "🔓 Sandbox OFF"));
210210
return;
211211
}
212212

213+
if (readonly) {
214+
ctx.ui.setStatus(
215+
"sandbox",
216+
ctx.ui.theme.fg("accent", "🔒 Sandbox") +
217+
ctx.ui.theme.fg("warning", " [READONLY]"),
218+
);
219+
return;
220+
}
221+
213222
const domains = config.network?.allowedDomains?.length ?? 0;
214223
const writePaths = config.filesystem?.allowWrite?.length ?? 0;
215224
const denyRead = config.filesystem?.denyRead?.length ?? 0;
@@ -265,6 +274,10 @@ export default function sandboxExtension(pi: ExtensionAPI) {
265274
// and /sandbox on|off. Checked synchronously by user_bash.
266275
let sandboxActive = false;
267276

277+
// Readonly mode state — set by plan-ask extension via pi.events.
278+
let readonlyActive = false;
279+
let lastCtx: ExtensionContext | null = null;
280+
268281
// Sandbox readiness — awaited by the bash tool to avoid races.
269282
// Resolves to true when sandbox is active, false otherwise.
270283
let sandboxReady: Promise<boolean> = Promise.resolve(false);
@@ -337,6 +350,7 @@ export default function sandboxExtension(pi: ExtensionAPI) {
337350

338351
// ---- Initialize sandbox on session start ----
339352
pi.on("session_start", async (_event, ctx) => {
353+
lastCtx = ctx;
340354
const noSandbox = pi.getFlag("no-sandbox") as boolean;
341355

342356
if (noSandbox) {
@@ -366,8 +380,51 @@ export default function sandboxExtension(pi: ExtensionAPI) {
366380
await sandboxReady;
367381
});
368382

383+
// ---- Readonly mode (emitted by plan-ask extension) ----
384+
pi.events.on("readonly", (data: unknown) => {
385+
const { enabled, ack } = data as { enabled: boolean; ack: () => void };
386+
387+
if (!sandboxActive || !lastCtx) return;
388+
389+
ack();
390+
391+
if (enabled === readonlyActive) return;
392+
readonlyActive = enabled;
393+
394+
const ctx = lastCtx;
395+
const config = loadConfig(ctx.cwd);
396+
397+
if (enabled) {
398+
const readonlyConfig: SandboxConfig = {
399+
...config,
400+
filesystem: {
401+
...config.filesystem,
402+
allowWrite: [],
403+
},
404+
};
405+
sandboxReady = activateSandbox(readonlyConfig, ctx).then((ok) => {
406+
if (ok) {
407+
updateStatus(ctx, readonlyConfig, true, true);
408+
ctx.ui.notify("🔒 Sandbox: filesystem set to read-only", "info");
409+
}
410+
return ok;
411+
});
412+
} else {
413+
sandboxReady = activateSandbox(config, ctx).then((ok) => {
414+
if (ok) {
415+
updateStatus(ctx, config, true, false);
416+
ctx.ui.notify("🔓 Sandbox: filesystem writes restored", "info");
417+
}
418+
return ok;
419+
});
420+
readonlyActive = false;
421+
}
422+
});
423+
369424
// ---- Cleanup ----
370425
pi.on("session_shutdown", async (_event, ctx) => {
426+
readonlyActive = false;
427+
lastCtx = null;
371428
await deactivateSandbox(ctx);
372429
});
373430

pi-extensions/status-bar.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*
44
* Line 1: [profile badge] + status icon + model + context meter (left), tokens in/out/cache + cost (right)
55
* Line 2: cwd (branch ±dirty +add,-del ✨new📝mod🗑del⚡unstaged) on left, tool tally + turn on right
6+
* Line 3: sandbox status (shown only when sandbox extension is active)
67
*
78
* When PI_CODING_AGENT_DIR is set to a non-default path, a colored profile badge
89
* is shown at the start of line 1. The badge background color is deterministically
@@ -254,9 +255,13 @@ export default function statusBarExtension(pi: ExtensionAPI) {
254255
theme.fg("dim", " ");
255256

256257
const statuses = footerData.getExtensionStatuses();
258+
const sandboxStatus = statuses.get("sandbox");
259+
const otherStatuses = [...statuses.entries()]
260+
.filter(([key]) => key !== "sandbox")
261+
.map(([, val]) => val);
257262
let l1Mid = "";
258-
if (statuses.size > 0) {
259-
l1Mid = " " + [...statuses.values()].join(theme.fg("dim", " · "));
263+
if (otherStatuses.length > 0) {
264+
l1Mid = " " + otherStatuses.join(theme.fg("dim", " · "));
260265
}
261266

262267
const pad1 = " ".repeat(
@@ -339,7 +344,14 @@ export default function statusBarExtension(pi: ExtensionAPI) {
339344
);
340345
const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, "");
341346

342-
return [line1, line2];
347+
if (!sandboxStatus) return [line1, line2];
348+
349+
// --- Line 3: sandbox status (left-aligned) ---
350+
const l3Left = " " + sandboxStatus;
351+
const pad3 = " ".repeat(Math.max(1, width - visibleWidth(l3Left)));
352+
const line3 = truncateToWidth(l3Left + pad3, width, "");
353+
354+
return [line1, line2, line3];
343355
},
344356
};
345357
});

0 commit comments

Comments
 (0)