@stixxert/pi-docker-sandbox 1.1.3 → 1.1.4

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/README.md CHANGED
@@ -189,6 +189,11 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
189
189
  extension runs inside the pi process, so stray console output would land on
190
190
  the same terminal the TUI is drawing and corrupt the chat. Turn it on when
191
191
  running `pi -p`, in a plain shell, or when diagnosing lifecycle issues.
192
+ - `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` — allow tools to run directly on the
193
+ **host** when no sandbox can be resolved. **Off by default**: a missing `sbx`
194
+ CLI or an unstartable VM makes tool calls **fail closed** (refused with an
195
+ actionable error) rather than silently executing on the host, which is a
196
+ sandbox escape. Set this only if you accept unsandboxed execution.
192
197
 
193
198
  ## Ports (verified rules)
194
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
package/sandbox/README.md CHANGED
@@ -87,10 +87,12 @@ entry; over `sbx exec` that would be N+3 sandbox round-trips per listing. A
87
87
  single POSIX-sh pass returns `d`/`f` + name and is memoised for the duration
88
88
  of that one tool call (verified: 25 entries => ≤ 4 round-trips).
89
89
 
90
- **It degrades instead of breaking.** If `sbx` is missing or the sandbox
91
- cannot be provisioned, the tools fall back to local execution, the user is
92
- notified, and the system prompt says so explicitly the agent is never led
93
- to believe it is sandboxed when it is not.
90
+ **It degrades instead of breaking.** If `sbx` is missing or the sandbox cannot
91
+ be provisioned, the tools **fail closed** rather than silently running on the
92
+ host: the call is refused with an actionable error naming the cause and the
93
+ opt-in, the user is notified, and the system prompt says so explicitly — the
94
+ agent is never led to believe it is sandboxed when it is not. Running directly
95
+ on the host requires an explicit `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1`.
94
96
 
95
97
  ## Trying it out (before publishing)
96
98
 
@@ -129,8 +131,9 @@ bash sandbox/try.sh --docker -- -p --tools read "Read /opt/only-in-sandbox.txt"
129
131
 
130
132
  If the first reports the container's OS and the second returns the file, the
131
133
  routing works. If the extension failed to load you get a missing-tool error
132
- instead — never a silent fallback to the host (that case is reported in the
133
- system prompt and via `/sbx`).
134
+ instead — and if no sandbox can be resolved, tool calls are **refused** by
135
+ default rather than falling back to the host (the refusal and its opt-in are
136
+ reported in the system prompt and via `/sbx`).
134
137
 
135
138
  ### On the host, with real sbx
136
139
 
@@ -171,6 +174,7 @@ edits go through pi's own tools.
171
174
  | `DOCKER_SANDBOX_KEEPALIVE` | **default `1` here** — keeps the VM running for the life of the pi process; set `0` to allow idle-stop |
172
175
  | `DOCKER_SANDBOX_ENV_ALLOWLIST` | additionally export these host vars into the sandbox shell (default: `PI_*` only) |
173
176
  | `DOCKER_SANDBOX` | pin the sandbox name (also disables per-project derivation) |
177
+ | `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` | **fail-closed default override** — permit tools to run directly on the host when no sandbox can be resolved (default: refuse) |
174
178
  | `SBX_EPHEMERAL` | `1` = throwaway per-session sandbox, removed at exit |
175
179
  | `SBX_PI_DEBUG` | `1` = log per-phase startup timings to stderr |
176
180
  | `DOCKER_SANDBOX_TEARDOWN` | `remove` / `stop` / `none` (a per-project sandbox defaults to `none`) |
@@ -111,6 +111,91 @@ export class SandboxUnavailableError extends Error {
111
111
  }
112
112
  }
113
113
 
