@yagni-app/code 1.0.6 → 1.0.8
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 +91 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +19 -0
- package/dist/extension/index.js +248 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +101 -2
- package/dist/extension/permission/gate.js +485 -42
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +64 -0
- package/dist/extension/permissionRules/loadConfig.js +105 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +60 -0
- package/dist/extension/permissionRules/pathRules.js +122 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +115 -0
- package/dist/extension/sandbox/config.js +388 -0
- package/dist/extension/sandbox/manager.d.ts +108 -0
- package/dist/extension/sandbox/manager.js +243 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +829 -0
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +103 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +47 -0
- package/dist/extension/telemetry/register.js +207 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.js +10 -1
- package/package.json +19 -3
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox session wiring: owns the per-session YagniSandboxManager
|
|
3
|
+
* and hooks it into pi — bash tool re-registration with the sandbox
|
|
4
|
+
* spawnHook, user_bash (! commands) routing, session lifecycle, and the
|
|
5
|
+
* network ask flow (interactive prompt in TUI/RPC, fail-closed in
|
|
6
|
+
* print/headless).
|
|
7
|
+
*
|
|
8
|
+
* Registration model: the sandbox re-registers the bash tool ONLY when
|
|
9
|
+
* sandbox.enabled is true in the loaded settings — default-off sessions keep
|
|
10
|
+
* pi's stock bash byte-identical (a duplicate tool name is a hard 400 on
|
|
11
|
+
* some providers). /sandbox (M5) toggles by rewriting settings and asking
|
|
12
|
+
* for a session restart, the same way Claude persists the toggle.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
19
|
+
import { createBashToolDefinition, getShellConfig, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { logEvent } from "../errorSink.js";
|
|
21
|
+
import { codeStateHome } from "../stateHome.js";
|
|
22
|
+
import { isDebug } from "../diagnostics.js";
|
|
23
|
+
import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
|
|
24
|
+
import { loadSandboxSettings } from "./config.js";
|
|
25
|
+
import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
26
|
+
import { YagniSandboxManager } from "./manager.js";
|
|
27
|
+
import { SandboxPanel, buildPanelState } from "./panel.js";
|
|
28
|
+
import { effectiveRules } from "../permissionRules/loadConfig.js";
|
|
29
|
+
/**
|
|
30
|
+
* Build the sandbox bash composition: given ANY stock bash definition (pi's
|
|
31
|
+
* own or condensedTools'), return the sandbox-aware version — schema gains
|
|
32
|
+
* dangerouslyDisableSandbox, execute wraps via the manager when the decision
|
|
33
|
+
* says so, and EPERM output is annotated. Identity when the sandbox is
|
|
34
|
+
* disabled at load time (default-off sessions stay byte-identical).
|
|
35
|
+
*
|
|
36
|
+
* Exported so condensedTools consumes it through its wrapBash seam — the
|
|
37
|
+
* M4 e2e lesson: condensed registers bash LAST, so a competing registration
|
|
38
|
+
* here would be overwritten and the sandbox would silently never reach
|
|
39
|
+
* model-driven tool calls.
|
|
40
|
+
*/
|
|
41
|
+
export function makeBashComposition(manager, settings, cwd) {
|
|
42
|
+
return (def) => {
|
|
43
|
+
if (!settings().enabled)
|
|
44
|
+
return def;
|
|
45
|
+
const userShellPath = SettingsManager.create(cwd).getShellPath();
|
|
46
|
+
const { shell: binShell } = getShellConfig(userShellPath);
|
|
47
|
+
const spawnHook = makeSandboxSpawnHook(manager);
|
|
48
|
+
const sandboxBash = createBashToolDefinition(cwd, {
|
|
49
|
+
shellPath: userShellPath,
|
|
50
|
+
spawnHook,
|
|
51
|
+
});
|
|
52
|
+
const schema = Type.Object({
|
|
53
|
+
command: Type.String({ description: "The bash command to execute" }),
|
|
54
|
+
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
55
|
+
dangerouslyDisableSandbox: Type.Optional(Type.Boolean({
|
|
56
|
+
description: "Set true ONLY when you have evidence this exact command failed due to a sandbox restriction " +
|
|
57
|
+
'("Operation not permitted", a denied path/host, unix-socket errors). The retry still goes ' +
|
|
58
|
+
"through the normal permission flow. Default false; prefer working within the sandbox.",
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
61
|
+
// Claude's getSimpleSandboxSection pattern: the model sees the RESOLVED
|
|
62
|
+
// restrictions inline, plus the retry protocol. Without this the model
|
|
63
|
+
// cannot distinguish sandbox denials from ordinary failures.
|
|
64
|
+
const s = settings();
|
|
65
|
+
const restrictions = [];
|
|
66
|
+
const allowed = s.network?.allowedDomains ?? [];
|
|
67
|
+
restrictions.push(allowed.length > 0
|
|
68
|
+
? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
|
|
69
|
+
: "Network: no domains pre-allowed — the first contact to each host prompts the user");
|
|
70
|
+
restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
|
|
71
|
+
if (s.filesystem?.denyWrite?.length)
|
|
72
|
+
restrictions.push(`Denied writes: ${s.filesystem.denyWrite.join(", ")}`);
|
|
73
|
+
const strictNote = s.allowUnsandboxedCommands === false
|
|
74
|
+
? "All commands MUST run sandboxed — dangerouslyDisableSandbox is disabled by policy."
|
|
75
|
+
: "Default to running commands in the sandbox. Set dangerouslyDisableSandbox: true ONLY with evidence of a sandbox-caused failure (\"Operation not permitted\", denied path/host, unix-socket errors) — the retry still goes through the normal permission flow. The user can adjust restrictions with /sandbox.";
|
|
76
|
+
const wrapped = {
|
|
77
|
+
...def,
|
|
78
|
+
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}`,
|
|
79
|
+
parameters: schema,
|
|
80
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
81
|
+
const input = params;
|
|
82
|
+
const s = settings();
|
|
83
|
+
const useSandbox = shouldUseSandbox(input, manager, s);
|
|
84
|
+
logEvent({
|
|
85
|
+
source: "sandbox",
|
|
86
|
+
level: isDebug() ? "debug" : "info",
|
|
87
|
+
event: "tool_execute_decision",
|
|
88
|
+
fields: { useSandbox, initialized: manager.initialized, hasCommand: Boolean(input.command) },
|
|
89
|
+
});
|
|
90
|
+
if (!useSandbox) {
|
|
91
|
+
return def.execute(id, params, signal, onUpdate, ctx);
|
|
92
|
+
}
|
|
93
|
+
const execParams = { ...input, command: await preWrappedCommand(manager, input.command, binShell) };
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await sandboxBash.execute(id, execParams, signal, onUpdate, ctx);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof Error && err.message.includes("Operation not permitted")) {
|
|
100
|
+
const annotated = annotateCommandOutput(manager, input.command, err.message);
|
|
101
|
+
throw new Error(annotated);
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
manager.cleanupAfterCommand();
|
|
107
|
+
}
|
|
108
|
+
if (result?.content) {
|
|
109
|
+
const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
110
|
+
if (text.includes("Operation not permitted")) {
|
|
111
|
+
const annotated = annotateCommandOutput(manager, input.command, text);
|
|
112
|
+
result = { ...result, content: [{ type: "text", text: annotated }] };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return wrapped;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Register the sandbox surfaces on the ExtensionAPI. Returns the session
|
|
123
|
+
* handle (manager + accessors) or null when the platform can never run the
|
|
124
|
+
* sandbox (no-op registration, stock bash stays).
|
|
125
|
+
*/
|
|
126
|
+
export function registerSandbox(pi, opts) {
|
|
127
|
+
const platform = process.platform;
|
|
128
|
+
if (platform !== "darwin" && platform !== "linux")
|
|
129
|
+
return null;
|
|
130
|
+
const load = () => loadSandboxSettings({
|
|
131
|
+
cwd: opts.cwd,
|
|
132
|
+
env: opts.env,
|
|
133
|
+
userHome: opts.userHome,
|
|
134
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
135
|
+
}).settings;
|
|
136
|
+
let currentSettings = load();
|
|
137
|
+
let rules = [];
|
|
138
|
+
// Optional-chained: some harnesses/mocks don't implement flag APIs; the
|
|
139
|
+
// flag simply reads as unset there. Real pi registers it normally.
|
|
140
|
+
pi.registerFlag?.("no-sandbox", {
|
|
141
|
+
description: "Disable OS-level sandboxing for bash commands (this session)",
|
|
142
|
+
type: "boolean",
|
|
143
|
+
default: false,
|
|
144
|
+
});
|
|
145
|
+
// Read LAZILY (per session_start), never at registration: pi applies
|
|
146
|
+
// extension flag values AFTER extension load (applyExtensionFlagValues in
|
|
147
|
+
// agent-session-services), so an eager read at registration time always
|
|
148
|
+
// sees unset — the flag would be silently dead.
|
|
149
|
+
const noSandboxFlag = () => pi.getFlag?.("no-sandbox") === true;
|
|
150
|
+
/** Session-level toggle state: null = follow config; false = forced off. */
|
|
151
|
+
let sessionToggle = null;
|
|
152
|
+
// The session's RESOLVED state home (override-aware) — every persist
|
|
153
|
+
// writes here, never a raw codeStateHome(process.env) that would ignore
|
|
154
|
+
// isolation (the test-leak lesson).
|
|
155
|
+
const resolvedStateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
|
|
156
|
+
const manager = opts.makeManager
|
|
157
|
+
? opts.makeManager({
|
|
158
|
+
cwd: opts.cwd,
|
|
159
|
+
env: opts.env,
|
|
160
|
+
userHome: opts.userHome,
|
|
161
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
162
|
+
projectRoot: opts.projectRoot,
|
|
163
|
+
rgPath: opts.rgPath,
|
|
164
|
+
})
|
|
165
|
+
: new YagniSandboxManager({
|
|
166
|
+
cwd: opts.cwd,
|
|
167
|
+
env: opts.env,
|
|
168
|
+
userHome: opts.userHome,
|
|
169
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
170
|
+
projectRoot: opts.projectRoot,
|
|
171
|
+
rgPath: opts.rgPath,
|
|
172
|
+
});
|
|
173
|
+
// The network ask fires from INSIDE a bash execution (srt's proxy at
|
|
174
|
+
// connect time) — no event ctx in scope. The UI bridge is captured from
|
|
175
|
+
// the most recent tool_call / session_start ctx (same bridge all session)
|
|
176
|
+
// and held here; RPC dialogs and TUI prompts both work through it.
|
|
177
|
+
let uiBridge = null;
|
|
178
|
+
const captureUi = (ctx) => {
|
|
179
|
+
if (ctx?.ui)
|
|
180
|
+
uiBridge = ctx;
|
|
181
|
+
};
|
|
182
|
+
// Trust snapshot for the sandbox rule merge (D3): an untrusted repo's
|
|
183
|
+
// committed allow rules (project OR local) must not widen the OS-level
|
|
184
|
+
// allowWrite/deniedDomains sets — the same filtering the permission gate
|
|
185
|
+
// applies. Captured from the latest ctx; defaults trusted (parity with
|
|
186
|
+
// the gate's fail-open posture on a missing isProjectTrusted).
|
|
187
|
+
let trustedSnapshot = true;
|
|
188
|
+
const captureTrust = (ctx) => {
|
|
189
|
+
try {
|
|
190
|
+
if (typeof ctx?.isProjectTrusted === "function")
|
|
191
|
+
trustedSnapshot = ctx.isProjectTrusted();
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Keep the prior snapshot (fail-open parity with the gate) but make
|
|
195
|
+
// the throw VISIBLE: warn (not debug) — readSessionTrail filters debug
|
|
196
|
+
// lines, so a debug event would never reach /feedback; a trust-probe
|
|
197
|
+
// failure silently pinning the wrong posture must be surfacable.
|
|
198
|
+
// Scrub-safe: error class only, no thrown message.
|
|
199
|
+
logEvent({
|
|
200
|
+
source: "sandbox",
|
|
201
|
+
level: "warn",
|
|
202
|
+
event: "trust_probe_failed",
|
|
203
|
+
fields: { error: err instanceof Error ? err.constructor.name : typeof err },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
/** Rules filtered to what the sandbox may enforce OS-wide (trust-aware). */
|
|
208
|
+
const effectiveRulesForSandbox = () => effectiveRules(rules, trustedSnapshot);
|
|
209
|
+
const sessionDomainGrants = new Set();
|
|
210
|
+
const askHandler = async (host) => {
|
|
211
|
+
if (sessionDomainGrants.has(host))
|
|
212
|
+
return true;
|
|
213
|
+
const ui = uiBridge;
|
|
214
|
+
if (!ui?.hasUI)
|
|
215
|
+
return false; // headless: fail closed (never grant silently)
|
|
216
|
+
if (ui.signal?.aborted)
|
|
217
|
+
return false;
|
|
218
|
+
// Claude-parity grant options. Session grant is memory-only and
|
|
219
|
+
// agent-invisible; project/user write the sandbox config file.
|
|
220
|
+
const dialogOpts = {
|
|
221
|
+
title: `Sandbox: allow network access to ${host}?`,
|
|
222
|
+
timeout: 600_000,
|
|
223
|
+
...(ui.signal ? { signal: ui.signal } : {}),
|
|
224
|
+
};
|
|
225
|
+
let choice;
|
|
226
|
+
try {
|
|
227
|
+
choice = await ui.ui.select(`Sandbox: allow network access to ${host}?`, [
|
|
228
|
+
"Allow for this session",
|
|
229
|
+
`Allow for this project (.yagni-code/config.json)`,
|
|
230
|
+
`Allow for all projects (~/.yagni-code/config.json)`,
|
|
231
|
+
"Block",
|
|
232
|
+
], dialogOpts);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false; // dialog error / timeout: never grant
|
|
236
|
+
}
|
|
237
|
+
if (choice === undefined || choice === "Block")
|
|
238
|
+
return false;
|
|
239
|
+
sessionDomainGrants.add(host);
|
|
240
|
+
if (choice.startsWith("Allow for this project")) {
|
|
241
|
+
try {
|
|
242
|
+
persistDomainGrant(opts.projectRoot ?? opts.cwd, host, resolvedStateHome);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
// fail-soft: the session grant already holds; the persistence just
|
|
246
|
+
// did not — the user chose it, so surface it (notify + sink, same
|
|
247
|
+
// posture as persistToggleAndNotify) rather than stay silent.
|
|
248
|
+
logGrantPersistFailure(host, "project", err);
|
|
249
|
+
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (choice.startsWith("Allow for all projects")) {
|
|
253
|
+
try {
|
|
254
|
+
persistDomainGrant(null, host, resolvedStateHome);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
logGrantPersistFailure(host, "user", err);
|
|
258
|
+
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// The grant must reach the live srt config before the retry.
|
|
262
|
+
manager.refreshConfig([...effectiveRulesForSandbox()]);
|
|
263
|
+
return true;
|
|
264
|
+
};
|
|
265
|
+
// --- bash composition ---
|
|
266
|
+
// ONE wrapped bash definition, built once and consumed by whichever site
|
|
267
|
+
// registers bash for this session: condensedTools' wrapBash seam (the
|
|
268
|
+
// common path — condensed registers LAST and would otherwise overwrite a
|
|
269
|
+
// competing registration, the M4 e2e lesson) or this module's own
|
|
270
|
+
// registration when condensed is inactive (eval mode, classic rows,
|
|
271
|
+
// desktop). The composition is identity when the sandbox is disabled —
|
|
272
|
+
// default-off sessions stay byte-identical.
|
|
273
|
+
const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd);
|
|
274
|
+
const registerOwnBash = () => {
|
|
275
|
+
if (!currentSettings.enabled)
|
|
276
|
+
return;
|
|
277
|
+
const userShellPath = SettingsManager.create(opts.cwd).getShellPath();
|
|
278
|
+
const stockBash = createBashToolDefinition(opts.cwd, { shellPath: userShellPath });
|
|
279
|
+
pi.registerTool(composeBash(stockBash));
|
|
280
|
+
};
|
|
281
|
+
const handle = {
|
|
282
|
+
manager,
|
|
283
|
+
settings: () => currentSettings,
|
|
284
|
+
setRules: (r) => {
|
|
285
|
+
rules = r;
|
|
286
|
+
},
|
|
287
|
+
grantDomainSession: (domain) => {
|
|
288
|
+
sessionDomainGrants.add(domain);
|
|
289
|
+
manager.refreshConfig([...effectiveRulesForSandbox()]);
|
|
290
|
+
},
|
|
291
|
+
sessionDomains: () => [...sessionDomainGrants],
|
|
292
|
+
composeBash,
|
|
293
|
+
registerOwnBash,
|
|
294
|
+
};
|
|
295
|
+
// --- user_bash (! commands): identical OS treatment to tool calls ---
|
|
296
|
+
// pi executes the user's command through the returned BashOperations, so
|
|
297
|
+
// wrapping at this seam gives ! commands the same sandbox. Process-group
|
|
298
|
+
// kill + stdio handling derived from pi-sandbox (MIT, carderne/pi-sandbox
|
|
299
|
+
// src/sandbox-runtime.ts, waitForChildProcess) with pi's local ops as the
|
|
300
|
+
// behavioral reference.
|
|
301
|
+
pi.on("user_bash", async (event) => {
|
|
302
|
+
if (!shouldUseSandboxForUserCommand(event.command, manager, currentSettings))
|
|
303
|
+
return;
|
|
304
|
+
const { shell, args } = getShellConfig(SettingsManager.create(opts.cwd).getShellPath());
|
|
305
|
+
const operations = {
|
|
306
|
+
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
|
307
|
+
if (!existsSync(cwd))
|
|
308
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
309
|
+
const wrapped = await manager.wrapWithSandbox(command, shell);
|
|
310
|
+
const child = spawn(shell, [...args, wrapped], {
|
|
311
|
+
cwd,
|
|
312
|
+
env: env ?? process.env,
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
315
|
+
});
|
|
316
|
+
let timedOut = false;
|
|
317
|
+
let timeoutHandle;
|
|
318
|
+
const killGroup = () => {
|
|
319
|
+
if (!child.pid)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
process.kill(-child.pid, "SIGKILL");
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
child.kill("SIGKILL");
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
if (timeout !== undefined && timeout > 0) {
|
|
329
|
+
timeoutHandle = setTimeout(() => { timedOut = true; killGroup(); }, timeout * 1000);
|
|
330
|
+
}
|
|
331
|
+
child.stdout?.on("data", onData);
|
|
332
|
+
child.stderr?.on("data", onData);
|
|
333
|
+
signal?.addEventListener("abort", killGroup, { once: true });
|
|
334
|
+
try {
|
|
335
|
+
const exitCode = await waitForChildProcess(child);
|
|
336
|
+
if (signal?.aborted)
|
|
337
|
+
throw new Error("aborted");
|
|
338
|
+
if (timedOut)
|
|
339
|
+
throw new Error(`timeout:${timeout}`);
|
|
340
|
+
return { exitCode };
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
if (timeoutHandle)
|
|
344
|
+
clearTimeout(timeoutHandle);
|
|
345
|
+
signal?.removeEventListener("abort", killGroup);
|
|
346
|
+
manager.cleanupAfterCommand();
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
return { operations };
|
|
351
|
+
});
|
|
352
|
+
// --- session lifecycle ---
|
|
353
|
+
// The UI bridge is captured on every ctx-bearing event so the network ask
|
|
354
|
+
// (fired from inside bash execution, no event ctx) can prompt.
|
|
355
|
+
pi.on("tool_call", async (_event, ctx) => {
|
|
356
|
+
captureUi(ctx);
|
|
357
|
+
captureTrust(ctx);
|
|
358
|
+
return;
|
|
359
|
+
});
|
|
360
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
361
|
+
captureUi(ctx);
|
|
362
|
+
captureTrust(ctx);
|
|
363
|
+
currentSettings = load();
|
|
364
|
+
// --no-sandbox skips init in EVERY mode — headless included. The notify
|
|
365
|
+
// is UI-gated, the SKIP never is (a UI-only skip would silently keep the
|
|
366
|
+
// sandbox on for scripted runs that asked it off).
|
|
367
|
+
if (noSandboxFlag()) {
|
|
368
|
+
if (opts.hasUI(ctx))
|
|
369
|
+
ctx.ui.notify("Sandbox disabled via --no-sandbox for this session", "warning");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!currentSettings.enabled)
|
|
373
|
+
return;
|
|
374
|
+
manager.setAskHandler(askHandler);
|
|
375
|
+
const err = await manager.initialize(effectiveRulesForSandbox());
|
|
376
|
+
if (err) {
|
|
377
|
+
logEvent({
|
|
378
|
+
source: "sandbox",
|
|
379
|
+
level: "warn",
|
|
380
|
+
event: "init_failed",
|
|
381
|
+
fields: { reason: err },
|
|
382
|
+
});
|
|
383
|
+
if (currentSettings.failIfUnavailable) {
|
|
384
|
+
ctx.ui.notify(`Sandbox required by config but unavailable: ${err}. Refusing to run — fix dependencies or disable sandbox.enabled.`, "error");
|
|
385
|
+
throw new Error(`sandbox unavailable (failIfUnavailable): ${err}`);
|
|
386
|
+
}
|
|
387
|
+
ctx.ui.notify(`Sandbox unavailable, commands run unsandboxed: ${err}`, "warning");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (ctx.hasUI) {
|
|
391
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
392
|
+
}
|
|
393
|
+
logEvent({ source: "sandbox", level: "info", event: "initialized", fields: { platform } });
|
|
394
|
+
});
|
|
395
|
+
pi.on("session_shutdown", async () => {
|
|
396
|
+
await manager.reset();
|
|
397
|
+
});
|
|
398
|
+
// --- /sandbox command + toggle surfaces ---
|
|
399
|
+
const formatConfigSummary = () => {
|
|
400
|
+
const s = currentSettings;
|
|
401
|
+
const lines = [
|
|
402
|
+
`enabled: ${s.enabled ? "yes" : "no"}${sessionToggle === false ? " (toggled off this session)" : ""}`,
|
|
403
|
+
`autoAllowBashIfSandboxed: ${s.autoAllowBashIfSandboxed !== false ? "yes" : "no"}`,
|
|
404
|
+
`allowUnsandboxedCommands: ${s.allowUnsandboxedCommands !== false ? "yes" : "no (strict)"}`,
|
|
405
|
+
];
|
|
406
|
+
if (s.network?.allowedDomains?.length)
|
|
407
|
+
lines.push(`allowedDomains: ${s.network.allowedDomains.join(", ")}`);
|
|
408
|
+
else
|
|
409
|
+
lines.push("allowedDomains: (none — first contact prompts)");
|
|
410
|
+
if (s.network?.deniedDomains?.length)
|
|
411
|
+
lines.push(`deniedDomains: ${s.network.deniedDomains.join(", ")}`);
|
|
412
|
+
if (s.network?.allowLocalBinding)
|
|
413
|
+
lines.push("allowLocalBinding: yes (loopback + private ranges)");
|
|
414
|
+
if (s.filesystem?.allowWrite?.length)
|
|
415
|
+
lines.push(`allowWrite: ${s.filesystem.allowWrite.join(", ")}`);
|
|
416
|
+
if (s.filesystem?.denyWrite?.length)
|
|
417
|
+
lines.push(`denyWrite: ${s.filesystem.denyWrite.join(", ")}`);
|
|
418
|
+
if (s.filesystem?.denyRead?.length)
|
|
419
|
+
lines.push(`denyRead: ${s.filesystem.denyRead.join(", ")}`);
|
|
420
|
+
if (sessionDomainGrants.size > 0)
|
|
421
|
+
lines.push(`session grants: ${[...sessionDomainGrants].join(", ")} (memory-only)`);
|
|
422
|
+
if (s.excludedCommands?.length)
|
|
423
|
+
lines.push(`excludedCommands: ${s.excludedCommands.join(", ")}`);
|
|
424
|
+
return lines;
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Session-level toggle. OFF is always immediate (manager.reset unwraps the
|
|
428
|
+
* next command) and — when the session started ENABLED — PERSISTS the
|
|
429
|
+
* disable to the local config (Claude's toggle persists both directions).
|
|
430
|
+
* ON is split by how the session started:
|
|
431
|
+
* - config-enabled session: re-initialize (force past nothing — it was on)
|
|
432
|
+
* - default-off session: the wrapped bash may not have registered at load
|
|
433
|
+
* (condensedTools owns the registration in the common TUI and reads disk
|
|
434
|
+
* settings, not this session's toggle). So /sandbox enable PERSISTS
|
|
435
|
+
* sandbox.enabled=true to the local config (Claude's own panel persists
|
|
436
|
+
* its mode choice the same way) and asks for a restart — the honest
|
|
437
|
+
* behavior; a silent no-op would be a security lie.
|
|
438
|
+
*/
|
|
439
|
+
/** Persist-or-notify for the enable/disable toggles — keeps
|
|
440
|
+
* setSessionToggle flat: try the local write, return the success notice
|
|
441
|
+
* on success or the failure notice on throw (the caller decides level +
|
|
442
|
+
* side effects like the sessionToggle reset). A failed persist also logs
|
|
443
|
+
* sandbox_persist_failed with the thrown message (path/reason text from
|
|
444
|
+
* mutateConfigJson) — same diagnosability posture as rule_save_failed. */
|
|
445
|
+
const persistToggleAndNotify = (enabled, ctx) => {
|
|
446
|
+
try {
|
|
447
|
+
persistEnabledFlip(enabled, opts.cwd);
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
notice: enabled
|
|
451
|
+
? "Sandbox enabled in .yagni-code/config.local.json — restart the session (or /new) to apply it to the model's bash tool. Your ! commands are NOT sandboxed until then."
|
|
452
|
+
: "Sandbox disabled for this session and set to off in .yagni-code/config.local.json",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
logEvent({
|
|
457
|
+
source: "sandbox",
|
|
458
|
+
level: "warn",
|
|
459
|
+
event: "sandbox_persist_failed",
|
|
460
|
+
fields: {
|
|
461
|
+
enabled,
|
|
462
|
+
error: err instanceof Error ? err.message : String(err),
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
notice: enabled
|
|
468
|
+
? 'Could not write the sandbox enable to .yagni-code/config.local.json — set "sandbox": { "enabled": true } there and restart.'
|
|
469
|
+
: "Sandbox disabled for this session (could not persist the off state to config.local.json — a restart may re-arm it)",
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const setSessionToggle = async (on, ctx) => {
|
|
474
|
+
sessionToggle = on;
|
|
475
|
+
if (on) {
|
|
476
|
+
const startedEnabled = load().enabled;
|
|
477
|
+
if (startedEnabled) {
|
|
478
|
+
const err = await manager.initialize(effectiveRulesForSandbox(), { force: true });
|
|
479
|
+
if (err) {
|
|
480
|
+
ctx.ui.notify(`Sandbox unavailable: ${err}`, "error");
|
|
481
|
+
sessionToggle = null;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// NO registerOwnBash here: on a condensed-active session (the common
|
|
485
|
+
// case for a config-enabled start) condensed owns the bash
|
|
486
|
+
// registration and composed the wrap at load; re-initializing the
|
|
487
|
+
// manager is all the toggle needs — composeBash reads the manager
|
|
488
|
+
// live at execution time. Registering a stock-wrapped bash here
|
|
489
|
+
// would REPLACE condensed's registration and silently degrade the
|
|
490
|
+
// transcript renderer for the rest of the session.
|
|
491
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
492
|
+
ctx.ui.notify("Sandbox enabled for this session", "info");
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Persist the enable (fail-soft: on write failure tell the user to
|
|
496
|
+
// edit the file — never claim success falsely).
|
|
497
|
+
const r = persistToggleAndNotify(true, ctx);
|
|
498
|
+
if (r.ok) {
|
|
499
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
500
|
+
ctx.ui.notify(r.notice, "warning");
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
ctx.ui.notify(r.notice, "error");
|
|
504
|
+
sessionToggle = null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
await manager.reset();
|
|
510
|
+
ctx.ui.setStatus?.("sandbox", "");
|
|
511
|
+
// Persist the disable when the session started enabled — a restart
|
|
512
|
+
// must not silently re-arm the sandbox the user just turned off.
|
|
513
|
+
// Fail-soft: the session-level reset above already applies NOW.
|
|
514
|
+
if (load().enabled) {
|
|
515
|
+
const r = persistToggleAndNotify(false, ctx);
|
|
516
|
+
// Error (not warning): a failed off-persist silently re-arms the
|
|
517
|
+
// sandbox on restart — the security-relevant direction, at least as
|
|
518
|
+
// loud as the enable failure.
|
|
519
|
+
ctx.ui.notify(r.notice, r.ok ? "warning" : "error");
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
ctx.ui.notify("Sandbox disabled for this session", "warning");
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* The interactive /sandbox panel (TUI only). Selections persist to the
|
|
528
|
+
* PROJECT-LOCAL config (.yagni-code/config.local.json — the personal
|
|
529
|
+
* per-project tier, Claude Code's own panel destination); the
|
|
530
|
+
* confirmation message is surfaced via notify after the panel closes,
|
|
531
|
+
* mirroring Claude's onComplete flow. enable-from-default-off keeps the
|
|
532
|
+
* honest restart advice.
|
|
533
|
+
*/
|
|
534
|
+
const openSandboxPanel = async (ctx) => {
|
|
535
|
+
const deps = manager.checkDependencies();
|
|
536
|
+
const state = buildPanelState(currentSettings, sessionToggle === false, effectiveRulesForSandbox(), {
|
|
537
|
+
cwd: opts.cwd,
|
|
538
|
+
userStateHome: resolvedStateHome,
|
|
539
|
+
projectRoot: opts.projectRoot ?? null,
|
|
540
|
+
homeDir: opts.userHome,
|
|
541
|
+
}, [...sessionDomainGrants], deps);
|
|
542
|
+
const actions = {
|
|
543
|
+
onModeSelect: async (choice) => {
|
|
544
|
+
const wasEnabled = load().enabled === true;
|
|
545
|
+
try {
|
|
546
|
+
persistSandboxMode(choice, opts.cwd);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return {
|
|
550
|
+
text: `Could not write the sandbox setting to .yagni-code/config.local.json — edit it directly ("sandbox": { "enabled": ${choice !== "disabled"} }) and restart.`,
|
|
551
|
+
level: "error",
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
currentSettings = load();
|
|
555
|
+
if (choice === "disabled") {
|
|
556
|
+
await manager.reset();
|
|
557
|
+
sessionToggle = false;
|
|
558
|
+
ctx.ui.setStatus?.("sandbox", "");
|
|
559
|
+
return { text: "○ Sandbox disabled", level: "info" };
|
|
560
|
+
}
|
|
561
|
+
const message = choice === "auto-allow"
|
|
562
|
+
? "✓ Sandbox enabled with auto-allow for bash commands"
|
|
563
|
+
: "✓ Sandbox enabled with regular bash permissions";
|
|
564
|
+
if (!wasEnabled) {
|
|
565
|
+
// Same honesty as setSessionToggle's enable path: the session's
|
|
566
|
+
// wrapped bash registered (or didn't) at load; a restart applies
|
|
567
|
+
// the new mode to the model's bash tool.
|
|
568
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
569
|
+
return {
|
|
570
|
+
text: `${message} — saved to project-local settings (.yagni-code/config.local.json); restart the session (or /new) to apply it to the model's bash tool.`,
|
|
571
|
+
level: "info",
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// Already-enabled session changing autoAllow only: refresh the
|
|
575
|
+
// live merge so the next bash call sees the new decision posture.
|
|
576
|
+
if (manager.initialized)
|
|
577
|
+
manager.refreshConfig(effectiveRulesForSandbox());
|
|
578
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
579
|
+
return {
|
|
580
|
+
text: `${message} — saved to project-local settings (.yagni-code/config.local.json).`,
|
|
581
|
+
level: "info",
|
|
582
|
+
};
|
|
583
|
+
},
|
|
584
|
+
onOverrideSelect: async (choice) => {
|
|
585
|
+
try {
|
|
586
|
+
persistSandboxOverride(choice, opts.cwd);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return {
|
|
590
|
+
text: `Could not write the sandbox override to .yagni-code/config.local.json — edit it directly ("sandbox": { "allowUnsandboxedCommands": ... }) and restart.`,
|
|
591
|
+
level: "error",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
currentSettings = load();
|
|
595
|
+
if (manager.initialized)
|
|
596
|
+
manager.refreshConfig(effectiveRulesForSandbox());
|
|
597
|
+
return {
|
|
598
|
+
text: choice === "open"
|
|
599
|
+
? "✓ Unsandboxed fallback allowed — commands can run outside the sandbox when necessary — saved to project-local settings"
|
|
600
|
+
: "✓ Strict sandbox mode — all commands must run in the sandbox or be excluded via the excludedCommands option — saved to project-local settings",
|
|
601
|
+
level: "info",
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
let outcome;
|
|
606
|
+
try {
|
|
607
|
+
outcome = await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
608
|
+
void tui;
|
|
609
|
+
void keybindings;
|
|
610
|
+
return new SandboxPanel(state, actions, theme, (o) => done(o ?? null));
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// custom() unavailable (harness without TUI) — fall through to notify.
|
|
615
|
+
}
|
|
616
|
+
// null = closed without a selection (Esc / cancel): no message, no
|
|
617
|
+
// summary dump. undefined = custom() never ran (RPC): text summary.
|
|
618
|
+
if (outcome) {
|
|
619
|
+
ctx.ui.notify(outcome.text, outcome.level);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (outcome === undefined) {
|
|
623
|
+
ctx.ui.notify(["Sandbox configuration:", ...formatConfigSummary().map((l) => ` ${l}`)].join("\n"), "info");
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
pi.registerCommand("sandbox", {
|
|
627
|
+
description: "Show sandbox configuration / manage the session toggle",
|
|
628
|
+
handler: async (args, ctx) => {
|
|
629
|
+
const trimmed = args.trim();
|
|
630
|
+
if (trimmed === "enable")
|
|
631
|
+
return setSessionToggle(true, ctx);
|
|
632
|
+
if (trimmed === "disable")
|
|
633
|
+
return setSessionToggle(false, ctx);
|
|
634
|
+
if (trimmed === "exclude" || trimmed.startsWith("exclude ")) {
|
|
635
|
+
// /sandbox exclude "npm run test:*" — convenience escape valve (the
|
|
636
|
+
// permission gate remains the security boundary).
|
|
637
|
+
const pattern = trimmed.slice("exclude ".length).trim().replace(/^["']|["']$/g, "");
|
|
638
|
+
if (!pattern) {
|
|
639
|
+
ctx.ui.notify("Usage: /sandbox exclude <command-pattern>", "error");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
currentSettings = { ...currentSettings, excludedCommands: [...(currentSettings.excludedCommands ?? []), pattern] };
|
|
643
|
+
ctx.ui.notify(`Excluded "${pattern}" from sandboxing for this session`, "info");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// TUI: the interactive tabbed panel (Claude's /sandbox). RPC/headless:
|
|
647
|
+
// custom() is unavailable there — keep the text summary.
|
|
648
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
649
|
+
return openSandboxPanel(ctx);
|
|
650
|
+
}
|
|
651
|
+
ctx.ui.notify(["Sandbox configuration:", ...formatConfigSummary().map((l) => ` ${l}`)].join("\n"), "info");
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
pi.registerShortcut?.(Key.alt("s"), {
|
|
655
|
+
description: "Toggle the bash sandbox for this session",
|
|
656
|
+
handler: (ctx) => {
|
|
657
|
+
void setSessionToggle((sessionToggle ?? manager.initialized) === false, ctx);
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
return handle;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Persist a domain grant to the user or project sandbox config (fail-soft:
|
|
664
|
+
* the session grant already applies even if the write fails).
|
|
665
|
+
*/
|
|
666
|
+
/** Local helper for the grant/target writers below (shared shape). */
|
|
667
|
+
function isPlainRecord(v) {
|
|
668
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
669
|
+
}
|
|
670
|
+
/** Sink line for a failed domain-grant persist — the thrown message is
|
|
671
|
+
* mutateConfigJson's own path/reason text (diagnosable, no file content),
|
|
672
|
+
* same posture as sandbox_persist_failed / rule_save_failed. */
|
|
673
|
+
function logGrantPersistFailure(host, scope, err) {
|
|
674
|
+
logEvent({
|
|
675
|
+
source: "sandbox",
|
|
676
|
+
level: "warn",
|
|
677
|
+
event: "sandbox_persist_failed",
|
|
678
|
+
fields: {
|
|
679
|
+
grant: host,
|
|
680
|
+
scope,
|
|
681
|
+
error: err instanceof Error ? err.message : String(err),
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/** Persist a domain grant to the user or project sandbox config. */
|
|
686
|
+
function persistDomainGrant(projectRoot, domain, stateHome) {
|
|
687
|
+
const target = projectRoot
|
|
688
|
+
? join(projectRoot, ".yagni-code", "config.json")
|
|
689
|
+
: join(stateHome, "config.json");
|
|
690
|
+
mutateConfigJson(target, (config) => {
|
|
691
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
692
|
+
const network = isPlainRecord(sandbox.network) ? { ...sandbox.network } : {};
|
|
693
|
+
const domains = Array.isArray(network.allowedDomains) ? [...network.allowedDomains] : [];
|
|
694
|
+
if (!domains.includes(domain))
|
|
695
|
+
domains.push(domain);
|
|
696
|
+
network.allowedDomains = domains;
|
|
697
|
+
sandbox.network = network;
|
|
698
|
+
config.sandbox = sandbox;
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Persist sandbox.enabled to the PROJECT-LOCAL config — the personal
|
|
703
|
+
* per-project tier, Claude Code's own destination for these writes
|
|
704
|
+
* (settings.local.json). Local writes fire-and-forget the global-gitignore
|
|
705
|
+
* helper so the file is never committed by accident.
|
|
706
|
+
*/
|
|
707
|
+
function persistEnabledFlip(enabled, cwd) {
|
|
708
|
+
mutateLocalConfig(cwd, (config) => {
|
|
709
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
710
|
+
sandbox.enabled = enabled;
|
|
711
|
+
config.sandbox = sandbox;
|
|
712
|
+
}, "sandbox");
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Persist a panel Mode choice (enabled + autoAllowBashIfSandboxed pair) to
|
|
716
|
+
* the PROJECT-LOCAL config — Claude Code's own panel persists its mode
|
|
717
|
+
* choice to settings.local.json; ours now matches. Also fire-and-forgets
|
|
718
|
+
* the global-gitignore helper on first write.
|
|
719
|
+
*/
|
|
720
|
+
function persistSandboxMode(choice, cwd) {
|
|
721
|
+
mutateLocalConfig(cwd, (config) => {
|
|
722
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
723
|
+
if (choice === "auto-allow") {
|
|
724
|
+
sandbox.enabled = true;
|
|
725
|
+
sandbox.autoAllowBashIfSandboxed = true;
|
|
726
|
+
}
|
|
727
|
+
else if (choice === "regular") {
|
|
728
|
+
sandbox.enabled = true;
|
|
729
|
+
sandbox.autoAllowBashIfSandboxed = false;
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
sandbox.enabled = false;
|
|
733
|
+
sandbox.autoAllowBashIfSandboxed = false;
|
|
734
|
+
}
|
|
735
|
+
config.sandbox = sandbox;
|
|
736
|
+
}, "sandbox");
|
|
737
|
+
}
|
|
738
|
+
/** Persist a panel Overrides choice (allowUnsandboxedCommands) — same
|
|
739
|
+
* destination + honesty contract as persistSandboxMode. */
|
|
740
|
+
function persistSandboxOverride(choice, cwd) {
|
|
741
|
+
mutateLocalConfig(cwd, (config) => {
|
|
742
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
743
|
+
sandbox.allowUnsandboxedCommands = choice === "open";
|
|
744
|
+
config.sandbox = sandbox;
|
|
745
|
+
}, "sandbox");
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Wait for a spawned child's exit without hanging on inherited stdio —
|
|
749
|
+
* after exit, release the pipes after a short idle grace so a detached
|
|
750
|
+
* descendant holding them open cannot wedge the await. Derived from
|
|
751
|
+
* pi-sandbox (MIT) waitForChildProcess.
|
|
752
|
+
*/
|
|
753
|
+
const EXIT_STDIO_GRACE_MS = 100;
|
|
754
|
+
function waitForChildProcess(child) {
|
|
755
|
+
return new Promise((resolve, reject) => {
|
|
756
|
+
let settled = false;
|
|
757
|
+
let exited = false;
|
|
758
|
+
let exitCode = null;
|
|
759
|
+
let postExitTimer;
|
|
760
|
+
let stdoutEnded = child.stdout === null;
|
|
761
|
+
let stderrEnded = child.stderr === null;
|
|
762
|
+
const cleanup = () => {
|
|
763
|
+
if (postExitTimer)
|
|
764
|
+
clearTimeout(postExitTimer);
|
|
765
|
+
child.removeListener("error", onError);
|
|
766
|
+
child.removeListener("exit", onExit);
|
|
767
|
+
child.removeListener("close", onClose);
|
|
768
|
+
child.stdout?.removeListener("end", onStdoutEnd);
|
|
769
|
+
child.stderr?.removeListener("end", onStderrEnd);
|
|
770
|
+
child.stdout?.removeListener("data", onData);
|
|
771
|
+
child.stderr?.removeListener("data", onData);
|
|
772
|
+
};
|
|
773
|
+
const finalize = (code) => {
|
|
774
|
+
if (settled)
|
|
775
|
+
return;
|
|
776
|
+
settled = true;
|
|
777
|
+
cleanup();
|
|
778
|
+
child.stdout?.destroy();
|
|
779
|
+
child.stderr?.destroy();
|
|
780
|
+
resolve(code);
|
|
781
|
+
};
|
|
782
|
+
const maybeFinalizeAfterExit = () => {
|
|
783
|
+
if (!exited || settled)
|
|
784
|
+
return;
|
|
785
|
+
if (stdoutEnded && stderrEnded)
|
|
786
|
+
finalize(exitCode);
|
|
787
|
+
};
|
|
788
|
+
const armIdleTimer = () => {
|
|
789
|
+
if (postExitTimer)
|
|
790
|
+
clearTimeout(postExitTimer);
|
|
791
|
+
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
|
|
792
|
+
};
|
|
793
|
+
const onData = () => {
|
|
794
|
+
if (exited && !settled)
|
|
795
|
+
armIdleTimer();
|
|
796
|
+
};
|
|
797
|
+
const onStdoutEnd = () => {
|
|
798
|
+
stdoutEnded = true;
|
|
799
|
+
maybeFinalizeAfterExit();
|
|
800
|
+
};
|
|
801
|
+
const onStderrEnd = () => {
|
|
802
|
+
stderrEnded = true;
|
|
803
|
+
maybeFinalizeAfterExit();
|
|
804
|
+
};
|
|
805
|
+
const onError = (error) => {
|
|
806
|
+
if (settled)
|
|
807
|
+
return;
|
|
808
|
+
settled = true;
|
|
809
|
+
cleanup();
|
|
810
|
+
reject(error);
|
|
811
|
+
};
|
|
812
|
+
const onExit = (code) => {
|
|
813
|
+
exited = true;
|
|
814
|
+
exitCode = code;
|
|
815
|
+
maybeFinalizeAfterExit();
|
|
816
|
+
if (!settled)
|
|
817
|
+
armIdleTimer();
|
|
818
|
+
};
|
|
819
|
+
const onClose = (code) => finalize(code);
|
|
820
|
+
child.stdout?.once("end", onStdoutEnd);
|
|
821
|
+
child.stderr?.once("end", onStderrEnd);
|
|
822
|
+
child.stdout?.on("data", onData);
|
|
823
|
+
child.stderr?.on("data", onData);
|
|
824
|
+
child.once("error", onError);
|
|
825
|
+
child.once("exit", onExit);
|
|
826
|
+
child.once("close", onClose);
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
//# sourceMappingURL=session.js.map
|