@yagni-app/code 1.0.5 → 1.0.7

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