@yagni-app/code 1.0.6 → 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 (85) hide show
  1. package/README.md +84 -46
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -5
  5. package/dist/claudePlugins.js +129 -21
  6. package/dist/cli.js +16 -4
  7. package/dist/doctor.d.ts +21 -8
  8. package/dist/doctor.js +53 -28
  9. package/dist/extension/condensedTools.d.ts +12 -1
  10. package/dist/extension/condensedTools.js +17 -9
  11. package/dist/extension/index.d.ts +13 -0
  12. package/dist/extension/index.js +230 -43
  13. package/dist/extension/mcp/cliConfig.d.ts +1 -1
  14. package/dist/extension/mcp/cliConfig.js +1 -1
  15. package/dist/extension/mcp/config.d.ts +24 -2
  16. package/dist/extension/mcp/config.js +75 -3
  17. package/dist/extension/mcp/manager.d.ts +3 -1
  18. package/dist/extension/mcp/manager.js +2 -2
  19. package/dist/extension/mcp/panel.d.ts +0 -1
  20. package/dist/extension/mcp/panel.js +13 -3
  21. package/dist/extension/mcp/startup.js +8 -6
  22. package/dist/extension/permission/gate.d.ts +97 -2
  23. package/dist/extension/permission/gate.js +375 -26
  24. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  25. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  26. package/dist/extension/permissionRules/engine.d.ts +50 -0
  27. package/dist/extension/permissionRules/engine.js +238 -0
  28. package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
  29. package/dist/extension/permissionRules/loadConfig.js +90 -0
  30. package/dist/extension/permissionRules/parser.d.ts +38 -0
  31. package/dist/extension/permissionRules/parser.js +136 -0
  32. package/dist/extension/permissionRules/pathRules.d.ts +58 -0
  33. package/dist/extension/permissionRules/pathRules.js +120 -0
  34. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  35. package/dist/extension/permissionRules/shellRules.js +221 -0
  36. package/dist/extension/pipeline/invocation.d.ts +3 -6
  37. package/dist/extension/pipeline/invocation.js +3 -6
  38. package/dist/extension/pipeline/runner.d.ts +0 -1
  39. package/dist/extension/pipeline/runner.js +6 -14
  40. package/dist/extension/plugins/inventory.d.ts +88 -0
  41. package/dist/extension/plugins/inventory.js +144 -0
  42. package/dist/extension/plugins/panel.d.ts +45 -0
  43. package/dist/extension/plugins/panel.js +293 -0
  44. package/dist/extension/sandbox/bash.d.ts +99 -0
  45. package/dist/extension/sandbox/bash.js +190 -0
  46. package/dist/extension/sandbox/config.d.ts +114 -0
  47. package/dist/extension/sandbox/config.js +366 -0
  48. package/dist/extension/sandbox/manager.d.ts +98 -0
  49. package/dist/extension/sandbox/manager.js +216 -0
  50. package/dist/extension/sandbox/panel.d.ts +111 -0
  51. package/dist/extension/sandbox/panel.js +342 -0
  52. package/dist/extension/sandbox/session.d.ts +85 -0
  53. package/dist/extension/sandbox/session.js +775 -0
  54. package/dist/extension/telemetry/attrs.d.ts +96 -0
  55. package/dist/extension/telemetry/attrs.js +149 -0
  56. package/dist/extension/telemetry/config.d.ts +99 -0
  57. package/dist/extension/telemetry/config.js +193 -0
  58. package/dist/extension/telemetry/index.d.ts +7 -0
  59. package/dist/extension/telemetry/index.js +7 -0
  60. package/dist/extension/telemetry/probe.d.ts +29 -0
  61. package/dist/extension/telemetry/probe.js +122 -0
  62. package/dist/extension/telemetry/register.d.ts +40 -0
  63. package/dist/extension/telemetry/register.js +192 -0
  64. package/dist/extension/telemetry/sdk.d.ts +63 -0
  65. package/dist/extension/telemetry/sdk.js +207 -0
  66. package/dist/extension/telemetry/tracker.d.ts +131 -0
  67. package/dist/extension/telemetry/tracker.js +551 -0
  68. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  69. package/dist/extension/vendor/ignore.d.ts +86 -0
  70. package/dist/extension/vendor/ignore.js +788 -0
  71. package/dist/goHeadless.d.ts +1 -1
  72. package/dist/goHeadless.js +2 -2
  73. package/dist/launch.d.ts +4 -3
  74. package/dist/launch.js +7 -4
  75. package/dist/mcpCommand.d.ts +10 -1
  76. package/dist/mcpCommand.js +42 -10
  77. package/dist/otel.d.ts +67 -90
  78. package/dist/otel.js +152 -195
  79. package/dist/paths.d.ts +13 -0
  80. package/dist/paths.js +18 -0
  81. package/dist/pluginCommand.d.ts +43 -0
  82. package/dist/pluginCommand.js +499 -0
  83. package/dist/pluginStore.d.ts +170 -0
  84. package/dist/pluginStore.js +554 -0
  85. package/package.json +19 -3
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `/plugin` and `/reload-plugins` — Claude Code marketplace plugins managed
3
+ * from inside a session.
4
+ *
5
+ * `/plugin` mirrors Claude Code's panel: the installed inventory (origin,
6
+ * version, what loads, what is not bridged), the marketplace catalog with
7
+ * one-keystroke install, and per-plugin uninstall/enable/disable. Every
8
+ * mutation runs the launcher's `yagni plugin …` (the single owner of the
9
+ * ledger, trust, and discovery) and then reloads.
10
+ *
11
+ * `/reload-plugins` is pi's own `/reload` with the plugin state refreshed
12
+ * first: MCP servers and agents re-read their env on the new runtime, and
13
+ * skills/commands of plugins that were not in the launch argv reach pi
14
+ * through `resources_discover`. Removed plugins stop contributing MCP servers
15
+ * and agents at once; their skills/commands stay until a restart, and the
16
+ * summary says so.
17
+ */
18
+ import { applyEnv, cliPath, defaultCliRunner, discoverPaths, fetchInventory, planReload, storedInventory, } from "./inventory.js";
19
+ const USAGE = [
20
+ "/plugin Browse installed plugins and marketplaces.",
21
+ "/plugin list Show what loads here.",
22
+ "/plugin install <plugin>[@marketplace] [-s user|project]",
23
+ "/plugin uninstall <plugin> (also: enable, disable, update [plugin])",
24
+ "/plugin marketplace add <owner/repo | git-url | path> (also: list, update, remove)",
25
+ "/reload-plugins Apply plugin changes without restarting.",
26
+ ].join("\n");
27
+ const MUTATING = new Set(["install", "add", "uninstall", "remove", "enable", "disable", "update"]);
28
+ export function describePlugin(p) {
29
+ const parts = [];
30
+ if (p.skillPaths.length)
31
+ parts.push(`${p.skillPaths.length} skill dir${p.skillPaths.length === 1 ? "" : "s"}`);
32
+ if (p.commandPaths.length)
33
+ parts.push(`${p.commandPaths.length} command dir${p.commandPaths.length === 1 ? "" : "s"}`);
34
+ if (p.agentDirs.length)
35
+ parts.push(`${p.agentDirs.length} agent dir${p.agentDirs.length === 1 ? "" : "s"}`);
36
+ if (p.mcpServers.length)
37
+ parts.push(`MCP: ${p.mcpServers.join(", ")}`);
38
+ const origin = p.origin === "yagni" ? "installed via yagni" : p.origin === "claude-code" ? "mirrored from Claude Code" : "from this repo's marketplace";
39
+ const lines = [`${p.key}${p.version ? ` v${p.version}` : ""} ${origin}`, ` ${parts.length ? parts.join(" · ") : "nothing loadable"}`];
40
+ if (p.unsupported.length)
41
+ lines.push(` not bridged: ${p.unsupported.join(", ")}`);
42
+ return lines.join("\n");
43
+ }
44
+ export function renderInventory(inventory, env) {
45
+ const lines = [];
46
+ if (inventory.plugins.length === 0) {
47
+ lines.push("No plugins load here.");
48
+ lines.push("Add a marketplace with `/plugin marketplace add <owner/repo | git-url | path>`, then `/plugin install <plugin>`.");
49
+ }
50
+ else {
51
+ lines.push(`Plugins (${inventory.plugins.length}):`);
52
+ for (const p of inventory.plugins)
53
+ lines.push(describePlugin(p));
54
+ }
55
+ if (inventory.pendingTrust.length > 0) {
56
+ lines.push(`Held back until this folder is trusted: ${inventory.pendingTrust.join(", ")}`);
57
+ }
58
+ const stale = staleSince(inventory, env);
59
+ if (stale.added.length > 0 || stale.removed.length > 0) {
60
+ lines.push(`Not yet applied to this session: ${[...stale.added.map((k) => `+${k}`), ...stale.removed.map((k) => `-${k}`)].join(", ")} — run /reload-plugins.`);
61
+ }
62
+ return lines.join("\n");
63
+ }
64
+ /** Plugins the inventory has that the session does not (and vice versa). */
65
+ function staleSince(inventory, env) {
66
+ const plan = planReload(inventory, env);
67
+ return { added: plan.added.map((p) => p.key), removed: plan.removed };
68
+ }
69
+ export function renderReloadSummary(added, removed) {
70
+ const lines = [];
71
+ if (added.length === 0 && removed.length === 0) {
72
+ lines.push("Plugins reloaded — nothing changed since launch.");
73
+ return lines.join("\n");
74
+ }
75
+ if (added.length > 0) {
76
+ lines.push(`Loaded: ${added.map((p) => p.key).join(", ")}`);
77
+ const mcp = added.flatMap((p) => p.mcpServers);
78
+ if (mcp.length > 0)
79
+ lines.push(` MCP servers connecting: ${mcp.join(", ")} (see /mcp)`);
80
+ const commands = added.filter((p) => p.commandPaths.length > 0).map((p) => p.key);
81
+ if (commands.length > 0)
82
+ lines.push(` Commands and skills from ${commands.join(", ")} are available now.`);
83
+ }
84
+ if (removed.length > 0) {
85
+ lines.push(`Removed: ${removed.join(", ")}`);
86
+ lines.push(" Their MCP servers and agents are gone now; skills and commands stay until you restart (`yagni -c` keeps this conversation).");
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+ /** Wire /plugin, /reload-plugins, and the resources_discover contribution. */
91
+ export function registerPluginPanel(pi, deps = {}) {
92
+ const env = deps.env ?? process.env;
93
+ const runCli = deps.runCli ?? defaultCliRunner;
94
+ // Skills/prompts of plugins that arrived via /reload-plugins: pi re-runs
95
+ // this on every reload and session start, so they persist for the process.
96
+ pi.on("resources_discover", async () => discoverPaths(env));
97
+ async function runPlugin(args, cwd, ui) {
98
+ const path = cliPath(env);
99
+ if (!path) {
100
+ ui.notify("Plugin management needs the yagni launcher (YAGNI_CLI_PATH is unset in this session). Run `yagni plugin …` in a terminal.", "warning");
101
+ return false;
102
+ }
103
+ const result = await runCli(["plugin", ...args], { cliPath: path, cwd, env });
104
+ const out = result.stdout.trim();
105
+ const err = result.stderr.trim();
106
+ if (result.code === 0) {
107
+ if (out)
108
+ ui.notify(out, "info");
109
+ if (err)
110
+ ui.notify(err, "warning");
111
+ return true;
112
+ }
113
+ ui.notify(err || out || `yagni plugin ${args.join(" ")} failed (exit ${result.code})`, "error");
114
+ return false;
115
+ }
116
+ async function reload(ctx) {
117
+ let inventory;
118
+ try {
119
+ inventory = await fetchInventory({ cwd: ctx.cwd, env, runCli });
120
+ }
121
+ catch (err) {
122
+ ctx.ui.notify(`Could not read the plugin inventory — ${err instanceof Error ? err.message : String(err)}`, "error");
123
+ return;
124
+ }
125
+ const plan = planReload(inventory, env);
126
+ applyEnv(env, plan.env);
127
+ ctx.ui.notify(renderReloadSummary(plan.added, plan.removed), "info");
128
+ // Treat reload as terminal for this handler (pi docs): the runtime that
129
+ // registered us is torn down and re-created from the rewritten env.
130
+ await ctx.reload();
131
+ }
132
+ async function listing(ctx) {
133
+ try {
134
+ const inventory = await fetchInventory({ cwd: ctx.cwd, env, runCli });
135
+ return inventory;
136
+ }
137
+ catch (err) {
138
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
139
+ return null;
140
+ }
141
+ }
142
+ pi.registerCommand("reload-plugins", {
143
+ description: "Apply plugin installs/removals to this session without restarting.",
144
+ handler: async (_args, ctx) => {
145
+ await reload(ctx);
146
+ },
147
+ });
148
+ pi.registerCommand("plugin", {
149
+ description: "Manage Claude Code plugins: browse marketplaces, install, uninstall, enable/disable.",
150
+ handler: async (args, rawCtx) => {
151
+ const ctx = rawCtx;
152
+ const sub = args.trim().split(/\s+/).filter(Boolean);
153
+ const [verb] = sub;
154
+ if (verb === "help" || verb === "--help" || verb === "-h") {
155
+ ctx.ui.notify(USAGE, "info");
156
+ return;
157
+ }
158
+ if (verb === "list" || (!verb && !ctx.hasUI)) {
159
+ const inventory = await listing(ctx);
160
+ if (inventory)
161
+ ctx.ui.notify(renderInventory(inventory, env), "info");
162
+ return;
163
+ }
164
+ if (!verb) {
165
+ await interactive(ctx);
166
+ return;
167
+ }
168
+ if (verb === "reload") {
169
+ await reload(ctx);
170
+ return;
171
+ }
172
+ // Everything else is the CLI's grammar verbatim (install, uninstall,
173
+ // enable, disable, update, marketplace …); mutations reload afterwards.
174
+ const ok = await runPlugin(sub, ctx.cwd, ctx.ui);
175
+ const mutates = MUTATING.has(verb) || (verb === "marketplace" && sub[1] !== "list");
176
+ if (ok && mutates)
177
+ await reload(ctx);
178
+ },
179
+ });
180
+ async function interactive(ctx) {
181
+ const inventory = await listing(ctx);
182
+ if (!inventory)
183
+ return;
184
+ const choice = await ctx.ui.select("Plugins", [`Installed plugins (${inventory.plugins.length})`, "Browse marketplaces", "Update all", "Reload plugins", "Exit"], ctx.signal ? { signal: ctx.signal } : undefined);
185
+ if (!choice || choice === "Exit")
186
+ return;
187
+ if (choice.startsWith("Installed")) {
188
+ await installedMenu(ctx, inventory);
189
+ return;
190
+ }
191
+ if (choice === "Browse marketplaces") {
192
+ await browseMenu(ctx, inventory);
193
+ return;
194
+ }
195
+ if (choice === "Update all") {
196
+ if (await runPlugin(["update"], ctx.cwd, ctx.ui))
197
+ await reload(ctx);
198
+ return;
199
+ }
200
+ if (choice === "Reload plugins") {
201
+ await reload(ctx);
202
+ return;
203
+ }
204
+ }
205
+ async function installedMenu(ctx, inventory) {
206
+ if (inventory.plugins.length === 0) {
207
+ ctx.ui.notify(renderInventory(inventory, env), "info");
208
+ return;
209
+ }
210
+ const options = inventory.plugins.map((p) => `${p.key}${p.version ? ` · v${p.version}` : ""}`);
211
+ const choice = await ctx.ui.select("Installed plugins", [...options, "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
212
+ if (!choice || choice === "Back")
213
+ return;
214
+ const plugin = inventory.plugins[options.indexOf(choice)];
215
+ if (!plugin)
216
+ return;
217
+ const actions = ["View details"];
218
+ if (plugin.origin === "yagni")
219
+ actions.push("Uninstall", "Disable");
220
+ actions.push("Back");
221
+ const action = await ctx.ui.select(describePlugin(plugin).split("\n")[0], actions, ctx.signal ? { signal: ctx.signal } : undefined);
222
+ if (!action || action === "Back")
223
+ return;
224
+ if (action === "View details") {
225
+ ctx.ui.notify(describePlugin(plugin), "info");
226
+ return;
227
+ }
228
+ if (action === "Uninstall") {
229
+ if (await runPlugin(["uninstall", plugin.key], ctx.cwd, ctx.ui))
230
+ await reload(ctx);
231
+ return;
232
+ }
233
+ if (action === "Disable") {
234
+ if (await runPlugin(["disable", plugin.key], ctx.cwd, ctx.ui))
235
+ await reload(ctx);
236
+ }
237
+ }
238
+ async function browseMenu(ctx, inventory) {
239
+ const path = cliPath(env);
240
+ if (!path) {
241
+ ctx.ui.notify("Plugin management needs the yagni launcher (YAGNI_CLI_PATH is unset in this session).", "warning");
242
+ return;
243
+ }
244
+ const result = await runCli(["plugin", "list", "--available"], { cliPath: path, cwd: ctx.cwd, env });
245
+ const catalog = parseCatalog(result.stdout);
246
+ if (catalog.length === 0) {
247
+ ctx.ui.notify(result.stdout.trim() || "No marketplaces added. Use `/plugin marketplace add <owner/repo | git-url | path>`.", "info");
248
+ return;
249
+ }
250
+ const installed = new Set(inventory.plugins.map((p) => p.key));
251
+ const entries = catalog.flatMap((mp) => mp.plugins.map((p) => ({ key: `${p.name}@${mp.name}`, label: `${p.name}@${mp.name}${p.version ? ` · v${p.version}` : ""}${installed.has(`${p.name}@${mp.name}`) ? " (installed)" : ""}${p.installable ? "" : " (remote source, not installable)"}` })));
252
+ const choice = await ctx.ui.select("Marketplace plugins", [...entries.map((e) => e.label), "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
253
+ if (!choice || choice === "Back")
254
+ return;
255
+ const entry = entries[entries.map((e) => e.label).indexOf(choice)];
256
+ if (!entry)
257
+ return;
258
+ const scope = await ctx.ui.select(`Install ${entry.key}`, ["For all my projects (user)", "Only this repo (project)", "Back"], ctx.signal ? { signal: ctx.signal } : undefined);
259
+ if (!scope || scope === "Back")
260
+ return;
261
+ const args = ["install", entry.key, ...(scope.includes("project") ? ["-s", "project"] : [])];
262
+ if (await runPlugin(args, ctx.cwd, ctx.ui))
263
+ await reload(ctx);
264
+ }
265
+ }
266
+ /**
267
+ * Parse `yagni plugin list --available` text (the CLI's stable, tested
268
+ * shape: `<marketplace>:` headers, then ` <name> v<ver> [flags]` lines).
269
+ */
270
+ export function parseCatalog(text) {
271
+ const out = [];
272
+ let current = null;
273
+ for (const raw of text.split("\n")) {
274
+ const header = /^(\S.*):$/.exec(raw);
275
+ if (header) {
276
+ current = { name: header[1], plugins: [] };
277
+ out.push(current);
278
+ continue;
279
+ }
280
+ const entry = /^ (\S+)(?:\s+v(\S+))?(?:\s+\[([^\]]*)\])?\s*$/.exec(raw);
281
+ if (entry && current) {
282
+ current.plugins.push({
283
+ name: entry[1],
284
+ version: entry[2] ?? null,
285
+ installable: !(entry[3] ?? "").includes("not installable"),
286
+ });
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+ /** Exported for tests: the current stored inventory, if a reload happened. */
292
+ export { storedInventory };
293
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Bash-tool sandbox wiring: decides whether a bash command runs
3
+ * sandboxed, and provides the spawnHook that wraps the command with the OS
4
+ * sandbox (srt) while pi's local exec backend keeps owning process
5
+ * lifecycle, timeout, and abort handling.
6
+ *
7
+ * The wrap uses pi's BashToolOptions.spawnHook (command/cwd/env rewrite
8
+ * before spawn) rather than replacing BashOperations wholesale — pi's
9
+ * battle-tested exec backend stays in charge; only the command string and
10
+ * proxy env change. user_bash (! commands) routes through the same wrap.
11
+ *
12
+ `dangerouslyDisableSandbox` mirrors Claude Code's bash tool parameter: the
13
+ * model may set it when it has evidence a sandbox restriction caused a
14
+ * failure; the parameter NEVER skips the permission gate (gate.ts routes the
15
+ * retry through the normal execPolicy/Guardian flow) — it only skips the OS
16
+ * wrap, and only when sandbox.allowUnsandboxedCommands is true.
17
+ *
18
+ * Child-process stdio-grace handling derived from pi-sandbox (MIT,
19
+ * carderne/pi-sandbox src/sandbox-runtime.ts) — not needed here because pi's
20
+ * local backend owns the child; the reference is credited for the operational
21
+ * lessons (process-group kill, stdio release).
22
+ */
23
+ import type { BashSpawnHook, BashToolInput } from "@earendil-works/pi-coding-agent";
24
+ import type { YagniSandboxManager } from "./manager.js";
25
+ import type { SandboxSettings } from "./config.js";
26
+ export interface SandboxBashInput extends BashToolInput {
27
+ /** Claude Code parity: model-requested unsandboxed retry. */
28
+ dangerouslyDisableSandbox?: boolean;
29
+ }
30
+ /**
31
+ * Does an excludedCommands pattern match any segment of the command?
32
+ * (Compound-aware: "echo hi && docker ps" must not ride an "echo:*" exclude.)
33
+ */
34
+ export declare function commandHasExcludedSegment(command: string, excludedPatterns: readonly string[]): boolean;
35
+ /**
36
+ * Should this bash tool call run inside the OS sandbox?
37
+ * Pure — reads the decision inputs, no I/O.
38
+ */
39
+ export declare function shouldUseSandbox(input: Pick<SandboxBashInput, "command" | "dangerouslyDisableSandbox">, manager: Pick<YagniSandboxManager, "initialized">, settings: Pick<SandboxSettings, "allowUnsandboxedCommands" | "excludedCommands">): boolean;
40
+ /**
41
+ * Build the spawnHook that wraps the command with the sandbox. The hook is
42
+ * async-ignoring by design: spawnHook is sync in pi's interface, so the
43
+ * command wrap must already be resolved — use preWrappedCommand() below to
44
+ * pre-wrap and stash, or build the hook from a pre-wrapped string.
45
+ *
46
+ * In practice we pre-wrap in tool_call (async, where we can await the wrap)
47
+ * by mutating event.input.command to the wrapped string, and the spawnHook
48
+ * only injects proxy env. That keeps a single code path for tool calls.
49
+ */
50
+ /**
51
+ * Env additions every sandboxed process needs (srt injects proxy vars into
52
+ * the WRAPPED command's env; the spawnHook passes them through).
53
+ */
54
+ export declare function sandboxSpawnEnv(manager: YagniSandboxManager): Record<string, string>;
55
+ /**
56
+ * spawnHook for the sandboxed bash tool: injects proxy-support env vars.
57
+ * The command itself was already wrapped (pre-wrap in tool_call, async).
58
+ */
59
+ export declare function makeSandboxSpawnHook(manager: YagniSandboxManager): BashSpawnHook;
60
+ /**
61
+ * Wrap a raw command for OS-sandboxed execution. Returns the original string
62
+ * when the manager is not initialized (fail-open to plain execution — the
63
+ * permission gate still ran; the sandbox simply is not active).
64
+ */
65
+ export declare function preWrappedCommand(manager: YagniSandboxManager, command: string, binShell?: string): Promise<string>;
66
+ /**
67
+ * Detect "Operation not permitted" style sandbox denials in bash output so
68
+ * callers (tool_result) can annotate and the model can react. Returns the
69
+ * offending path when found (pi-sandbox's regex, credited MIT).
70
+ */
71
+ export declare function extractBlockedWritePath(output: string): string | null;
72
+ /**
73
+ * Wrap decision for user_bash (! commands): same rules as the tool, minus
74
+ * the dangerouslyDisableSandbox parameter (users running ! commands are
75
+ * already the principal, not the model).
76
+ */
77
+ export declare function shouldUseSandboxForUserCommand(command: string, manager: Pick<YagniSandboxManager, "initialized">, settings: Pick<SandboxSettings, "excludedCommands">): boolean;
78
+ /**
79
+ * Post-execution violation annotation: annotate a command's combined output
80
+ * with sandbox violation details when the sandbox is active. Returns the
81
+ * annotated output (or the original when nothing to annotate).
82
+ */
83
+ export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string): string;
84
+ /** cwd must exist before spawn (guard borrowed from pi's local ops). */
85
+ export declare function assertSpawnableCwd(cwd: string): void;
86
+ /**
87
+ * The gate's sandbox auto-allow decision — the production predicate index.ts
88
+ * wires into registerPermissionGate's sandboxAutoAllow dep. Exported so the
89
+ * precedence table is pinned by real tests, not mirrored tautologies.
90
+ *
91
+ * Declines (returns false): non-bash tools, autoAllow disabled by setting,
92
+ * manager not initialized, empty command, and — critically —
93
+ * dangerouslyDisableSandbox retries that will run UNSANDBOXED (they must pay
94
+ * the normal permission price; only strict-mode-forced sandboxing rides the
95
+ * auto-allow). Otherwise: shouldUseSandbox's decision (excludedCommands
96
+ * included — an excluded command runs unsandboxed through the ordinary flow).
97
+ */
98
+ export declare function sandboxAutoAllowDecision(toolName: string, params: Record<string, unknown>, manager: Pick<YagniSandboxManager, "initialized">, settings: Pick<SandboxSettings, "autoAllowBashIfSandboxed" | "allowUnsandboxedCommands" | "excludedCommands">): boolean;
99
+ //# sourceMappingURL=bash.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Bash-tool sandbox wiring: decides whether a bash command runs
3
+ * sandboxed, and provides the spawnHook that wraps the command with the OS
4
+ * sandbox (srt) while pi's local exec backend keeps owning process
5
+ * lifecycle, timeout, and abort handling.
6
+ *
7
+ * The wrap uses pi's BashToolOptions.spawnHook (command/cwd/env rewrite
8
+ * before spawn) rather than replacing BashOperations wholesale — pi's
9
+ * battle-tested exec backend stays in charge; only the command string and
10
+ * proxy env change. user_bash (! commands) routes through the same wrap.
11
+ *
12
+ `dangerouslyDisableSandbox` mirrors Claude Code's bash tool parameter: the
13
+ * model may set it when it has evidence a sandbox restriction caused a
14
+ * failure; the parameter NEVER skips the permission gate (gate.ts routes the
15
+ * retry through the normal execPolicy/Guardian flow) — it only skips the OS
16
+ * wrap, and only when sandbox.allowUnsandboxedCommands is true.
17
+ *
18
+ * Child-process stdio-grace handling derived from pi-sandbox (MIT,
19
+ * carderne/pi-sandbox src/sandbox-runtime.ts) — not needed here because pi's
20
+ * local backend owns the child; the reference is credited for the operational
21
+ * lessons (process-group kill, stdio release).
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ function stripLeadingSafeEnvVars(command) {
25
+ // Best-effort normalization for excludedCommands matching (not a security
26
+ // boundary — the permission gate is). Strips leading VAR=val pairs whose
27
+ // name can't affect binary resolution.
28
+ const SAFE = /^[A-Z_][A-Z0-9_]*=(?:[A-Za-z0-9_@./:+~-]+|"[^"\n]*"|'[^'\n]*')/;
29
+ let out = command.trimStart();
30
+ for (;;) {
31
+ const m = out.match(SAFE);
32
+ if (!m)
33
+ return out;
34
+ out = out.slice(m[0].length).trimStart();
35
+ }
36
+ }
37
+ function commandSegments(command) {
38
+ // Split on shell separators for excludedCommands matching against compound
39
+ // commands. Deliberately naive (no quote-awareness beyond the safe-env
40
+ // strip): excludedCommands is a convenience, never a security boundary.
41
+ // & (background) joins ;, &&, ||, | — a compound rider must not smuggle an
42
+ // excluded command's segment past the match.
43
+ return command
44
+ .split(/(?:\n|&&|\|\||;|\||&)/)
45
+ .map((s) => stripLeadingSafeEnvVars(s).trim())
46
+ .filter(Boolean);
47
+ }
48
+ function matchesExcludedPattern(pattern, candidate) {
49
+ if (pattern.endsWith(":*")) {
50
+ const prefix = pattern.slice(0, -2);
51
+ return candidate === prefix || candidate.startsWith(prefix + " ");
52
+ }
53
+ return candidate === pattern;
54
+ }
55
+ /**
56
+ * Does an excludedCommands pattern match any segment of the command?
57
+ * (Compound-aware: "echo hi && docker ps" must not ride an "echo:*" exclude.)
58
+ */
59
+ export function commandHasExcludedSegment(command, excludedPatterns) {
60
+ if (excludedPatterns.length === 0)
61
+ return false;
62
+ const segments = commandSegments(command);
63
+ return segments.some((seg) => excludedPatterns.some((p) => matchesExcludedPattern(p, seg)));
64
+ }
65
+ /**
66
+ * Should this bash tool call run inside the OS sandbox?
67
+ * Pure — reads the decision inputs, no I/O.
68
+ */
69
+ export function shouldUseSandbox(input, manager, settings) {
70
+ if (!manager.initialized)
71
+ return false;
72
+ if (!input.command)
73
+ return false;
74
+ if (input.dangerouslyDisableSandbox && settings.allowUnsandboxedCommands !== false)
75
+ return false;
76
+ if (commandHasExcludedSegment(input.command, settings.excludedCommands ?? []))
77
+ return false;
78
+ return true;
79
+ }
80
+ /**
81
+ * Build the spawnHook that wraps the command with the sandbox. The hook is
82
+ * async-ignoring by design: spawnHook is sync in pi's interface, so the
83
+ * command wrap must already be resolved — use preWrappedCommand() below to
84
+ * pre-wrap and stash, or build the hook from a pre-wrapped string.
85
+ *
86
+ * In practice we pre-wrap in tool_call (async, where we can await the wrap)
87
+ * by mutating event.input.command to the wrapped string, and the spawnHook
88
+ * only injects proxy env. That keeps a single code path for tool calls.
89
+ */
90
+ /**
91
+ * Env additions every sandboxed process needs (srt injects proxy vars into
92
+ * the WRAPPED command's env; the spawnHook passes them through).
93
+ */
94
+ export function sandboxSpawnEnv(manager) {
95
+ const env = {};
96
+ // Node >= 22.21 / >= 24: make undici honor proxy env vars, so fetch() in
97
+ // sandboxed node processes routes through the srt proxy like curl/git do.
98
+ const [major, minor] = process.versions.node.split(".").map(Number);
99
+ if ((major === 22 && minor >= 21) || major >= 24) {
100
+ env.NODE_USE_ENV_PROXY = "1";
101
+ }
102
+ return env;
103
+ }
104
+ /**
105
+ * spawnHook for the sandboxed bash tool: injects proxy-support env vars.
106
+ * The command itself was already wrapped (pre-wrap in tool_call, async).
107
+ */
108
+ export function makeSandboxSpawnHook(manager) {
109
+ const extraEnv = sandboxSpawnEnv(manager);
110
+ return (ctx) => ({
111
+ ...ctx,
112
+ env: { ...ctx.env, ...extraEnv },
113
+ });
114
+ }
115
+ /**
116
+ * Wrap a raw command for OS-sandboxed execution. Returns the original string
117
+ * when the manager is not initialized (fail-open to plain execution — the
118
+ * permission gate still ran; the sandbox simply is not active).
119
+ */
120
+ export async function preWrappedCommand(manager, command, binShell) {
121
+ if (!manager.initialized)
122
+ return command;
123
+ return manager.wrapWithSandbox(command, binShell);
124
+ }
125
+ /**
126
+ * Detect "Operation not permitted" style sandbox denials in bash output so
127
+ * callers (tool_result) can annotate and the model can react. Returns the
128
+ * offending path when found (pi-sandbox's regex, credited MIT).
129
+ */
130
+ export function extractBlockedWritePath(output) {
131
+ const match = output.match(/(?:\/bin\/bash|bash|sh): (?:line \d+: )?(\/[^\s:]+): Operation not permitted/);
132
+ return match ? match[1] : null;
133
+ }
134
+ /**
135
+ * Wrap decision for user_bash (! commands): same rules as the tool, minus
136
+ * the dangerouslyDisableSandbox parameter (users running ! commands are
137
+ * already the principal, not the model).
138
+ */
139
+ export function shouldUseSandboxForUserCommand(command, manager, settings) {
140
+ if (!manager.initialized)
141
+ return false;
142
+ if (!command)
143
+ return false;
144
+ return !commandHasExcludedSegment(command, settings.excludedCommands ?? []);
145
+ }
146
+ /**
147
+ * Post-execution violation annotation: annotate a command's combined output
148
+ * with sandbox violation details when the sandbox is active. Returns the
149
+ * annotated output (or the original when nothing to annotate).
150
+ */
151
+ export function annotateCommandOutput(manager, command, output) {
152
+ if (!manager.initialized)
153
+ return output;
154
+ if (!output.includes("Operation not permitted"))
155
+ return output;
156
+ return manager.annotateStderrWithSandboxFailures(command, output);
157
+ }
158
+ /** cwd must exist before spawn (guard borrowed from pi's local ops). */
159
+ export function assertSpawnableCwd(cwd) {
160
+ if (!existsSync(cwd))
161
+ throw new Error(`Working directory does not exist: ${cwd}`);
162
+ }
163
+ /**
164
+ * The gate's sandbox auto-allow decision — the production predicate index.ts
165
+ * wires into registerPermissionGate's sandboxAutoAllow dep. Exported so the
166
+ * precedence table is pinned by real tests, not mirrored tautologies.
167
+ *
168
+ * Declines (returns false): non-bash tools, autoAllow disabled by setting,
169
+ * manager not initialized, empty command, and — critically —
170
+ * dangerouslyDisableSandbox retries that will run UNSANDBOXED (they must pay
171
+ * the normal permission price; only strict-mode-forced sandboxing rides the
172
+ * auto-allow). Otherwise: shouldUseSandbox's decision (excludedCommands
173
+ * included — an excluded command runs unsandboxed through the ordinary flow).
174
+ */
175
+ export function sandboxAutoAllowDecision(toolName, params, manager, settings) {
176
+ if (toolName !== "bash")
177
+ return false;
178
+ if (settings.autoAllowBashIfSandboxed === false)
179
+ return false;
180
+ if (!manager.initialized)
181
+ return false;
182
+ const command = typeof params.command === "string" ? params.command : "";
183
+ if (!command)
184
+ return false;
185
+ const retryUnsandboxed = params.dangerouslyDisableSandbox === true && settings.allowUnsandboxedCommands !== false;
186
+ if (retryUnsandboxed)
187
+ return false;
188
+ return shouldUseSandbox({ command, dangerouslyDisableSandbox: params.dangerouslyDisableSandbox === true }, manager, settings);
189
+ }
190
+ //# sourceMappingURL=bash.js.map