@stixxert/pi-docker-sandbox 1.1.2 → 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 +5 -0
- package/package.json +1 -1
- package/sandbox/README.md +10 -6
- package/sandbox/failure.ts +152 -14
- package/sandbox/index.ts +100 -28
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
|
+
"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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 —
|
|
133
|
-
|
|
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`) |
|
package/sandbox/failure.ts
CHANGED
|
@@ -33,20 +33,29 @@ function asText(value: Uint8Array | string | undefined): string {
|
|
|
33
33
|
/**
|
|
34
34
|
* The characteristic signatures of "the sandbox runtime could not start".
|
|
35
35
|
*
|
|
36
|
-
* Chosen to be NARROW
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Chosen to be NARROW — specifically, long enough that a *user command's* own
|
|
37
|
+
* stderr cannot plausibly collide with them. That specificity is load-bearing:
|
|
38
|
+
* a false positive REPLACES the command's real exit status with a
|
|
39
|
+
* `SandboxUnavailableError`, and the caller then reports that the command's
|
|
40
|
+
* effects are unknown — which is exactly the ambiguity that tempts an agent to
|
|
41
|
+
* re-run a side-effecting command. So every pattern anchors on the runtime's
|
|
42
|
+
* actual wrapper phrasing, never on a short phrase a project script, CI helper
|
|
43
|
+
* or test wrapper might print about its OWN docker/CI runtime:
|
|
39
44
|
*
|
|
40
|
-
* 1. `failed to start sandbox
|
|
41
|
-
*
|
|
42
|
-
* `failed to start sandbox: start runtime: request failed: 500 ...`.
|
|
45
|
+
* 1. `failed to start sandbox: start runtime:` — the CLI's own wrapper chained
|
|
46
|
+
* to the runtime start, e.g. the reproduced
|
|
47
|
+
* `failed to start sandbox: start runtime: request failed: 500 ...`. BOTH
|
|
48
|
+
* halves must appear together; the bare `failed to start sandbox` alone is
|
|
49
|
+
* the kind of line a user's own launcher could emit, so it is not used.
|
|
43
50
|
* 2. `start runtime: request failed: 5xx` — sandboxd's runtime API rejecting
|
|
44
51
|
* the start with an HTTP 5xx. Anchored on `start runtime: request failed:`
|
|
45
52
|
* rather than a bare `request failed: 500`, because the bare form could
|
|
46
53
|
* come from any command's stderr (a curl wrapper, an API client, ...) and
|
|
47
54
|
* would then misclassify an ordinary failing command.
|
|
48
|
-
* 3. `docker daemon failed to start` — the in-VM cause
|
|
49
|
-
*
|
|
55
|
+
* 3. `docker daemon failed to start inside the sandbox` — the in-VM cause, as
|
|
56
|
+
* the FULL phrase the runtime reports. The short `docker daemon failed to
|
|
57
|
+
* start` alone is NOT enough: a user command or CI wrapper that manages its
|
|
58
|
+
* own docker could print exactly that about a local daemon.
|
|
50
59
|
*
|
|
51
60
|
* Matching deliberately requires BOTH a non-zero exit AND a signature on
|
|
52
61
|
* *stderr* (diagnostics live there; stdout is the command's own output, where a
|
|
@@ -54,9 +63,9 @@ function asText(value: Uint8Array | string | undefined): string {
|
|
|
54
63
|
* A signal kill (exitCode null — our own abort/timeout) is never classified.
|
|
55
64
|
*/
|
|
56
65
|
const RUNTIME_FAILURE_SIGNATURES: readonly RegExp[] = [
|
|
57
|
-
/failed to start sandbox
|
|
66
|
+
/failed to start sandbox:\s*start runtime:/i,
|
|
58
67
|
/start runtime: request failed:\s*5\d\d\b/i,
|
|
59
|
-
/docker daemon failed to start\b/i,
|
|
68
|
+
/docker daemon failed to start inside the sandbox\b/i,
|
|
60
69
|
];
|
|
61
70
|
|
|
62
71
|
/**
|
|
@@ -89,10 +98,11 @@ export class SandboxUnavailableError extends Error {
|
|
|
89
98
|
constructor(target: string, stderr: string) {
|
|
90
99
|
const detail = stderr.trim();
|
|
91
100
|
super(
|
|
92
|
-
`sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so the
|
|
93
|
-
`
|
|
94
|
-
`The
|
|
95
|
-
|
|
101
|
+
`sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so this is not the ` +
|
|
102
|
+
`command's own exit status and the command's effects cannot be assumed to have happened.\n` +
|
|
103
|
+
`The command was not run on the host and was not retried. The VM may need recreating (check ` +
|
|
104
|
+
`\`sbx ls\`, \`sbx stop ${target}\`, or recreate it) — the next tool call will try to start the ` +
|
|
105
|
+
`sandbox again.` +
|
|
96
106
|
(detail ? `\n\n${detail}` : ""),
|
|
97
107
|
);
|
|
98
108
|
this.name = "SandboxUnavailableError";
|
|
@@ -100,3 +110,131 @@ export class SandboxUnavailableError extends Error {
|
|
|
100
110
|
this.stderr = detail;
|
|
101
111
|
}
|
|
102
112
|
}
|
|
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
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Per-episode state for the runtime-failure notification.
|
|
201
|
+
*
|
|
202
|
+
* `withSandboxFailureHandling` (see `sandbox/index.ts`) wraps every routed tool
|
|
203
|
+
* call. A runtime failure should tell the user ONCE — not once per tool call in
|
|
204
|
+
* an otherwise broken session — and then tell them AGAIN if a later call
|
|
205
|
+
* succeeds and a new, distinct outage begins.
|
|
206
|
+
*
|
|
207
|
+
* The subtlety the caller cannot express on its own: a tool call may arrive
|
|
208
|
+
* without a UI context, so a failure cannot always be surfaced. A failure that
|
|
209
|
+
* cannot be surfaced must NOT consume the episode's single notification, or an
|
|
210
|
+
* early headless failure would suppress the one notification the user needs for
|
|
211
|
+
* the whole episode. Hence `claimNotification(canNotify)`: the claim is only
|
|
212
|
+
* used up when the notification is actually deliverable.
|
|
213
|
+
*
|
|
214
|
+
* Pure and dependency-free (like the rest of this module) so the episode's
|
|
215
|
+
* behaviour can be unit-tested without the pi packages.
|
|
216
|
+
*/
|
|
217
|
+
export class SandboxFailureEpisode {
|
|
218
|
+
/** Has this episode's one notification already been delivered? */
|
|
219
|
+
private notified = false;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Claim this episode's single notification.
|
|
223
|
+
*
|
|
224
|
+
* @param canNotify Whether the caller can actually deliver a notification
|
|
225
|
+
* right now (i.e. it has a UI context). When false the claim is left
|
|
226
|
+
* untouched, so a later, deliverable failure in the same episode still
|
|
227
|
+
* notifies.
|
|
228
|
+
* @returns true only for the first *deliverable* failure of the episode.
|
|
229
|
+
*/
|
|
230
|
+
claimNotification(canNotify: boolean): boolean {
|
|
231
|
+
if (this.notified || !canNotify) return false;
|
|
232
|
+
this.notified = true;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A successful sandbox round-trip ends the episode. */
|
|
237
|
+
succeeded(): void {
|
|
238
|
+
this.notified = false;
|
|
239
|
+
}
|
|
240
|
+
}
|
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 {
|
|
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,
|
|
@@ -79,8 +85,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
79
85
|
let transport: ExecTransport | undefined;
|
|
80
86
|
let starting: Promise<ExecTransport | undefined> | undefined;
|
|
81
87
|
let lastError: string | undefined;
|
|
82
|
-
/**
|
|
83
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Notification state for the CURRENT run of runtime failures. One message per
|
|
90
|
+
* episode, reset by a successful round-trip — see `SandboxFailureEpisode`.
|
|
91
|
+
*/
|
|
92
|
+
const sandboxEpisode = new SandboxFailureEpisode();
|
|
84
93
|
|
|
85
94
|
/**
|
|
86
95
|
* Forget the memoised transport so the next tool call re-resolves it.
|
|
@@ -88,10 +97,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
88
97
|
* This is the whole recovery mechanism: `resolveTransport()` re-derives the
|
|
89
98
|
* sandbox and (re)starts the VM, so a runtime failure is an episode rather
|
|
90
99
|
* than a permanent bricking of every remaining tool call in the session.
|
|
100
|
+
*
|
|
101
|
+
* `starting` is deliberately NOT reset here. It is non-undefined only while a
|
|
102
|
+
* `resolveTransport()` is genuinely in flight, and that in-flight resolution
|
|
103
|
+
* will itself publish a fresh transport when it settles — so the next
|
|
104
|
+
* `ensureTransport()` awaits it, which is exactly the recovery we want.
|
|
105
|
+
* Clearing it here would instead let a concurrent caller launch a SECOND
|
|
106
|
+
* resolution, and each resolution can boot/create a VM. The resulting race is
|
|
107
|
+
* benign today only because every resolution targets the same per-project
|
|
108
|
+
* sandbox name and therefore reuses one VM; there is no reason to open it up.
|
|
91
109
|
*/
|
|
92
110
|
function invalidateTransport(): void {
|
|
93
111
|
transport = undefined;
|
|
94
|
-
|
|
112
|
+
}
|
|
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;
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
/**
|
|
@@ -110,18 +141,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
110
141
|
): Promise<T> {
|
|
111
142
|
try {
|
|
112
143
|
const result = await run();
|
|
113
|
-
|
|
144
|
+
sandboxEpisode.succeeded(); // a success ends the episode
|
|
114
145
|
return result;
|
|
115
146
|
} catch (err) {
|
|
116
147
|
if (!(err instanceof SandboxUnavailableError)) throw err;
|
|
117
148
|
invalidateTransport();
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
// Tell the user ONCE per episode. The claim is only taken when a
|
|
150
|
+
// notification is actually deliverable (`ctx` present) — a failure we
|
|
151
|
+
// cannot surface must not swallow the episode's one message.
|
|
152
|
+
if (sandboxEpisode.claimNotification(ctx !== undefined)) {
|
|
120
153
|
ctx?.ui.notify(
|
|
121
|
-
`sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this
|
|
122
|
-
`
|
|
123
|
-
`to
|
|
124
|
-
`
|
|
154
|
+
`sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this ` +
|
|
155
|
+
`result is not the command's own exit status and the command's effects cannot be assumed ` +
|
|
156
|
+
`to have happened. The command was NOT run on the host and was not retried. The next tool ` +
|
|
157
|
+
`call will try to start the sandbox again; if it keeps failing, the VM may need recreating ` +
|
|
158
|
+
`(\`sbx ls\`, then \`sbx stop ${err.target}\`).`,
|
|
125
159
|
"error",
|
|
126
160
|
);
|
|
127
161
|
}
|
|
@@ -130,10 +164,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
/**
|
|
133
|
-
* Resolve (and memoize) the transport. Never throws: if sbx is missing or
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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.
|
|
137
174
|
*/
|
|
138
175
|
async function ensureTransport(ctx?: ExtensionContext): Promise<ExecTransport | undefined> {
|
|
139
176
|
if (transport) return transport;
|
|
@@ -155,7 +192,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
155
192
|
} catch (err) {
|
|
156
193
|
lastError = err instanceof Error ? err.message : String(err);
|
|
157
194
|
ctx?.ui.setStatus("sbx", ctx.ui.theme.fg("error", "sbx: unavailable"));
|
|
158
|
-
ctx?.ui.notify(
|
|
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
|
+
);
|
|
159
202
|
return undefined;
|
|
160
203
|
} finally {
|
|
161
204
|
starting = undefined;
|
|
@@ -166,7 +209,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
166
209
|
}
|
|
167
210
|
|
|
168
211
|
/**
|
|
169
|
-
* Route a tool to the sandbox,
|
|
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
|
+
*
|
|
170
215
|
* `ctx` is forwarded — the built-ins use it to inject PI_* session metadata
|
|
171
216
|
* into the bash environment, and dropping it would silently change behaviour.
|
|
172
217
|
*/
|
|
@@ -178,7 +223,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
223
|
...local,
|
|
179
224
|
async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
|
|
180
225
|
const t = await ensureTransport(ctx);
|
|
181
|
-
if (!t)
|
|
226
|
+
if (!t) {
|
|
227
|
+
assertLocalFallbackAllowed();
|
|
228
|
+
return (local.execute as Function)(id, params, signal, onUpdate, ctx);
|
|
229
|
+
}
|
|
182
230
|
return withSandboxFailureHandling(ctx, () =>
|
|
183
231
|
(build(t).execute as Function)(id, params, signal, onUpdate, ctx),
|
|
184
232
|
);
|
|
@@ -210,8 +258,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
258
|
.then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
|
|
211
259
|
.catch((err) => {
|
|
212
260
|
// Raw console writes land on the terminal the TUI is drawing, so cap
|
|
213
|
-
// the failure note behind the debug flag —
|
|
214
|
-
//
|
|
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).
|
|
215
264
|
if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
216
265
|
});
|
|
217
266
|
});
|
|
@@ -236,7 +285,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
285
|
"",
|
|
237
286
|
"Tools routed into the sandbox: bash, read, write, edit, grep, find, ls",
|
|
238
287
|
].join("\n")
|
|
239
|
-
: `sbx backend unavailable —
|
|
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 ?? ""}`,
|
|
240
293
|
t ? "info" : "warning",
|
|
241
294
|
);
|
|
242
295
|
},
|
|
@@ -256,26 +309,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
256
309
|
...localGrep,
|
|
257
310
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
258
311
|
const t = await ensureTransport(ctx);
|
|
259
|
-
if (!t)
|
|
312
|
+
if (!t) {
|
|
313
|
+
assertLocalFallbackAllowed();
|
|
314
|
+
return localGrep.execute(id, params, signal, onUpdate, ctx);
|
|
315
|
+
}
|
|
260
316
|
return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
|
|
261
317
|
},
|
|
262
318
|
});
|
|
263
319
|
|
|
264
320
|
// The user's own `!` commands belong in the sandbox too, exactly as gondolin
|
|
265
|
-
// 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.
|
|
266
324
|
pi.on("user_bash", async (_event, ctx) => {
|
|
267
325
|
const t = await ensureTransport(ctx);
|
|
268
|
-
if (!t)
|
|
326
|
+
if (!t) {
|
|
327
|
+
assertLocalFallbackAllowed();
|
|
328
|
+
return undefined; // opt-in set: run on the host, as explicitly requested
|
|
329
|
+
}
|
|
269
330
|
return { operations: createBashOps(t, { allowEnv: bashAllowEnv }) };
|
|
270
331
|
});
|
|
271
332
|
|
|
272
333
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
273
334
|
const t = await ensureTransport(ctx);
|
|
274
335
|
const localLine = `Current working directory: ${localCwd}`;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
279
351
|
const systemPrompt = event.systemPrompt.includes(localLine)
|
|
280
352
|
? event.systemPrompt.replace(localLine, replacement)
|
|
281
353
|
: `${event.systemPrompt}\n\n${replacement}`;
|