@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,216 @@
1
+ /**
2
+ * Sandbox manager adapter — wraps @anthropic-ai/sandbox-runtime's
3
+ * SandboxManager with YAGNI Code wiring: dependency check against the
4
+ * vendored ripgrep, settings-derived runtime config (config.ts merge), and
5
+ * an injectable network ask-callback so the UI layer owns prompting (M5)
6
+ * while headless fails closed (deny).
7
+ *
8
+ * All state is session-scoped behind a single holder; nothing module-global
9
+ * is mutated except the underlying srt manager, which initialize/reset
10
+ * bracket. Pure functions (config building) live in config.ts.
11
+ */
12
+ import { SandboxManager as SrtSandboxManager } from "@anthropic-ai/sandbox-runtime";
13
+ import { logEvent } from "../errorSink.js";
14
+ import { codeStateHome } from "../stateHome.js";
15
+ import { loadSandboxSettings, mergeRulesIntoSandbox, } from "./config.js";
16
+ /** Default vendored rg shipped with the CLI install. */
17
+ export function defaultRgPath(env = process.env) {
18
+ const agentDir = env.PI_CODING_AGENT_DIR;
19
+ if (!agentDir)
20
+ return undefined;
21
+ return `${agentDir.replace(/\/$/, "")}/bin/rg`;
22
+ }
23
+ export class YagniSandboxManager {
24
+ state = { initialized: false, dependencies: null, settings: null };
25
+ askHandler = null;
26
+ opts;
27
+ constructor(opts) {
28
+ this.opts = opts;
29
+ this.askHandler = opts.askHandler ?? null;
30
+ }
31
+ get initialized() { return this.state.initialized; }
32
+ get settings() { return this.state.settings; }
33
+ get dependencies() { return this.state.dependencies; }
34
+ setAskHandler(handler) {
35
+ this.askHandler = handler;
36
+ }
37
+ /** The failure-trail wrapper handed to srt — exported for tests so the
38
+ * catch-and-log contract (fail closed + sink line) is observable without
39
+ * driving a real wrapped network connection. */
40
+ async networkAsk(host) {
41
+ if (!this.askHandler)
42
+ return false; // fail closed (headless)
43
+ try {
44
+ return await this.askHandler(host);
45
+ }
46
+ catch (err) {
47
+ // Fail closed, but never silently — a denied host with no trail is
48
+ // undebuggable. Metadata only (host + error class): no command content.
49
+ logEvent({
50
+ source: "sandbox",
51
+ level: "warn",
52
+ event: "network_ask_failed",
53
+ fields: {
54
+ host,
55
+ errorClass: err instanceof Error ? err.constructor.name : String(err),
56
+ },
57
+ });
58
+ return false;
59
+ }
60
+ }
61
+ checkDependencies() {
62
+ // No explicit rgPath/PI_CODING_AGENT_DIR: fall through to srt's own check,
63
+ // which uses { command: 'rg' } and consults PATH — rg on PATH is a
64
+ // legitimate resolution (CI runners, bare-metal Linux installs). The
65
+ // vendored-rg case is the explicit path above.
66
+ const rg = this.opts.rgPath ?? defaultRgPath(this.opts.env ?? process.env);
67
+ const check = rg
68
+ ? SrtSandboxManager.checkDependencies({ command: rg, args: ["--version"] })
69
+ : SrtSandboxManager.checkDependencies();
70
+ return { errors: [...check.errors], warnings: [...check.warnings] };
71
+ }
72
+ /**
73
+ * Build the runtime filesystem/network config from current settings +
74
+ * permission rules. Exposed for diagnostics (/sandbox config display) and
75
+ * tests; initialize() consumes it internally.
76
+ */
77
+ buildRuntimeMerge(rules) {
78
+ const { settings } = loadSandboxSettings({
79
+ cwd: this.opts.cwd,
80
+ env: this.opts.env,
81
+ userHome: this.opts.userHome,
82
+ stateHomeOverride: this.opts.stateHomeOverride,
83
+ });
84
+ return mergeRulesIntoSandbox(settings, rules, {
85
+ cwd: this.opts.cwd,
86
+ userStateHome: this.opts.stateHomeOverride ?? codeStateHome(null, this.opts.env, this.opts.userHome),
87
+ projectRoot: this.opts.projectRoot ?? null,
88
+ homeDir: this.opts.userHome,
89
+ });
90
+ }
91
+ runtimeConfig(merge, settings) {
92
+ return {
93
+ network: {
94
+ allowedDomains: merge.network.allowedDomains,
95
+ deniedDomains: merge.network.deniedDomains,
96
+ allowUnixSockets: merge.network.allowUnixSockets,
97
+ allowAllUnixSockets: merge.network.allowAllUnixSockets,
98
+ allowLocalBinding: merge.network.allowLocalBinding,
99
+ httpProxyPort: merge.network.httpProxyPort,
100
+ socksProxyPort: merge.network.socksProxyPort,
101
+ },
102
+ filesystem: {
103
+ allowRead: merge.filesystem.allowRead,
104
+ denyRead: merge.filesystem.denyRead,
105
+ allowWrite: merge.filesystem.allowWrite,
106
+ denyWrite: merge.filesystem.denyWrite,
107
+ },
108
+ ignoreViolations: settings.ignoreViolations,
109
+ enableWeakerNestedSandbox: settings.enableWeakerNestedSandbox,
110
+ enableWeakerNetworkIsolation: settings.enableWeakerNetworkIsolation,
111
+ };
112
+ }
113
+ /**
114
+ * Initialize srt for this session. Returns an error string when the sandbox
115
+ * cannot start (deps/platform) — the caller decides fail-soft (notify) vs
116
+ * fail-hard (refuse to run, `failIfUnavailable`). Idempotent: a second call
117
+ * while initialized is a no-op.
118
+ *
119
+ * `force` bypasses the disk `enabled` check: the session-level toggle
120
+ * (/sandbox enable, Alt+S) must be able to START the sandbox in a
121
+ * default-off config — that is its entire purpose. Config-driven starts
122
+ * (session_start) never pass force.
123
+ */
124
+ async initialize(rules, opts = {}) {
125
+ if (this.state.initialized)
126
+ return undefined;
127
+ // Config-first (Claude's order): a sandbox the user did not enable must
128
+ // never report a dependency problem — disabled is disabled regardless of
129
+ // the machine's health. `force` (session toggle) reads config the same
130
+ // way; the dep check below still guards the actual start.
131
+ const load = loadSandboxSettings({
132
+ cwd: this.opts.cwd,
133
+ env: this.opts.env,
134
+ userHome: this.opts.userHome,
135
+ stateHomeOverride: this.opts.stateHomeOverride,
136
+ });
137
+ if (!load.settings.enabled && !opts.force)
138
+ return "sandbox disabled by config";
139
+ const deps = this.checkDependencies();
140
+ this.state.dependencies = deps;
141
+ if (deps.errors.length > 0)
142
+ return `sandbox dependencies unavailable: ${deps.errors.join(", ")}`;
143
+ const merge = this.buildRuntimeMerge(rules);
144
+ const ask = async (hostPattern) => this.networkAsk(hostPattern.host);
145
+ try {
146
+ await SrtSandboxManager.initialize(this.runtimeConfig(merge, load.settings), ask);
147
+ }
148
+ catch (err) {
149
+ this.state.dependencies = null;
150
+ return `sandbox initialization failed: ${err instanceof Error ? err.message : String(err)}`;
151
+ }
152
+ this.state.initialized = true;
153
+ this.state.settings = load.settings;
154
+ this.opts.events?.onStateChange?.(true);
155
+ return undefined;
156
+ }
157
+ /**
158
+ * Rebuild + push the srt config without reset (settings changed or a grant
159
+ * landed). No-op when not initialized.
160
+ */
161
+ refreshConfig(rules) {
162
+ if (!this.state.initialized)
163
+ return;
164
+ const merge = this.buildRuntimeMerge(rules);
165
+ SrtSandboxManager.updateConfig(this.runtimeConfig(merge, loadSandboxSettings({
166
+ cwd: this.opts.cwd,
167
+ env: this.opts.env,
168
+ userHome: this.opts.userHome,
169
+ stateHomeOverride: this.opts.stateHomeOverride,
170
+ }).settings));
171
+ }
172
+ /**
173
+ * Re-init from scratch (config grant changed semantics srt can't hot-swap,
174
+ * or a toggle). Tears down proxies + violation store first.
175
+ */
176
+ async reinitialize(rules) {
177
+ if (this.state.initialized) {
178
+ await this.reset();
179
+ }
180
+ return this.initialize(rules);
181
+ }
182
+ /** Tear down srt (proxies, violation store, seatbelt state). */
183
+ async reset() {
184
+ this.state = { initialized: false, dependencies: null, settings: null };
185
+ try {
186
+ await SrtSandboxManager.reset();
187
+ }
188
+ catch {
189
+ // teardown errors are non-fatal; state is already cleared
190
+ }
191
+ this.opts.events?.onStateChange?.(false);
192
+ }
193
+ // --- command wrapping (thin, typed passthroughs) ---
194
+ async wrapWithSandbox(command, binShell) {
195
+ if (!this.state.initialized)
196
+ return command;
197
+ return SrtSandboxManager.wrapWithSandbox(command, binShell);
198
+ }
199
+ cleanupAfterCommand() {
200
+ if (!this.state.initialized)
201
+ return;
202
+ SrtSandboxManager.cleanupAfterCommand();
203
+ }
204
+ annotateStderrWithSandboxFailures(command, stderr) {
205
+ if (!this.state.initialized)
206
+ return stderr;
207
+ return SrtSandboxManager.annotateStderrWithSandboxFailures(command, stderr);
208
+ }
209
+ getProxyPort() {
210
+ return this.state.initialized ? SrtSandboxManager.getProxyPort() : undefined;
211
+ }
212
+ getSocksProxyPort() {
213
+ return this.state.initialized ? SrtSandboxManager.getSocksProxyPort() : undefined;
214
+ }
215
+ }
216
+ //# sourceMappingURL=manager.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * /sandbox interactive TUI panel — mirrors Claude Code's SandboxSettings
3
+ * tabs (Mode / Overrides / Config, plus Dependencies when checkDependencies
4
+ * reports errors). Rendered via ctx.ui.custom() in TUI mode; RPC/headless
5
+ * keeps the text summary (custom() is unavailable there).
6
+ *
7
+ * Structure follows Claude's components (SandboxSettings/SandboxModeTab/
8
+ * SandboxOverridesTab/SandboxConfigTab): tabs switched with left/right,
9
+ * SelectList-driven option tabs, read-only config tab, off-state
10
+ * placeholders on Overrides/Config. Copy mirrors Claude's where the
11
+ * mechanism is identical; persistence deviates deliberately (see
12
+ * persistSandboxMode in session.ts) until the project-local settings tier
13
+ * lands.
14
+ *
15
+ * Input contract (verified against pi's interactive mode): pi focuses the
16
+ * ROOT component and calls only root.handleInput(data). Children without
17
+ * their own handleInput (Box, Text) are never consulted — so the panel
18
+ * keeps a direct reference to the active SelectList and forwards keys to
19
+ * it. A Box wrapper does NOT forward input.
20
+ */
21
+ import { Container, type SelectItem } from "@earendil-works/pi-tui";
22
+ import { type SandboxRuntimeMerge, type SandboxRuntimePaths, type SandboxSettings } from "./config.js";
23
+ import type { RenderTheme } from "../subagentRender.js";
24
+ import type { PermissionRule } from "../permissionRules/loadConfig.js";
25
+ export type SandboxPanelTab = "mode" | "overrides" | "config" | "dependencies";
26
+ export type SandboxModeChoice = "auto-allow" | "regular" | "disabled";
27
+ export type SandboxOverrideChoice = "open" | "closed";
28
+ /** Everything the panel needs, resolved by the caller (session.ts). */
29
+ export interface SandboxPanelState {
30
+ settings: SandboxSettings;
31
+ /** Session-only toggle state (false = forced off this session). */
32
+ sessionToggledOff: boolean;
33
+ /** Dependencies status for the Dependencies tab + warnings on Config. */
34
+ dependencyErrors: string[];
35
+ dependencyWarnings: string[];
36
+ /** Runtime merge output (rules + settings) for the Config tab. */
37
+ merge: SandboxRuntimeMerge;
38
+ /** Domains granted this session (memory-only). */
39
+ sessionGrants: string[];
40
+ }
41
+ export interface SandboxPanelActions {
42
+ /** Persist a mode choice. Resolves with the confirmation to surface
43
+ * after the panel closes (Claude's onComplete(message) contract). */
44
+ onModeSelect: (choice: SandboxModeChoice) => Promise<SandboxPanelOutcome>;
45
+ /** Persist an override choice. Same contract. */
46
+ onOverrideSelect: (choice: SandboxOverrideChoice) => Promise<SandboxPanelOutcome>;
47
+ }
48
+ /** What the panel hands back when it closes: the confirmation (or error)
49
+ * the session surfaces via notify once custom() resolves. */
50
+ export interface SandboxPanelOutcome {
51
+ text: string;
52
+ level: "info" | "error";
53
+ }
54
+ /** Current mode derived from resolved settings (Claude's currentMode). */
55
+ export declare function deriveCurrentMode(settings: SandboxSettings, sessionToggledOff: boolean): SandboxModeChoice;
56
+ export declare function deriveCurrentOverride(settings: SandboxSettings): SandboxOverrideChoice;
57
+ export declare function modeOptions(current: SandboxModeChoice): SelectItem[];
58
+ export declare function overrideOptions(current: SandboxOverrideChoice): SelectItem[];
59
+ /** Per-mode explanation (Claude's copy, adapted to our surfaces). */
60
+ export declare function modeExplanation(mode: SandboxModeChoice): string;
61
+ export declare function overrideExplanation(choice: SandboxOverrideChoice): string;
62
+ /**
63
+ * Config tab sections, conditional like Claude's SandboxConfigTab: empty
64
+ * sections are omitted entirely (Excluded Commands — Claude's one always-on
65
+ * section — shows None). Structured as title + detail lines so the tab can
66
+ * space sections apart instead of rendering one dense text block.
67
+ */
68
+ export interface ConfigSection {
69
+ title: string;
70
+ detail: string[];
71
+ }
72
+ export declare function configSections(state: SandboxPanelState): ConfigSection[];
73
+ /** Tabs offered, mirroring Claude: Dependencies appears only on errors. */
74
+ export declare function panelTabs(dependencyErrors: string[]): SandboxPanelTab[];
75
+ /**
76
+ * The /sandbox panel. Structure: top border · tab strip · blank · tab
77
+ * content · blank · footer · bottom border. pi focuses this root and calls
78
+ * its handleInput; the panel forwards keys to the active tab's SelectList
79
+ * (when one exists) and switches tabs on left/right.
80
+ */
81
+ export declare class SandboxPanel extends Container {
82
+ private readonly state;
83
+ private readonly actions;
84
+ private readonly theme;
85
+ private readonly done;
86
+ private activeTab;
87
+ private readonly tabs;
88
+ /** The active tab's SelectList (mode/overrides) or null (config/deps) —
89
+ * input forwards here. */
90
+ private activeList;
91
+ /** Padded content frame; children[0] = tab strip, children[1] = body. */
92
+ private readonly content;
93
+ constructor(state: SandboxPanelState, actions: SandboxPanelActions, theme: RenderTheme, done: (outcome?: SandboxPanelOutcome) => void);
94
+ private setTab;
95
+ private buildBody;
96
+ private buildTabContent;
97
+ private buildModeTab;
98
+ private buildOverridesTab;
99
+ private buildConfigTab;
100
+ private buildDependenciesTab;
101
+ handleInput(data: string): void;
102
+ }
103
+ /**
104
+ * Build the panel state from the pieces session.ts already holds. Exported
105
+ * for unit tests; the TUI component consumes it.
106
+ */
107
+ export declare function buildPanelState(settings: SandboxSettings, sessionToggledOff: boolean, rules: readonly PermissionRule[], paths: SandboxRuntimePaths, sessionGrants: readonly string[], dependencyStatus: {
108
+ errors: string[];
109
+ warnings: string[];
110
+ }): SandboxPanelState;
111
+ //# sourceMappingURL=panel.d.ts.map
@@ -0,0 +1,342 @@
1
+ /**
2
+ * /sandbox interactive TUI panel — mirrors Claude Code's SandboxSettings
3
+ * tabs (Mode / Overrides / Config, plus Dependencies when checkDependencies
4
+ * reports errors). Rendered via ctx.ui.custom() in TUI mode; RPC/headless
5
+ * keeps the text summary (custom() is unavailable there).
6
+ *
7
+ * Structure follows Claude's components (SandboxSettings/SandboxModeTab/
8
+ * SandboxOverridesTab/SandboxConfigTab): tabs switched with left/right,
9
+ * SelectList-driven option tabs, read-only config tab, off-state
10
+ * placeholders on Overrides/Config. Copy mirrors Claude's where the
11
+ * mechanism is identical; persistence deviates deliberately (see
12
+ * persistSandboxMode in session.ts) until the project-local settings tier
13
+ * lands.
14
+ *
15
+ * Input contract (verified against pi's interactive mode): pi focuses the
16
+ * ROOT component and calls only root.handleInput(data). Children without
17
+ * their own handleInput (Box, Text) are never consulted — so the panel
18
+ * keeps a direct reference to the active SelectList and forwards keys to
19
+ * it. A Box wrapper does NOT forward input.
20
+ */
21
+ import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
22
+ import { mergeRulesIntoSandbox } from "./config.js";
23
+ // ---------------------------------------------------------------------------
24
+ // Pure derivation helpers (unit-tested; no TUI dependency)
25
+ // ---------------------------------------------------------------------------
26
+ /** Current mode derived from resolved settings (Claude's currentMode). */
27
+ export function deriveCurrentMode(settings, sessionToggledOff) {
28
+ if (!settings.enabled || sessionToggledOff)
29
+ return "disabled";
30
+ return settings.autoAllowBashIfSandboxed !== false ? "auto-allow" : "regular";
31
+ }
32
+ export function deriveCurrentOverride(settings) {
33
+ return settings.allowUnsandboxedCommands !== false ? "open" : "closed";
34
+ }
35
+ export function modeOptions(current) {
36
+ return [
37
+ { value: "auto-allow", label: `Sandbox BashTool, with auto-allow${current === "auto-allow" ? " (current)" : ""}` },
38
+ { value: "regular", label: `Sandbox BashTool, with regular permissions${current === "regular" ? " (current)" : ""}` },
39
+ { value: "disabled", label: `No Sandbox${current === "disabled" ? " (current)" : ""}` },
40
+ ];
41
+ }
42
+ export function overrideOptions(current) {
43
+ return [
44
+ { value: "open", label: `Allow unsandboxed fallback${current === "open" ? " (current)" : ""}` },
45
+ { value: "closed", label: `Strict sandbox mode${current === "closed" ? " (current)" : ""}` },
46
+ ];
47
+ }
48
+ /** Per-mode explanation (Claude's copy, adapted to our surfaces). */
49
+ export function modeExplanation(mode) {
50
+ switch (mode) {
51
+ case "auto-allow":
52
+ return "Bash commands run inside the OS sandbox and are auto-allowed; explicit deny/ask permission rules still win. Writes stay confined to the workspace and /tmp; network follows the domain allowlist.";
53
+ case "regular":
54
+ return "Bash commands run inside the OS sandbox, but each command still goes through the normal permission flow (sandboxed execution is not auto-allowed).";
55
+ case "disabled":
56
+ return "No OS-level sandbox. Bash commands run through the ordinary permission system only.";
57
+ }
58
+ }
59
+ export function overrideExplanation(choice) {
60
+ return choice === "open"
61
+ ? "When a command fails due to sandbox restrictions, the agent can retry with dangerouslyDisableSandbox to run outside the sandbox (falling back to default permissions)."
62
+ : "All bash commands invoked by the model must run in the sandbox unless they are explicitly listed in excludedCommands.";
63
+ }
64
+ export function configSections(state) {
65
+ const { settings, merge } = state;
66
+ const sections = [];
67
+ const excluded = settings.excludedCommands ?? [];
68
+ sections.push({
69
+ title: "Excluded Commands",
70
+ detail: [excluded.length > 0 ? excluded.join(", ") : "None"],
71
+ });
72
+ if (merge.filesystem.denyRead.length > 0) {
73
+ const detail = [`Denied: ${merge.filesystem.denyRead.join(", ")}`];
74
+ if (merge.filesystem.allowRead.length > 0)
75
+ detail.push(`Allowed within denied: ${merge.filesystem.allowRead.join(", ")}`);
76
+ sections.push({ title: "Filesystem Read Restrictions", detail });
77
+ }
78
+ if (merge.filesystem.allowWrite.length > 0) {
79
+ const detail = [`Allowed: ${merge.filesystem.allowWrite.join(", ")}`];
80
+ if (merge.filesystem.denyWrite.length > 0)
81
+ detail.push(`Denied within allowed: ${merge.filesystem.denyWrite.join(", ")}`);
82
+ sections.push({ title: "Filesystem Write Restrictions", detail });
83
+ }
84
+ if (merge.network.allowedDomains.length > 0 || merge.network.deniedDomains.length > 0) {
85
+ const detail = [];
86
+ if (merge.network.allowedDomains.length > 0)
87
+ detail.push(`Allowed: ${merge.network.allowedDomains.join(", ")}`);
88
+ if (merge.network.deniedDomains.length > 0)
89
+ detail.push(`Denied: ${merge.network.deniedDomains.join(", ")}`);
90
+ sections.push({ title: "Network Restrictions", detail });
91
+ }
92
+ const unixSockets = settings.network?.allowUnixSockets ?? [];
93
+ if (unixSockets.length > 0)
94
+ sections.push({ title: "Allowed Unix Sockets", detail: [unixSockets.join(", ")] });
95
+ if (state.sessionGrants.length > 0) {
96
+ sections.push({ title: "Session Grants (memory-only)", detail: [state.sessionGrants.join(", ")] });
97
+ }
98
+ return sections;
99
+ }
100
+ /** Tabs offered, mirroring Claude: Dependencies appears only on errors. */
101
+ export function panelTabs(dependencyErrors) {
102
+ const tabs = ["mode", "overrides", "config"];
103
+ if (dependencyErrors.length > 0)
104
+ tabs.push("dependencies");
105
+ return tabs;
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // TUI component
109
+ // ---------------------------------------------------------------------------
110
+ const TAB_LABELS = {
111
+ mode: "Mode",
112
+ overrides: "Overrides",
113
+ config: "Config",
114
+ dependencies: "Dependencies",
115
+ };
116
+ /** Horizontal breathing room for panel content — the same left/right
117
+ * margin the footer chip uses. The border lines span the full width;
118
+ * everything between them is inset by this. */
119
+ const CONTENT_PADDING_X = 2;
120
+ function selectListTheme(theme) {
121
+ return {
122
+ selectedPrefix: (t) => theme.fg("accent", t),
123
+ selectedText: (t) => theme.fg("accent", t),
124
+ description: (t) => theme.fg("muted", t),
125
+ scrollInfo: (t) => theme.fg("muted", t),
126
+ noMatch: (t) => theme.fg("muted", t),
127
+ };
128
+ }
129
+ /** Full-width border line (the house DynamicBorder pattern, color fn passed
130
+ * explicitly — extensions never read pi's global theme). */
131
+ class Border {
132
+ color;
133
+ constructor(color) {
134
+ this.color = color;
135
+ }
136
+ render(width) {
137
+ return [this.color("─".repeat(Math.max(1, width)))];
138
+ }
139
+ invalidate() { }
140
+ }
141
+ /**
142
+ * The /sandbox panel. Structure: top border · tab strip · blank · tab
143
+ * content · blank · footer · bottom border. pi focuses this root and calls
144
+ * its handleInput; the panel forwards keys to the active tab's SelectList
145
+ * (when one exists) and switches tabs on left/right.
146
+ */
147
+ export class SandboxPanel extends Container {
148
+ state;
149
+ actions;
150
+ theme;
151
+ done;
152
+ activeTab;
153
+ tabs;
154
+ /** The active tab's SelectList (mode/overrides) or null (config/deps) —
155
+ * input forwards here. */
156
+ activeList = null;
157
+ /** Padded content frame; children[0] = tab strip, children[1] = body. */
158
+ content;
159
+ constructor(state, actions, theme, done) {
160
+ super();
161
+ this.state = state;
162
+ this.actions = actions;
163
+ this.theme = theme;
164
+ this.done = done;
165
+ this.tabs = panelTabs(state.dependencyErrors);
166
+ this.activeTab = this.tabs[0];
167
+ this.content = new Box(CONTENT_PADDING_X, 0);
168
+ this.content.addChild(new Text("", 0, 0)); // tab strip placeholder
169
+ this.content.addChild(new Text("", 0, 0)); // body placeholder
170
+ this.addChild(new Border((s) => theme.fg("borderMuted", s)));
171
+ this.addChild(this.content);
172
+ this.addChild(new Border((s) => theme.fg("borderMuted", s)));
173
+ this.setTab(this.activeTab);
174
+ }
175
+ setTab(tab) {
176
+ this.activeTab = tab;
177
+ const t = this.theme;
178
+ const parts = this.tabs.map((candidate) => {
179
+ const active = candidate === tab;
180
+ return t.fg(active ? "accent" : "muted", `${active ? "[" : " "}${TAB_LABELS[candidate]}${active ? "]" : " "}`);
181
+ });
182
+ const strip = new HStack();
183
+ strip.addChild(new Text(parts.join(""), 0, 1));
184
+ this.content.children[0] = strip;
185
+ this.content.children[1] = this.buildBody();
186
+ this.invalidate();
187
+ }
188
+ buildBody() {
189
+ const wrap = new Container();
190
+ wrap.addChild(this.buildTabContent());
191
+ // Footer on its own line with a top margin — a distinct element, and
192
+ // consistent across every tab.
193
+ wrap.addChild(new Text(this.theme.fg("muted", "left/right to switch tabs · up/down to navigate · Enter to select · Esc to close"), 0, 1));
194
+ return wrap;
195
+ }
196
+ buildTabContent() {
197
+ const t = this.theme;
198
+ switch (this.activeTab) {
199
+ case "mode":
200
+ return this.buildModeTab();
201
+ case "overrides":
202
+ return this.buildOverridesTab();
203
+ case "config":
204
+ return this.buildConfigTab();
205
+ case "dependencies":
206
+ return this.buildDependenciesTab();
207
+ }
208
+ }
209
+ buildModeTab() {
210
+ const t = this.theme;
211
+ const current = deriveCurrentMode(this.state.settings, this.state.sessionToggledOff);
212
+ const options = modeOptions(current);
213
+ const list = new SelectList(options, 10, selectListTheme(t));
214
+ const preselect = options.findIndex((o) => o.value === current);
215
+ if (preselect >= 0)
216
+ list.setSelectedIndex(preselect);
217
+ // Top margin (paddingY=1 + the container's separator) separates the
218
+ // explanation from the option list — it's a distinct element and reads
219
+ // too tight flush to it.
220
+ const explanation = new Text(t.fg("muted", modeExplanation(current)), 0, 1);
221
+ list.onSelectionChange = (item) => {
222
+ explanation.setText(t.fg("muted", modeExplanation(item.value)));
223
+ this.invalidate();
224
+ };
225
+ list.onSelect = (item) => {
226
+ // The outcome (confirmation or write-failure) rides done() out of the
227
+ // panel; the session notifies it once custom() resolves — Claude's
228
+ // onComplete(message) flow. It must NOT be dropped here.
229
+ void this.actions
230
+ .onModeSelect(item.value)
231
+ .then((outcome) => this.done(outcome))
232
+ .catch(() => this.done());
233
+ };
234
+ list.onCancel = () => this.done();
235
+ this.activeList = list;
236
+ const wrap = new Container();
237
+ wrap.addChild(new Text(t.bold("Configure mode:"), 0, 0));
238
+ wrap.addChild(list);
239
+ wrap.addChild(explanation);
240
+ return wrap;
241
+ }
242
+ buildOverridesTab() {
243
+ const t = this.theme;
244
+ if (!this.state.settings.enabled) {
245
+ this.activeList = null;
246
+ return new Text(t.fg("muted", "Sandbox is not enabled. Enable sandbox to configure override settings."), 0, 0);
247
+ }
248
+ const current = deriveCurrentOverride(this.state.settings);
249
+ const options = overrideOptions(current);
250
+ const list = new SelectList(options, 10, selectListTheme(t));
251
+ const preselect = options.findIndex((o) => o.value === current);
252
+ if (preselect >= 0)
253
+ list.setSelectedIndex(preselect);
254
+ const explanation = new Text(t.fg("muted", overrideExplanation(current)), 0, 1);
255
+ list.onSelectionChange = (item) => {
256
+ explanation.setText(t.fg("muted", overrideExplanation(item.value)));
257
+ this.invalidate();
258
+ };
259
+ list.onSelect = (item) => {
260
+ void this.actions
261
+ .onOverrideSelect(item.value)
262
+ .then((outcome) => this.done(outcome))
263
+ .catch(() => this.done());
264
+ };
265
+ list.onCancel = () => this.done();
266
+ this.activeList = list;
267
+ const wrap = new Container();
268
+ wrap.addChild(new Text(t.bold("Configure Overrides:"), 0, 0));
269
+ wrap.addChild(list);
270
+ wrap.addChild(explanation);
271
+ return wrap;
272
+ }
273
+ buildConfigTab() {
274
+ const t = this.theme;
275
+ this.activeList = null;
276
+ if (!this.state.settings.enabled) {
277
+ const lines = [t.fg("muted", "Sandbox is not enabled")];
278
+ for (const w of this.state.dependencyWarnings)
279
+ lines.push(t.fg("muted", w));
280
+ return new Text(lines.join("\n"), 0, 0);
281
+ }
282
+ // One block per section, bold title + muted detail lines, with a blank
283
+ // line between sections — a structured read, not one dense paragraph.
284
+ // Sections are one Text block each (bold title + detail lines), and
285
+ // Text(paddingY) pads ABOVE and below — so section 1 stays flush and
286
+ // every later section carries the blank-line separation. No doubling:
287
+ // Container adds no separator of its own.
288
+ const wrap = new Container();
289
+ let first = true;
290
+ for (const section of configSections(this.state)) {
291
+ const block = [t.bold(section.title), ...section.detail.map((d) => t.fg("muted", d))].join("\n");
292
+ wrap.addChild(new Text(block, 0, first ? 0 : 1));
293
+ first = false;
294
+ }
295
+ if (this.state.dependencyWarnings.length > 0) {
296
+ wrap.addChild(new Text(this.state.dependencyWarnings.map((w) => t.fg("warning", w)).join("\n"), 0, 1));
297
+ }
298
+ return wrap;
299
+ }
300
+ buildDependenciesTab() {
301
+ const t = this.theme;
302
+ this.activeList = null;
303
+ const lines = this.state.dependencyErrors.map((e) => t.fg("error", e));
304
+ lines.push(...this.state.dependencyWarnings.map((w) => t.fg("warning", w)));
305
+ return new Text(lines.join("\n"), 0, 0);
306
+ }
307
+ handleInput(data) {
308
+ // Tab switching first — works from every tab (Claude's </> + arrows).
309
+ if (matchesKey(data, Key.left) || matchesKey(data, "<")) {
310
+ const i = this.tabs.indexOf(this.activeTab);
311
+ this.setTab(this.tabs[(i - 1 + this.tabs.length) % this.tabs.length]);
312
+ return;
313
+ }
314
+ if (matchesKey(data, Key.right) || matchesKey(data, ">")) {
315
+ const i = this.tabs.indexOf(this.activeTab);
316
+ this.setTab(this.tabs[(i + 1) % this.tabs.length]);
317
+ return;
318
+ }
319
+ if (matchesKey(data, Key.escape)) {
320
+ this.done();
321
+ return;
322
+ }
323
+ // Everything else (up/down/enter) goes to the active SelectList.
324
+ this.activeList?.handleInput(data);
325
+ this.invalidate();
326
+ }
327
+ }
328
+ /**
329
+ * Build the panel state from the pieces session.ts already holds. Exported
330
+ * for unit tests; the TUI component consumes it.
331
+ */
332
+ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessionGrants, dependencyStatus) {
333
+ return {
334
+ settings,
335
+ sessionToggledOff,
336
+ dependencyErrors: dependencyStatus.errors,
337
+ dependencyWarnings: dependencyStatus.warnings,
338
+ merge: mergeRulesIntoSandbox(settings, rules, paths),
339
+ sessionGrants: [...sessionGrants],
340
+ };
341
+ }
342
+ //# sourceMappingURL=panel.js.map