@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.
- package/README.md +84 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +13 -0
- package/dist/extension/index.js +230 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +97 -2
- package/dist/extension/permission/gate.js +375 -26
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/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
|