114
+ /* ------------------------------------------------------------------ */
115
+ /* fail-closed policy when NO sandbox can be resolved at all */
116
+ /* ------------------------------------------------------------------ */
117
+
118
+ /**
119
+ * The opt-in that permits running tools directly on the HOST when no sandbox
120
+ * can be resolved.
121
+ *
122
+ * The default is to REFUSE (fail closed): an unresolvable sandbox must never
123
+ * mean "silently run on the host" — that is a sandbox escape, and the host is
124
+ * not the execution environment. Named with the repo's own `DOCKER_SANDBOX_*`
125
+ * prefix and deliberately NOT tied to any launcher (e.g. pidock's separate
126
+ * `PIDOCK_ALLOW_UNSANDBOXED`), so the backend stays usable standalone.
127
+ */
128
+ export const UNSANDBOXED_OPT_IN_ENV = "DOCKER_SANDBOX_ALLOW_UNSANDBOXED";
129
+
130
+ /**
131
+ * True when the user has explicitly opted into unsandboxed operation via
132
+ * `UNSANDBOXED_OPT_IN_ENV`.
133
+ *
134
+ * Accepts `1`, `true`, `yes`, `on` (case-insensitive, surrounding whitespace
135
+ * ignored) — the same boolean vocabulary as the repo's other knobs
136
+ * (`DOCKER_SANDBOX_DEBUG`, `DOCKER_SANDBOX_ENV_PASSTHROUGH`, ...). Anything
137
+ * else, including unset and `0`/`false`/`no`/`off`, leaves the secure default:
138
+ * refuse.
139
+ */
140
+ export function unsandboxedAllowed(env: Readonly<Record<string, string | undefined>>): boolean {
141
+ return /^(1|true|yes|on)$/i.test((env[UNSANDBOXED_OPT_IN_ENV] ?? "").trim());
142
+ }
143
+
144
+ /**
145
+ * The typed error for a tool call REFUSED because no sandbox could be resolved
146
+ * and the host fallback is disabled.
147
+ *
148
+ * Distinct from `SandboxUnavailableError`: that one is a sandbox that existed
149
+ * and failed mid-round-trip (transient — the next call re-resolves and may
150
+ * recover). This one means there was never a sandbox to fail in, and the
151
+ * fail-closed policy has refused to run the tool anywhere. It is actionable,
152
+ * not a stack trace: it names the cause, states in as many words that the
153
+ * command was NOT run on the host, and names the exact variable that would opt
154
+ * into unsandboxed operation.
155
+ */
156
+ export class SandboxRequiredError extends Error {
157
+ /** The resolution failure that led here (may be empty). */
158
+ readonly failure: string;
159
+
160
+ constructor(failure?: string) {
161
+ const detail = (failure ?? "").trim();
162
+ super(
163
+ `sbx sandbox unavailable: ${
164
+ detail ||
165
+ "no sandbox transport could be resolved (is the `sbx` CLI installed and the VM runnable?)"
166
+ }.\n` +
167
+ `This tool was NOT run: it did not execute in the sandbox, and it was NOT run on the host either.\n` +
168
+ `Refusing to run unsandboxed by default. To allow tools to run directly on the host instead, set ` +
169
+ `${UNSANDBOXED_OPT_IN_ENV}=1 and retry.`,
170
+ );
171
+ this.name = "SandboxRequiredError";
172
+ this.failure = detail;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * The fail-closed policy for a resolution failure: from an environment snapshot
178
+ * plus the resolution failure, decide whether the caller may fall back to the
179
+ * LOCAL (host) tool or must refuse.
180
+ *
181
+ * `{ allow: true }` only when the user has explicitly opted in with
182
+ * `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` (see `unsandboxedAllowed`). Otherwise the
183
+ * decision carries a `SandboxRequiredError` whose message names the cause, says
184
+ * the command was not run on the host, and names the opt-in variable.
185
+ *
186
+ * Pure and dependency-free (like the rest of this module) so the policy can be
187
+ * unit-tested without the pi packages.
188
+ */
189
+ export type LocalFallbackDecision = { allow: true } | { allow: false; error: SandboxRequiredError };
190
+
191
+ export function decideLocalFallback(
192
+ env: Readonly<Record<string, string | undefined>>,
193
+ failure?: string,
194
+ ): LocalFallbackDecision {
195
+ if (unsandboxedAllowed(env)) return { allow: true };
196
+ return { allow: false, error: new SandboxRequiredError(failure) };
197
+ }
198
+
114
199
  /**
115
200
  * Per-episode state for the runtime-failure notification.
116
201
  *
package/sandbox/index.ts CHANGED
@@ -41,7 +41,13 @@ import {
41
41
  createWriteToolDefinition,
42
42
  } from "@earendil-works/pi-coding-agent";
43
43
  import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
44
- import { SandboxFailureEpisode, SandboxUnavailableError } from "./failure.ts";
44
+ import {
45
+ SandboxFailureEpisode,
46
+ SandboxUnavailableError,
47
+ UNSANDBOXED_OPT_IN_ENV,
48
+ decideLocalFallback,
49
+ unsandboxedAllowed,
50
+ } from "./failure.ts";
45
51
  import {
46
52
  createBashOps,
47
53
  createEditOps,
@@ -105,6 +111,20 @@ export default function (pi: ExtensionAPI) {
105
111
  transport = undefined;
106
112
  }
107
113
 
114
+ /**
115
+ * The fail-closed gate for "there is no transport at all".
116
+ *
117
+ * Returns normally ONLY when the user has explicitly opted into unsandboxed
118
+ * operation (`DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1`); otherwise throws the
119
+ * typed `SandboxRequiredError`, so the tool call is REFUSED instead of being
120
+ * silently executed on the host. The decision itself lives in `failure.ts`,
121
+ * which is import-free and unit-tested.
122
+ */
123
+ function assertLocalFallbackAllowed(): void {
124
+ const decision = decideLocalFallback(process.env, lastError);
125
+ if (decision.allow === false) throw decision.error;
126
+ }
127
+
108
128
  /**
109
129
  * Run one sandbox round-trip, turning a dead sandbox into: invalidate +
110
130
  * notify (ONCE per episode) + rethrow.
@@ -144,10 +164,13 @@ export default function (pi: ExtensionAPI) {
144
164
  }
145
165
 
146
166
  /**
147
- * Resolve (and memoize) the transport. Never throws: if sbx is missing or
148
- * the sandbox cannot be provisioned, pi keeps working with its LOCAL tools
149
- * and the degradation is reported — both to the user and in the system
150
- * prompt, so the agent never believes it is sandboxed when it is not.
167
+ * Resolve (and memoize) the transport. Never throws: if sbx is missing or the
168
+ * sandbox cannot be provisioned, `undefined` is returned and the degradation
169
+ * is reported — both to the user and in the system prompt. The CALLER of a
170
+ * tool call then decides what to do (see `assertLocalFallbackAllowed`): by
171
+ * default it REFUSES, so the host is never silently used as the execution
172
+ * environment; only an explicit `DOCKER_SANDBOX_ALLOW_UNSANDBOXED=1` opt-in
173
+ * permits the local fallback.
151
174
  */
152
175
  async function ensureTransport(ctx?: ExtensionContext): Promise<ExecTransport | undefined> {
153
176
  if (transport) return transport;
@@ -169,7 +192,13 @@ export default function (pi: ExtensionAPI) {
169
192
  } catch (err) {
170
193
  lastError = err instanceof Error ? err.message : String(err);
171
194
  ctx?.ui.setStatus("sbx", ctx.ui.theme.fg("error", "sbx: unavailable"));
172
- ctx?.ui.notify(`sbx backend unavailable — running tools locally.\n${lastError}`, "warning");
195
+ ctx?.ui.notify(
196
+ unsandboxedAllowed(process.env)
197
+ ? `sbx backend unavailable — ${UNSANDBOXED_OPT_IN_ENV}=1, so tools run directly on the host.\n${lastError}`
198
+ : `sbx backend unavailable — refusing tool calls; nothing will run on the host.\n` +
199
+ `Set ${UNSANDBOXED_OPT_IN_ENV}=1 to run tools directly on the host instead.\n${lastError}`,
200
+ "warning",
201
+ );
173
202
  return undefined;
174
203
  } finally {
175
204
  starting = undefined;
@@ -180,7 +209,9 @@ export default function (pi: ExtensionAPI) {
180
209
  }
181
210
 
182
211
  /**
183
- * Route a tool to the sandbox, falling back to the local tool on failure.
212
+ * Route a tool to the sandbox, refusing to run it on the host when no sandbox
213
+ * can be had (fail closed) unless the user opted in.
214
+ *
184
215
  * `ctx` is forwarded — the built-ins use it to inject PI_* session metadata
185
216
  * into the bash environment, and dropping it would silently change behaviour.
186
217
  */
@@ -192,7 +223,10 @@ export default function (pi: ExtensionAPI) {
192
223
  ...local,
193
224
  async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
194
225
  const t = await ensureTransport(ctx);
195
- if (!t) return (local.execute as Function)(id, params, signal, onUpdate, ctx);
226
+ if (!t) {
227
+ assertLocalFallbackAllowed();
228
+ return (local.execute as Function)(id, params, signal, onUpdate, ctx);
229
+ }
196
230
  return withSandboxFailureHandling(ctx, () =>
197
231
  (build(t).execute as Function)(id, params, signal, onUpdate, ctx),
198
232
  );
@@ -224,8 +258,9 @@ export default function (pi: ExtensionAPI) {
224
258
  .then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
225
259
  .catch((err) => {
226
260
  // Raw console writes land on the terminal the TUI is drawing, so cap
227
- // the failure note behind the debug flag — the backend degrades to
228
- // local tools either way (the `sbx` command reports live status).
261
+ // the failure note behind the debug flag — tool calls refuse by default
262
+ // or run on the host only under the explicit opt-in (the `sbx` command
263
+ // reports live status).
229
264
  if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
230
265
  });
231
266
  });
@@ -250,7 +285,11 @@ export default function (pi: ExtensionAPI) {
250
285
  "",
251
286
  "Tools routed into the sandbox: bash, read, write, edit, grep, find, ls",
252
287
  ].join("\n")
253
- : `sbx backend unavailable — tools run locally.\n${lastError ?? ""}`,
288
+ : `sbx backend unavailable — ${
289
+ unsandboxedAllowed(process.env)
290
+ ? `tools run directly on the host (${UNSANDBOXED_OPT_IN_ENV}=1)`
291
+ : `tool calls are refused; nothing runs on the host (set ${UNSANDBOXED_OPT_IN_ENV}=1 to allow unsandboxed execution)`
292
+ }.\n${lastError ?? ""}`,
254
293
  t ? "info" : "warning",
255
294
  );
256
295
  },
@@ -270,26 +309,45 @@ export default function (pi: ExtensionAPI) {
270
309
  ...localGrep,
271
310
  async execute(id, params, signal, onUpdate, ctx) {
272
311
  const t = await ensureTransport(ctx);
273
- if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
312
+ if (!t) {
313
+ assertLocalFallbackAllowed();
314
+ return localGrep.execute(id, params, signal, onUpdate, ctx);
315
+ }
274
316
  return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
275
317
  },
276
318
  });
277
319
 
278
320
  // The user's own `!` commands belong in the sandbox too, exactly as gondolin
279
- // routes them — otherwise `!` would silently execute on the host.
321
+ // routes them — otherwise `!` would silently execute on the host. When no
322
+ // sandbox can be had, the same fail-closed policy applies: refuse (throw)
323
+ // unless the user opted into unsandboxed operation.
280
324
  pi.on("user_bash", async (_event, ctx) => {
281
325
  const t = await ensureTransport(ctx);
282
- if (!t) return undefined;
326
+ if (!t) {
327
+ assertLocalFallbackAllowed();
328
+ return undefined; // opt-in set: run on the host, as explicitly requested
329
+ }
283
330
  return { operations: createBashOps(t, { allowEnv: bashAllowEnv }) };
284
331
  });
285
332
 
286
333
  pi.on("before_agent_start", async (event, ctx) => {
287
334
  const t = await ensureTransport(ctx);
288
335
  const localLine = `Current working directory: ${localCwd}`;
289
- const replacement = t
290
- ? `Current working directory: ${localCwd} — commands run inside the ${t.kind} sandbox "${t.target}" ` +
291
- `(the same absolute paths exist there; the host is not the execution environment)`
292
- : `${localLine} (WARNING: the sbx sandbox is unavailable, so commands run directly on the host)`;
336
+ let replacement: string;
337
+ if (t) {
338
+ replacement =
339
+ `Current working directory: ${localCwd} commands run inside the ${t.kind} sandbox "${t.target}" ` +
340
+ `(the same absolute paths exist there; the host is not the execution environment)`;
341
+ } else if (unsandboxedAllowed(process.env)) {
342
+ replacement =
343
+ `${localLine} (WARNING: the sbx sandbox is unavailable and ${UNSANDBOXED_OPT_IN_ENV} is set, ` +
344
+ `so commands run directly on the host — not in the sandbox)`;
345
+ } else {
346
+ replacement =
347
+ `${localLine} (WARNING: the sbx sandbox is unavailable and ${UNSANDBOXED_OPT_IN_ENV} is not set, ` +
348
+ `so tool calls are REFUSED and will NOT run — neither in the sandbox nor on the host. ` +
349
+ `Set ${UNSANDBOXED_OPT_IN_ENV}=1 to run tools directly on the host instead.)`;
350
+ }
293
351
  const systemPrompt = event.systemPrompt.includes(localLine)
294
352
  ? event.systemPrompt.replace(localLine, replacement)
295
353
  : `${event.systemPrompt}\n\n${replacement}`;