universal-opencode-sandbox 0.1.1 → 0.4.0

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 CHANGED
@@ -26,9 +26,11 @@ Requires OpenCode 2.x (the `@opencode/plugin` 2.x contract).
26
26
  ```
27
27
 
28
28
  The sandbox is on by default once the plugin is installed, in its strictest useful
29
- mode: the workspace is writable (and the system temp directory), everything else is
30
- read-only, the network is denied, and a command the sandbox cannot run is blocked
31
- rather than run unconfined.
29
+ mode: the workspace and the system temp directory are writable, everything else is
30
+ write-denied, the network is denied, credential stores are unreadable (see the
31
+ default list below), and a command the sandbox cannot run is blocked rather than
32
+ run unconfined. No configuration is required for that; the config file below only
33
+ changes it.
32
34
 
33
35
  ## Configure
34
36
 
@@ -45,18 +47,35 @@ on purpose: they can be set from a repository's own config.
45
47
  // "permissive" runs it unsandboxed with a warning.
46
48
  "mode": "enforce",
47
49
  "filesystem": {
48
- // "read-only" (default) denies every write; "workspace-write" allows writes to
49
- // the workspace, the temp directory, and writable_roots.
50
+ // "workspace-write" (default) allows writes to the workspace, the temp
51
+ // directory, and writable_roots; "read-only" denies every write.
50
52
  "mode": "workspace-write",
51
53
  "writable_roots": [],
52
- // Paths the sandboxed command can neither read nor write, e.g. credentials.
53
- "denied_paths": ["~/.ssh", "~/.aws"]
54
+ // Leave denied_paths unset to get the standard credential list below.
55
+ // Setting it replaces that list wholesale, so to add a path of your own,
56
+ // copy the standard list and extend it, for example:
57
+ // "denied_paths": ["~/.ssh", "~/.aws", "... the standard list below ...", "~/.config/my-cli"]
54
58
  },
55
59
  // "deny" (default) blocks all network access for sandboxed commands.
56
60
  "network": "deny",
57
61
  // What happens when a tool call asks to skip the sandbox (shell input
58
- // "sandbox: false"): "deny" (default) blocks it, "allow" runs it unsandboxed.
62
+ // "sandbox: false"): "deny" (default) blocks it; "ask" prompts you with the
63
+ // exact command and runs it unsandboxed only after your approval; "allow"
64
+ // runs it unsandboxed without asking. Per-project overrides only tighten it.
59
65
  "unsandboxed": "deny",
66
+ // Commands pre-approved to run unsandboxed without a prompt, with "*"
67
+ // wildcards. The persistent form of an approval, kept here in your config
68
+ // where it stays visible and reviewable. Applies in "ask" mode.
69
+ "unsandboxed_allow": ["git push *", "npm publish*"],
70
+ // Also sandbox user "!" commands and plugin shells, protecting the user from
71
+ // social engineering ("run this for me") and accidental damage. Default: false.
72
+ "sandbox_user_commands": true,
73
+ // Tool names the agent may not call at all; "*" is a wildcard. Use this to close
74
+ // escape routes outside the sandbox, such as a host app's unsandboxed terminal
75
+ // tools (an agent can otherwise spawn or drive a raw shell there). Unlike
76
+ // opencode's own permission rules, a repository config cannot re-allow these.
77
+ // See "Blocking host tools" below; for example, for the Paseo host app:
78
+ // "blocked_tools": ["paseo_create_terminal", "paseo_send_terminal_keys", "paseo_capture_terminal"],
60
79
  // Per-project overrides, keyed by the workspace's absolute path. Every field is
61
80
  // optional and can only make the policy stricter, never looser.
62
81
  "projects": {
@@ -67,6 +86,75 @@ on purpose: they can be set from a repository's own config.
67
86
 
68
87
  `UNIVERSAL_SANDBOX_CONFIG` (a JSON string) tightens the same fields per invocation.
69
88
 
89
+ ### Escaping the sandbox with approval
90
+
91
+ `unsandboxed: "ask"` registers an escape tool, `shell_unsandboxed`: one shell
92
+ command outside the sandbox, and every call needs your approval - the prompt
93
+ shows the exact command, and only an approval runs it. A rejection or a deny
94
+ rule fails the call. With `unsandboxed: "allow"` the tool runs commands without
95
+ asking; with `"deny"` (the default) the tool is not registered at all, so an
96
+ agent cannot even ask.
97
+
98
+ The prompt uses its own permission action, `unsandboxed`, so an allow rule for
99
+ `shell` never silently covers an escape - and you can pre-approve command
100
+ patterns through opencode's own permission rules, which the prompt then honors
101
+ instead of asking again. A deny rule for the action hides the tool entirely.
102
+ The prompt offers no persistent "always allow": every escape is approved per
103
+ call, and where a host still renders an "always allow" button it behaves
104
+ identically to "allow once" - the request carries no save scope, so nothing is
105
+ ever stored. The persistent form of an approval is `unsandboxed_allow` in this
106
+ plugin's own config, where it stays visible and reviewable.
107
+
108
+ What the prompt looks like is up to the host: opencode's terminal UI checks the
109
+ save scope and omits its "always allow" option entirely, so there the choice is
110
+ simply allow once or reject; the web and desktop apps still render the button,
111
+ which then approves just that call. The command is always visible somewhere -
112
+ in the request message where the host renders it, and in the tool call with
113
+ its full input in the timeline right above the prompt.
114
+
115
+ ```jsonc
116
+ // opencode.jsonc
117
+ {
118
+ "permissions": [
119
+ // Never ask again for these unsandboxed commands:
120
+ { "action": "unsandboxed", "resource": "git push *", "effect": "allow" },
121
+ // Or remove the escape route altogether (blocked_tools works too):
122
+ { "action": "unsandboxed", "resource": "*", "effect": "deny" }
123
+ ]
124
+ }
125
+ ```
126
+
127
+ ### Default denied paths
128
+
129
+ Without a `denied_paths` entry, the sandbox denies reading and writing these
130
+ credential stores. A stolen long-lived credential cannot be undone by a restart,
131
+ so reading is denied even though the network usually is too: a secret read once
132
+ can be copied into the workspace and walk out through a commit.
133
+
134
+ Paths that live in a platform's app-data directory are listed per platform; the
135
+ variants for the other platforms are nonexistent there and simply do nothing.
136
+
137
+ - `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.azure` - identity directories:
138
+ keys, cloud credentials, cluster tokens (identical on every platform)
139
+ - `~/.netrc` and `~/_netrc` - plaintext logins for curl and friends (the latter
140
+ is the Windows convention)
141
+ - `~/.config/gcloud`, `~/AppData/Roaming/gcloud` - GCP credentials and
142
+ service-account keys (Linux/macOS and Windows)
143
+ - `~/.npmrc`, `~/.pypirc`, `~/.cargo/credentials`, `~/.docker/config.json` -
144
+ registry tokens of the major package managers and Docker
145
+ - `~/.config/gh/hosts.yml`, `~/Library/Application Support/gh/hosts.yml`,
146
+ `~/AppData/Roaming/gh/hosts.yml` - the GitHub CLI token in each platform's
147
+ config-directory convention
148
+ - `~/.local/share/opencode/auth.json` - OpenCode provider API keys (OpenCode
149
+ uses this XDG path on every platform, Windows included)
150
+ - `~/.claude/.credentials.json`, `~/.codex/auth.json` - credentials of the
151
+ other agent CLIs
152
+
153
+ Setting `denied_paths` replaces this list. On Linux only paths that exist are
154
+ enforced (an upstream deny bug would otherwise create stray directories,
155
+ microsoft/mxc#1780); on macOS and Windows not-yet-existing paths are protected
156
+ too.
157
+
70
158
  ## What is enforced
71
159
 
72
160
  - **Filesystem**: in `workspace-write` mode, writes are allowed in the workspace and
@@ -86,6 +174,77 @@ on purpose: they can be set from a repository's own config.
86
174
  Linux), or a missing executor blocks the command in `enforce` mode instead of
87
175
  running it unconfined. `mode: "permissive"` trades that guarantee for convenience.
88
176
 
177
+ ## Sandboxed terminal
178
+
179
+ The package also ships `sandboxed-shell`, a small CLI that starts an **interactive
180
+ shell inside the same sandbox**: the same filesystem and network policy as the agent
181
+ commands, applied to everything you (or anything else) type into it.
182
+
183
+ ```bash
184
+ # Safe terminal: interactive zsh under Seatbelt/bubblewrap/ProcessContainer
185
+ sandboxed-shell --workspace /path/to/project
186
+ ```
187
+
188
+ This gives you two terminal types next to each other:
189
+
190
+ | Terminal type | How | What runs inside |
191
+ |---|---|---|
192
+ | Safe terminal | `sandboxed-shell` (or `exec` it in an existing terminal) | The MXC sandbox policy from your config |
193
+ | Admin terminal | your normal shell | Unconfined, for server restarts and admin work |
194
+
195
+ The safe terminal fails closed: if the sandbox cannot run, no shell is started, and
196
+ exiting it ends the session with no unsandboxed fallback. It inherits your
197
+ environment (PATH, TERM, HOME) so it behaves like a normal terminal; secrets in the
198
+ environment stay put because egress is denied and the filesystem policy still applies.
199
+
200
+ **Locking it down for other users**: the two-type design is exactly what lets you
201
+ disable the admin terminal for regular users. The plugin confines opencode agents;
202
+ terminals spawned by a host app (for example Paseo) are confined by having the host
203
+ start them through `sandboxed-shell` instead of a plain shell. Whoever controls
204
+ terminal creation controls the choice: point every terminal at the wrapper and nobody
205
+ gets an unsandboxed one. For the agent's own route to a host app's tools, see
206
+ [Blocking host tools](#blocking-host-tools).
207
+
208
+ ## Blocking host tools
209
+
210
+ The OS sandbox confines the processes it spawns, but a host app around opencode can
211
+ register tools that spawn processes of their own, outside the sandbox: a terminal
212
+ tool runs a raw shell, and an agent with access to it can type into one. Two layers
213
+ close that route. Both take the tool names as the agent sees them, and both support
214
+ `*` as a wildcard.
215
+
216
+ 1. `blocked_tools` in this plugin's config denies every matching tool call, whatever
217
+ asks for it. It is read only from user-owned config outside the workspace, so a
218
+ repository config cannot re-allow a blocked tool.
219
+
220
+ ```jsonc
221
+ // ~/.config/universal-opencode-sandbox/config.json
222
+ {
223
+ "blocked_tools": ["paseo_create_terminal", "paseo_send_terminal_keys", "paseo_capture_terminal"]
224
+ }
225
+ ```
226
+
227
+ 2. opencode's own `permissions` rules hide the tools from the agent's catalog
228
+ entirely, so the model never even sees them. Use the `permissions` array: the
229
+ older `permission` object is deprecated and, on current releases, silently
230
+ ignored.
231
+
232
+ ```jsonc
233
+ // opencode.jsonc - example: denying the Paseo terminal tools
234
+ {
235
+ "permissions": [
236
+ { "action": "paseo_create_terminal", "resource": "*", "effect": "deny" },
237
+ { "action": "paseo_send_terminal_keys", "resource": "*", "effect": "deny" },
238
+ { "action": "paseo_capture_terminal", "resource": "*", "effect": "deny" }
239
+ ]
240
+ }
241
+ ```
242
+
243
+ The names above are the Paseo terminal tools as a worked example; substitute the
244
+ tool names of your own host app, or use a wildcard such as `"paseo_*"`. Layer 1 is
245
+ the enforcement (a call cannot run), layer 2 the clean-up (the tool is not offered
246
+ to the model at all); together a repository config cannot talk its way around them.
247
+
89
248
  ## Known limitations
90
249
 
91
250
  - With the sandbox enabled, OpenCode's per-command shell permission rules see the
@@ -95,7 +254,13 @@ on purpose: they can be set from a repository's own config.
95
254
  commands, prefer this plugin's `denied_paths` and filesystem policy.
96
255
  - Intel macOS is unsupported: the MXC npm package ships no darwin-x64 runtime yet.
97
256
  - MXC release builds on Windows emit required Microsoft diagnostic telemetry; see the
98
- [MXC documentation](https://github.com/microsoft/mxc).
257
+ [MXC documentation](https://github.com/microsoft/mxc). Per MXC's field inventory
258
+ the events carry only operational metadata: the MXC version, the requested
259
+ sandbox kind and selected backend, run outcome, exit code, duration, a bounded
260
+ failure category, and random correlation identifiers. They explicitly do not
261
+ contain command lines, environment values, file paths, tokens, credentials,
262
+ sandbox output, or free-form error text. Telemetry is a no-op on macOS and
263
+ Linux: nothing leaves the host there.
99
264
  - MXC's process streams do not deliver under Bun (microsoft/mxc#1782). This plugin is
100
265
  unaffected: the executor is a native binary whose output the shell tool captures.
101
266
 
@@ -106,7 +271,10 @@ and Windows 11 (ProcessContainer): output and exit codes stream through, writes
106
271
  the workspace succeed, writes outside are denied, protected config files cannot be
107
272
  planted, and network egress is blocked (except the documented Windows gap). Also
108
273
  verified end to end inside a real OpenCode 2.2.7 session: an agent's shell command
109
- writes inside the workspace and is denied outside it.
274
+ writes inside the workspace and is denied outside it. The `sandboxed-shell` terminal
275
+ was verified interactively on macOS: commands run with full environment, writes inside
276
+ the workspace succeed, writes outside are denied, network egress is blocked, and
277
+ `denied_paths` (for example `~/.ssh`) cannot be read.
110
278
 
111
279
  ## License
112
280
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn } from "node:child_process";
5
+ import fs3 from "node:fs";
6
+ import os3 from "node:os";
7
+ import path3 from "node:path";
8
+
9
+ // src/policy.ts
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ var realHome = () => os.userInfo().homedir;
14
+ var defaultDeniedPaths = [
15
+ "~/.ssh",
16
+ "~/.aws",
17
+ "~/.gnupg",
18
+ "~/.netrc",
19
+ "~/_netrc",
20
+ "~/.kube",
21
+ "~/.azure",
22
+ "~/.config/gcloud",
23
+ "~/AppData/Roaming/gcloud",
24
+ "~/.npmrc",
25
+ "~/.pypirc",
26
+ "~/.cargo/credentials",
27
+ "~/.docker/config.json",
28
+ "~/.config/gh/hosts.yml",
29
+ "~/Library/Application Support/gh/hosts.yml",
30
+ "~/AppData/Roaming/gh/hosts.yml",
31
+ "~/.local/share/opencode/auth.json",
32
+ "~/.claude/.credentials.json",
33
+ "~/.codex/auth.json"
34
+ ];
35
+ var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
36
+ var configPath = () => path.join(configDirectory(), "config.json");
37
+ function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
38
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [], unsandboxedAllow: [] };
39
+ const global = readConfig(globalFile);
40
+ if (global)
41
+ merged = tighten(merged, global);
42
+ const project = global?.projects?.[workspace];
43
+ if (project)
44
+ merged = tighten(merged, project);
45
+ const injected = environment.UNIVERSAL_SANDBOX_CONFIG;
46
+ if (injected) {
47
+ try {
48
+ merged = tighten(merged, JSON.parse(injected));
49
+ } catch {}
50
+ }
51
+ return {
52
+ enabled: merged.enabled ?? true,
53
+ mode: merged.mode ?? "enforce",
54
+ filesystem: {
55
+ mode: merged.filesystemMode ?? "workspace-write",
56
+ writableRoots: [...new Set(merged.writableRoots)],
57
+ deniedPaths: merged.deniedPathsExplicit ? [...new Set(merged.deniedPaths)] : defaultDeniedPaths
58
+ },
59
+ network: merged.network ?? "deny",
60
+ unsandboxed: merged.unsandboxed ?? "deny",
61
+ userCommands: merged.userCommands ?? false,
62
+ blockedTools: [...new Set(merged.blockedTools)],
63
+ unsandboxedAllow: [...new Set(merged.unsandboxedAllow)]
64
+ };
65
+ }
66
+ function readConfig(file) {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(file, "utf8"));
69
+ } catch {
70
+ return;
71
+ }
72
+ }
73
+ function tighten(merged, raw) {
74
+ if (Array.isArray(raw.filesystem?.writable_roots)) {
75
+ for (const root of raw.filesystem.writable_roots)
76
+ if (typeof root === "string")
77
+ merged.writableRoots.push(root);
78
+ }
79
+ if (Array.isArray(raw.filesystem?.denied_paths)) {
80
+ const items = raw.filesystem.denied_paths.filter((item) => typeof item === "string");
81
+ merged.deniedPaths = merged.deniedPathsExplicit ? [...merged.deniedPaths, ...items] : items;
82
+ merged.deniedPathsExplicit = true;
83
+ }
84
+ if (Array.isArray(raw.blocked_tools)) {
85
+ for (const item of raw.blocked_tools)
86
+ if (typeof item === "string")
87
+ merged.blockedTools.push(item);
88
+ }
89
+ if (Array.isArray(raw.unsandboxed_allow)) {
90
+ for (const item of raw.unsandboxed_allow)
91
+ if (typeof item === "string")
92
+ merged.unsandboxedAllow.push(item);
93
+ }
94
+ return {
95
+ enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
96
+ mode: strictest(merged.mode, raw.mode, "enforce", "permissive"),
97
+ filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
98
+ network: strictest(merged.network, raw.network, "deny", "allow"),
99
+ unsandboxed: strictestUnsandboxed(merged.unsandboxed, raw.unsandboxed),
100
+ userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
101
+ blockedTools: merged.blockedTools,
102
+ unsandboxedAllow: merged.unsandboxedAllow,
103
+ writableRoots: merged.writableRoots,
104
+ deniedPaths: merged.deniedPaths,
105
+ deniedPathsExplicit: merged.deniedPathsExplicit
106
+ };
107
+ }
108
+ function strictest(current, incoming, strict, loose) {
109
+ if (current === strict || incoming === strict)
110
+ return strict;
111
+ if (incoming === loose)
112
+ return loose;
113
+ return current;
114
+ }
115
+ function strictestUnsandboxed(current, incoming) {
116
+ if (incoming !== "deny" && incoming !== "ask" && incoming !== "allow")
117
+ return current;
118
+ if (current === "deny" || incoming === "deny")
119
+ return "deny";
120
+ if (current === "ask" || incoming === "ask")
121
+ return "ask";
122
+ return "allow";
123
+ }
124
+ function expandHome(item, home = os.homedir()) {
125
+ return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
126
+ }
127
+ function matchesWildcard(value, pattern) {
128
+ const normalized = value.replaceAll("\\", "/");
129
+ let escaped = pattern.replaceAll("\\", "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
130
+ if (escaped.endsWith(" .*"))
131
+ escaped = escaped.slice(0, -3) + "( .*)?";
132
+ return new RegExp(`^${escaped}$`, "s").test(normalized);
133
+ }
134
+ function toolBlocked(tool, patterns) {
135
+ return patterns.some((pattern) => matchesWildcard(tool, pattern));
136
+ }
137
+
138
+ // src/wire.ts
139
+ import { randomBytes } from "node:crypto";
140
+ import fs2 from "node:fs";
141
+ import os2 from "node:os";
142
+ import path2 from "node:path";
143
+ import { fileURLToPath } from "node:url";
144
+ var PROTECTED = ["opencode.json", "opencode.jsonc", ".opencode", ".claude", ".agents"];
145
+ function wireRequest(input, platform = process.platform) {
146
+ const workspace = real(input.workspace);
147
+ const cwd = real(input.cwd);
148
+ const writable = [
149
+ workspace,
150
+ ...(input.tmpRoots ?? [os2.tmpdir()]).map(real),
151
+ ...input.policy.filesystem.writableRoots.map((root) => real(expandHome(root)))
152
+ ];
153
+ const protectedPaths = PROTECTED.map((item) => path2.join(workspace, item));
154
+ const deniedPaths = input.policy.filesystem.deniedPaths.map((item) => real(expandHome(item)));
155
+ let filesystem;
156
+ if (input.policy.filesystem.mode !== "workspace-write") {
157
+ filesystem = { readwritePaths: [], readonlyPaths: [input.workspace], deniedPaths: existing(deniedPaths, platform) };
158
+ } else if (platform === "win32") {
159
+ filesystem = { readwritePaths: writable, readonlyPaths: [], deniedPaths: [...protectedPaths, ...deniedPaths] };
160
+ } else if (platform === "linux") {
161
+ filesystem = {
162
+ readwritePaths: writable,
163
+ readonlyPaths: protectedPaths.filter((item) => exists(item)),
164
+ deniedPaths: existing(deniedPaths, platform)
165
+ };
166
+ } else {
167
+ filesystem = { readwritePaths: writable, readonlyPaths: protectedPaths, deniedPaths };
168
+ }
169
+ const request = {
170
+ version: "1.0.0",
171
+ containerId: randomBytes(16).toString("hex"),
172
+ containment: platform === "darwin" ? "seatbelt" : platform === "win32" ? "processcontainer" : "bubblewrap",
173
+ ...platform === "darwin" ? { seatbelt: {} } : {},
174
+ ...platform === "win32" ? {
175
+ processContainer: {
176
+ capabilities: input.policy.network === "allow" ? ["internetClient"] : [],
177
+ ui: { isolation: "container", desktopSystemControl: false, systemSettings: "none", ime: false },
178
+ filesystem: undefined,
179
+ network: undefined
180
+ }
181
+ } : {},
182
+ lifecycle: { destroyOnExit: true, preservePolicy: false },
183
+ process: {
184
+ commandLine: input.command,
185
+ timeout: input.timeout,
186
+ cwd,
187
+ inheritDefaultEnv: true,
188
+ ...input.environment === undefined ? {} : { env: input.environment }
189
+ },
190
+ filesystem,
191
+ ...input.policy.network === "deny" ? { network: { egress: { default: "deny" }, ingress: { default: "deny", hostLoopback: "deny" } } } : { network: { egress: { default: "allow" } } }
192
+ };
193
+ return request;
194
+ }
195
+ function exists(item) {
196
+ try {
197
+ fs2.statSync(item);
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+ function real(item) {
204
+ try {
205
+ return fs2.realpathSync(item);
206
+ } catch {
207
+ return item;
208
+ }
209
+ }
210
+ function existing(paths, platform) {
211
+ return platform === "linux" ? paths.filter(exists) : paths;
212
+ }
213
+ function unsupportedNetwork(platform, network) {
214
+ return platform === "win32" && network === "deny";
215
+ }
216
+ function resolveExecutor(platform = process.platform, arch = process.arch) {
217
+ const name = platform === "darwin" ? "mxc-exec-mac" : platform === "win32" ? "wxc-exec.exe" : platform === "linux" ? "lxc-exec" : undefined;
218
+ if (!name)
219
+ return `no MXC executor exists for ${platform}`;
220
+ const sdk = path2.dirname(fileURLToPath(import.meta.resolve("@microsoft/mxc-sdk/package.json")));
221
+ const executor = path2.join(sdk, "bin", arch === "arm64" ? "arm64" : "x64", name);
222
+ if (!fs2.existsSync(executor)) {
223
+ return platform === "darwin" && arch === "x64" ? "the installed @microsoft/mxc-sdk ships no Intel macOS executor yet" : `the MXC executor is missing from the @microsoft/mxc-sdk package: ${executor}`;
224
+ }
225
+ return executor;
226
+ }
227
+ function quote(value, platform = process.platform) {
228
+ if (platform === "win32")
229
+ return windowsArgument(value);
230
+ return `'${value.replaceAll("'", `'\\''`)}'`;
231
+ }
232
+ function windowsArgument(value) {
233
+ if (!/[\s"]/.test(value))
234
+ return value;
235
+ let quoted = '"';
236
+ let backslashes = 0;
237
+ for (const character of value) {
238
+ if (character === "\\") {
239
+ backslashes++;
240
+ continue;
241
+ }
242
+ if (character === '"') {
243
+ quoted += "\\".repeat(backslashes * 2 + 1) + '"';
244
+ backslashes = 0;
245
+ continue;
246
+ }
247
+ quoted += "\\".repeat(backslashes) + character;
248
+ backslashes = 0;
249
+ }
250
+ return quoted + "\\".repeat(backslashes * 2) + '"';
251
+ }
252
+
253
+ // src/cli.ts
254
+ var HELP = `sandboxed-shell: an interactive shell inside the MXC sandbox
255
+
256
+ Usage: sandboxed-shell [--workspace <dir>] [--shell <path>]
257
+
258
+ Options:
259
+ -w, --workspace <dir> The workspace the policy treats as writable (default: cwd)
260
+ -s, --shell <path> The shell to run inside the sandbox (default: $SHELL, /bin/zsh)
261
+ -h, --help Show this help
262
+
263
+ The policy comes from the same config as the plugin:
264
+ ~/.config/universal-opencode-sandbox/config.json
265
+
266
+ Fails closed: when the sandbox cannot run, nothing runs.`;
267
+ function fail(message) {
268
+ console.error(`sandboxed-shell: ${message}`);
269
+ process.exit(1);
270
+ }
271
+ var workspace = process.cwd();
272
+ var shell = process.env.SHELL ?? "/bin/zsh";
273
+ var args = process.argv.slice(2);
274
+ for (let index = 0;index < args.length; index++) {
275
+ const arg = args[index];
276
+ if (arg === "-h" || arg === "--help") {
277
+ console.log(HELP);
278
+ process.exit(0);
279
+ } else if (arg === "-w" || arg === "--workspace") {
280
+ workspace = args[++index] ?? fail(`${arg} needs a value`);
281
+ } else if (arg === "-s" || arg === "--shell") {
282
+ shell = args[++index] ?? fail(`${arg} needs a value`);
283
+ } else {
284
+ fail(`unknown argument: ${arg}
285
+
286
+ ${HELP}`);
287
+ }
288
+ }
289
+ if (!fs3.existsSync(workspace))
290
+ fail(`workspace does not exist: ${workspace}`);
291
+ var policy = resolvePolicy(workspace);
292
+ if (!policy.enabled) {
293
+ fail("the sandbox is disabled in the config; refusing to start an unsandboxed shell (set enabled true to use this terminal)");
294
+ }
295
+ var executor = resolveExecutor();
296
+ if (!fs3.existsSync(executor))
297
+ fail(executor);
298
+ if (unsupportedNetwork(process.platform, policy.network)) {
299
+ fail('the sandbox backend on Windows cannot enforce network denial yet; set network "allow" in the sandbox config to use this terminal');
300
+ }
301
+ var environment = Object.entries(process.env).filter((entry) => typeof entry[1] === "string").map(([key, value]) => `${key}=${value}`);
302
+ var request = wireRequest({
303
+ command: quote(shell),
304
+ workspace,
305
+ cwd: workspace,
306
+ timeout: 0,
307
+ policy,
308
+ environment
309
+ });
310
+ var requestDirectory = path3.join(os3.tmpdir(), "universal-opencode-sandbox");
311
+ fs3.mkdirSync(requestDirectory, { recursive: true });
312
+ var file = path3.join(requestDirectory, `terminal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.json`);
313
+ fs3.writeFileSync(file, JSON.stringify(request), { mode: 384 });
314
+ var child = spawn(executor, [file], { stdio: "inherit" });
315
+ var remove = () => fs3.rmSync(file, { force: true });
316
+ child.on("error", (error) => {
317
+ remove();
318
+ fail(error.message);
319
+ });
320
+ child.on("exit", (code, signal) => {
321
+ remove();
322
+ if (signal !== null)
323
+ process.kill(process.pid, signal);
324
+ process.exit(code ?? 1);
325
+ });
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import fs3 from "node:fs";
3
- import os3 from "node:os";
3
+ import os4 from "node:os";
4
4
  import path3 from "node:path";
5
5
  import { Plugin } from "@opencode/plugin";
6
6
 
@@ -8,10 +8,32 @@ import { Plugin } from "@opencode/plugin";
8
8
  import fs from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
- var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "universal-opencode-sandbox");
11
+ var realHome = () => os.userInfo().homedir;
12
+ var defaultDeniedPaths = [
13
+ "~/.ssh",
14
+ "~/.aws",
15
+ "~/.gnupg",
16
+ "~/.netrc",
17
+ "~/_netrc",
18
+ "~/.kube",
19
+ "~/.azure",
20
+ "~/.config/gcloud",
21
+ "~/AppData/Roaming/gcloud",
22
+ "~/.npmrc",
23
+ "~/.pypirc",
24
+ "~/.cargo/credentials",
25
+ "~/.docker/config.json",
26
+ "~/.config/gh/hosts.yml",
27
+ "~/Library/Application Support/gh/hosts.yml",
28
+ "~/AppData/Roaming/gh/hosts.yml",
29
+ "~/.local/share/opencode/auth.json",
30
+ "~/.claude/.credentials.json",
31
+ "~/.codex/auth.json"
32
+ ];
33
+ var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
12
34
  var configPath = () => path.join(configDirectory(), "config.json");
13
35
  function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
14
- let merged = { writableRoots: [], deniedPaths: [] };
36
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [], unsandboxedAllow: [] };
15
37
  const global = readConfig(globalFile);
16
38
  if (global)
17
39
  merged = tighten(merged, global);
@@ -28,12 +50,15 @@ function resolvePolicy(workspace, environment = process.env, globalFile = config
28
50
  enabled: merged.enabled ?? true,
29
51
  mode: merged.mode ?? "enforce",
30
52
  filesystem: {
31
- mode: merged.filesystemMode ?? "read-only",
53
+ mode: merged.filesystemMode ?? "workspace-write",
32
54
  writableRoots: [...new Set(merged.writableRoots)],
33
- deniedPaths: [...new Set(merged.deniedPaths)]
55
+ deniedPaths: merged.deniedPathsExplicit ? [...new Set(merged.deniedPaths)] : defaultDeniedPaths
34
56
  },
35
57
  network: merged.network ?? "deny",
36
- unsandboxed: merged.unsandboxed ?? "deny"
58
+ unsandboxed: merged.unsandboxed ?? "deny",
59
+ userCommands: merged.userCommands ?? false,
60
+ blockedTools: [...new Set(merged.blockedTools)],
61
+ unsandboxedAllow: [...new Set(merged.unsandboxedAllow)]
37
62
  };
38
63
  }
39
64
  function readConfig(file) {
@@ -50,18 +75,32 @@ function tighten(merged, raw) {
50
75
  merged.writableRoots.push(root);
51
76
  }
52
77
  if (Array.isArray(raw.filesystem?.denied_paths)) {
53
- for (const item of raw.filesystem.denied_paths)
78
+ const items = raw.filesystem.denied_paths.filter((item) => typeof item === "string");
79
+ merged.deniedPaths = merged.deniedPathsExplicit ? [...merged.deniedPaths, ...items] : items;
80
+ merged.deniedPathsExplicit = true;
81
+ }
82
+ if (Array.isArray(raw.blocked_tools)) {
83
+ for (const item of raw.blocked_tools)
84
+ if (typeof item === "string")
85
+ merged.blockedTools.push(item);
86
+ }
87
+ if (Array.isArray(raw.unsandboxed_allow)) {
88
+ for (const item of raw.unsandboxed_allow)
54
89
  if (typeof item === "string")
55
- merged.deniedPaths.push(item);
90
+ merged.unsandboxedAllow.push(item);
56
91
  }
57
92
  return {
58
93
  enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
59
94
  mode: strictest(merged.mode, raw.mode, "enforce", "permissive"),
60
95
  filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
61
96
  network: strictest(merged.network, raw.network, "deny", "allow"),
62
- unsandboxed: strictest(merged.unsandboxed, raw.unsandboxed, "deny", "allow"),
97
+ unsandboxed: strictestUnsandboxed(merged.unsandboxed, raw.unsandboxed),
98
+ userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
99
+ blockedTools: merged.blockedTools,
100
+ unsandboxedAllow: merged.unsandboxedAllow,
63
101
  writableRoots: merged.writableRoots,
64
- deniedPaths: merged.deniedPaths
102
+ deniedPaths: merged.deniedPaths,
103
+ deniedPathsExplicit: merged.deniedPathsExplicit
65
104
  };
66
105
  }
67
106
  function strictest(current, incoming, strict, loose) {
@@ -71,9 +110,28 @@ function strictest(current, incoming, strict, loose) {
71
110
  return loose;
72
111
  return current;
73
112
  }
113
+ function strictestUnsandboxed(current, incoming) {
114
+ if (incoming !== "deny" && incoming !== "ask" && incoming !== "allow")
115
+ return current;
116
+ if (current === "deny" || incoming === "deny")
117
+ return "deny";
118
+ if (current === "ask" || incoming === "ask")
119
+ return "ask";
120
+ return "allow";
121
+ }
74
122
  function expandHome(item, home = os.homedir()) {
75
123
  return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
76
124
  }
125
+ function matchesWildcard(value, pattern) {
126
+ const normalized = value.replaceAll("\\", "/");
127
+ let escaped = pattern.replaceAll("\\", "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
128
+ if (escaped.endsWith(" .*"))
129
+ escaped = escaped.slice(0, -3) + "( .*)?";
130
+ return new RegExp(`^${escaped}$`, "s").test(normalized);
131
+ }
132
+ function toolBlocked(tool, patterns) {
133
+ return patterns.some((pattern) => matchesWildcard(tool, pattern));
134
+ }
77
135
 
78
136
  // src/wire.ts
79
137
  import { randomBytes } from "node:crypto";
@@ -124,7 +182,8 @@ function wireRequest(input, platform = process.platform) {
124
182
  commandLine: input.command,
125
183
  timeout: input.timeout,
126
184
  cwd,
127
- inheritDefaultEnv: true
185
+ inheritDefaultEnv: true,
186
+ ...input.environment === undefined ? {} : { env: input.environment }
128
187
  },
129
188
  filesystem,
130
189
  ...input.policy.network === "deny" ? { network: { egress: { default: "deny" }, ingress: { default: "deny", hostLoopback: "deny" } } } : { network: { egress: { default: "allow" } } }
@@ -189,6 +248,75 @@ function windowsArgument(value) {
189
248
  return quoted + "\\".repeat(backslashes * 2) + '"';
190
249
  }
191
250
 
251
+ // src/unsandboxed.ts
252
+ import { spawn } from "node:child_process";
253
+ import os3 from "node:os";
254
+ var OUTPUT_LIMIT = 200000;
255
+ var RETURN_LIMIT = 50000;
256
+ var unsandboxedShellTool = (workspace) => ({
257
+ name: "shell_unsandboxed",
258
+ description: "Run one shell command OUTSIDE the sandbox, on the host. The user approves every call and sees the exact command (or has pre-approved a pattern). " + "Use this only for what the sandboxed shell cannot do and the user asked for, such as publishing a package, pushing over SSH, or reaching the network; " + "for everything else prefer the sandboxed shell tool.",
259
+ input: {
260
+ type: "object",
261
+ properties: {
262
+ command: { type: "string", description: "The shell command to run unsandboxed" },
263
+ timeout: { type: "number", description: "Timeout in milliseconds (default 120000)" },
264
+ workdir: { type: "string", description: "Working directory (default: the workspace)" }
265
+ },
266
+ required: ["command"],
267
+ additionalProperties: false
268
+ },
269
+ options: { permission: "unsandboxed" },
270
+ execute: async (raw, context) => {
271
+ const input = raw ?? {};
272
+ const command = input.command;
273
+ if (typeof command !== "string" || command === "")
274
+ throw new Error("a command is required");
275
+ const timeout = typeof input.timeout === "number" && input.timeout > 0 ? input.timeout : 120000;
276
+ const cwd = typeof input.workdir === "string" && input.workdir !== "" ? input.workdir : workspace;
277
+ return await new Promise((resolve) => {
278
+ const child = spawn(process.env.SHELL ?? "/bin/sh", ["-c", command], {
279
+ cwd,
280
+ env: { ...process.env, HOME: os3.userInfo().homedir }
281
+ });
282
+ let stdout = "";
283
+ let stderr = "";
284
+ let timedOut = false;
285
+ const timer = setTimeout(() => {
286
+ timedOut = true;
287
+ child.kill("SIGKILL");
288
+ }, timeout);
289
+ child.stdout?.on("data", (chunk) => {
290
+ if (stdout.length < OUTPUT_LIMIT)
291
+ stdout += chunk.toString();
292
+ });
293
+ child.stderr?.on("data", (chunk) => {
294
+ if (stderr.length < OUTPUT_LIMIT)
295
+ stderr += chunk.toString();
296
+ });
297
+ child.on("error", (error) => {
298
+ clearTimeout(timer);
299
+ resolve({ content: `failed to start: ${error.message}` });
300
+ });
301
+ child.on("close", (code) => {
302
+ clearTimeout(timer);
303
+ const combined = `stdout:
304
+ ${stdout}
305
+ stderr:
306
+ ${stderr}`;
307
+ const tail = combined.length > RETURN_LIMIT ? `...${combined.slice(-RETURN_LIMIT)}` : combined;
308
+ resolve({
309
+ content: `${timedOut ? `timed out
310
+ ` : ""}exit code ${code ?? "unknown"}
311
+
312
+ ${tail}`,
313
+ metadata: { exitCode: code, timedOut }
314
+ });
315
+ });
316
+ });
317
+ }
318
+ });
319
+
192
320
  // src/index.ts
193
321
  var BLOCKED = "universal-opencode-sandbox unavailable; command blocked";
194
322
  var src_default = Plugin.define({
@@ -207,7 +335,7 @@ var src_default = Plugin.define({
207
335
  log(enforce ? "error" : "warn", `${reason}; ${enforce ? 'shell commands will be blocked (set mode "permissive" to run unsandboxed)' : "commands will run without a sandbox"}`);
208
336
  }
209
337
  const inFlight = new Map;
210
- const requestDirectory = path3.join(os3.tmpdir(), "universal-opencode-sandbox");
338
+ const requestDirectory = path3.join(os4.tmpdir(), "universal-opencode-sandbox");
211
339
  fs3.mkdirSync(requestDirectory, { recursive: true });
212
340
  const cleanup = (callID) => {
213
341
  const pending = inFlight.get(callID);
@@ -216,7 +344,50 @@ var src_default = Plugin.define({
216
344
  inFlight.delete(callID);
217
345
  fs3.rmSync(pending.file, { force: true });
218
346
  };
347
+ const escapeTool = policy.unsandboxed === "deny" ? undefined : await context.tool.transform((editor) => {
348
+ editor.add(unsandboxedShellTool(context.location.directory));
349
+ });
350
+ const askGuard = policy.unsandboxed === "ask" ? await context.permission.hook("evaluate", async (event) => {
351
+ if (event.action !== "unsandboxed")
352
+ return;
353
+ event.message = `Run this command outside the sandbox:
354
+
355
+ ${event.resources.join(" ")}`;
356
+ if (event.effect !== "allow")
357
+ return;
358
+ const force = () => {
359
+ event.effect = "ask";
360
+ };
361
+ try {
362
+ const session = await context.session.get({ sessionID: event.sessionID });
363
+ const specific = (session.permissions ?? []).some((rule) => rule.effect === "allow" && rule.action !== "*" && matchesWildcard(event.action, rule.action) && event.resources.every((resource) => matchesWildcard(resource, rule.resource)));
364
+ if (!specific)
365
+ force();
366
+ } catch {
367
+ force();
368
+ }
369
+ }) : undefined;
219
370
  const before = await context.tool.hook("execute.before", async (event) => {
371
+ if (toolBlocked(event.tool, policy.blockedTools)) {
372
+ log("warn", `tool call to ${event.tool} blocked by the sandbox config (blocked_tools)`);
373
+ throw new Error(`universal-opencode-sandbox: the tool ${event.tool} is blocked by the sandbox config`);
374
+ }
375
+ if (event.tool === "shell_unsandboxed") {
376
+ const escape = event.input?.command;
377
+ if (policy.unsandboxed === "ask" && typeof escape === "string" && escape !== "") {
378
+ const preapproved = policy.unsandboxedAllow.some((pattern) => matchesWildcard(escape, pattern));
379
+ if (!preapproved) {
380
+ await context.permission.assert({
381
+ sessionID: event.sessionID,
382
+ action: "unsandboxed",
383
+ resources: [escape],
384
+ metadata: { command: escape }
385
+ });
386
+ log("warn", `the user approved running a command unsandboxed: ${escape}`);
387
+ }
388
+ }
389
+ return;
390
+ }
220
391
  if (event.tool !== "shell")
221
392
  return;
222
393
  const input = event.input ?? {};
@@ -225,9 +396,23 @@ var src_default = Plugin.define({
225
396
  return;
226
397
  if (input.sandbox === false) {
227
398
  if (policy.unsandboxed === "deny") {
228
- log("warn", `tool call asked to skip the sandbox; blocked (set unsandboxed "allow" to permit this)`);
399
+ log("warn", `tool call asked to skip the sandbox; blocked (set unsandboxed "ask" or "allow" to permit this)`);
229
400
  throw new Error("universal-opencode-sandbox: running this command unsandboxed is not allowed");
230
401
  }
402
+ if (policy.unsandboxed === "ask") {
403
+ const preapproved = policy.unsandboxedAllow.some((pattern) => matchesWildcard(command, pattern));
404
+ if (!preapproved) {
405
+ await context.permission.assert({
406
+ sessionID: event.sessionID,
407
+ action: "unsandboxed",
408
+ resources: [command],
409
+ metadata: { command }
410
+ });
411
+ log("warn", `the user approved running a command unsandboxed: ${command}`);
412
+ } else {
413
+ log("warn", `running a command unsandboxed without a prompt (unsandboxed_allow): ${command}`);
414
+ }
415
+ }
231
416
  return;
232
417
  }
233
418
  if (unavailable || gap) {
@@ -253,6 +438,32 @@ var src_default = Plugin.define({
253
438
  log("warn", `failed to wrap the command; running unsandboxed: ${error instanceof Error ? error.message : String(error)}`);
254
439
  }
255
440
  });
441
+ const shellBefore = policy.userCommands ? await context.shell.hook("create.before", async (invocation) => {
442
+ if (invocation.command.startsWith(`${quote(executor)} `))
443
+ return;
444
+ if (unavailable || gap) {
445
+ if (enforce)
446
+ throw new Error(`${BLOCKED}: ${reason}`);
447
+ return;
448
+ }
449
+ try {
450
+ const request = wireRequest({
451
+ command: invocation.command,
452
+ workspace: context.location.directory,
453
+ cwd: invocation.cwd,
454
+ timeout: invocation.timeout || 0,
455
+ policy
456
+ });
457
+ const id = `shell_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
458
+ const file = path3.join(requestDirectory, `${id}.json`);
459
+ fs3.writeFileSync(file, JSON.stringify(request), { mode: 384 });
460
+ invocation.command = `${quote(executor)} ${quote(file)}`;
461
+ } catch (error) {
462
+ if (enforce)
463
+ throw new Error(`${BLOCKED}: ${error instanceof Error ? error.message : String(error)}`);
464
+ log("warn", `failed to wrap a shell command; running unsandboxed: ${error instanceof Error ? error.message : String(error)}`);
465
+ }
466
+ }) : undefined;
256
467
  const after = await context.tool.hook("execute.after", async (event) => {
257
468
  const pending = inFlight.get(event.id);
258
469
  if (pending === undefined)
@@ -264,6 +475,12 @@ var src_default = Plugin.define({
264
475
  return async () => {
265
476
  await before.dispose();
266
477
  await after.dispose();
478
+ if (shellBefore)
479
+ await shellBefore.dispose();
480
+ if (escapeTool)
481
+ await escapeTool.dispose();
482
+ if (askGuard)
483
+ await askGuard.dispose();
267
484
  for (const callID of [...inFlight.keys()])
268
485
  cleanup(callID);
269
486
  };
package/dist/policy.d.ts CHANGED
@@ -10,13 +10,43 @@ export interface Policy {
10
10
  };
11
11
  readonly network: "deny" | "allow";
12
12
  /** What happens when a tool call asks to skip the sandbox (shell input "sandbox: false"). */
13
- readonly unsandboxed: "deny" | "allow";
13
+ readonly unsandboxed: "deny" | "ask" | "allow";
14
+ /** Also sandbox user "!" commands and plugin shells, not just agent shell tool calls. */
15
+ readonly userCommands: boolean;
16
+ /** Tool names the agent may not call at all, with "*" as a wildcard ("paseo_*"). */
17
+ readonly blockedTools: string[];
18
+ /** Commands pre-approved to run unsandboxed without a prompt, with "*" wildcards. */
19
+ readonly unsandboxedAllow: string[];
14
20
  }
21
+ /**
22
+ * Credential stores a sandboxed command can neither read nor write by default: the
23
+ * classic identity directories plus the plaintext credential files of the major
24
+ * CLIs, cloud SDKs and agent tools. A stolen long-lived credential cannot be undone
25
+ * by a restart, so reading is denied even though the network usually is too: a secret
26
+ * read once can be copied into the workspace and walk out through a commit. Setting
27
+ * denied_paths in the config replaces this list; the entries are then the user's.
28
+ *
29
+ * Paths that live in a platform's app-data directory are listed per platform: the
30
+ * Windows variants are nonexistent (and therefore no-ops) on macOS and Linux, and
31
+ * vice versa. Entries under the profile directory itself (~/.ssh and friends) are
32
+ * identical everywhere.
33
+ */
34
+ export declare const defaultDeniedPaths: string[];
15
35
  export declare const defaultPolicy: Policy;
16
36
  /**
17
37
  * Resolves the sandbox policy for a workspace: the global config file, tightened by the
18
38
  * matching per-project override, tightened by UNIVERSAL_SANDBOX_CONFIG. Security fields
19
- * only ever get stricter across explicit values; roots and denied paths accumulate.
39
+ * only ever get stricter across explicit values; roots and blocked tools accumulate.
40
+ * Denied paths start from defaultDeniedPaths and are replaced wholesale by the first
41
+ * layer that sets denied_paths, then extended by later layers.
20
42
  */
21
43
  export declare function resolvePolicy(workspace: string, environment?: NodeJS.ProcessEnv, globalFile?: string): Policy;
22
44
  export declare function expandHome(item: string, home?: string): string;
45
+ /**
46
+ * Matches a value against a pattern the way OpenCode's own permission rules do:
47
+ * "*" matches any sequence, "?" one character, a trailing " *" also matches the bare
48
+ * prefix, and backslashes normalize to forward slashes.
49
+ */
50
+ export declare function matchesWildcard(value: string, pattern: string): boolean;
51
+ /** True when the tool name matches a blocked pattern; "*" in a pattern matches any sequence. */
52
+ export declare function toolBlocked(tool: string, patterns: readonly string[]): boolean;
@@ -0,0 +1,35 @@
1
+ export interface ToolResult {
2
+ readonly content?: string | ReadonlyArray<{
3
+ type: "text";
4
+ text: string;
5
+ }>;
6
+ readonly metadata?: Record<string, unknown>;
7
+ }
8
+ interface ToolContext {
9
+ readonly sessionID: string;
10
+ }
11
+ type FieldType = {
12
+ readonly type: "string" | "number";
13
+ readonly description: string;
14
+ };
15
+ export type ToolInputSchema = {
16
+ readonly type: "object";
17
+ readonly properties: {
18
+ readonly command: FieldType;
19
+ readonly timeout: FieldType;
20
+ readonly workdir: FieldType;
21
+ };
22
+ readonly required: readonly ["command"];
23
+ readonly additionalProperties: false;
24
+ };
25
+ export interface ToolRegistration {
26
+ readonly name: string;
27
+ readonly description: string;
28
+ readonly input: ToolInputSchema;
29
+ readonly options?: {
30
+ readonly permission?: string;
31
+ };
32
+ readonly execute: (input: unknown, context: ToolContext) => Promise<ToolResult>;
33
+ }
34
+ export declare const unsandboxedShellTool: (workspace: string) => ToolRegistration;
35
+ export {};
package/dist/wire.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface WireInput {
39
39
  readonly timeout: number;
40
40
  readonly policy: Policy;
41
41
  readonly tmpRoots?: string[];
42
+ /** Extra KEY=VALUE environment entries layered over the backend default (used by the sandboxed-shell CLI). */
43
+ readonly environment?: string[];
42
44
  }
43
45
  /** Maps a policy and one command onto the wire request, platform-aware. */
44
46
  export declare function wireRequest(input: WireInput, platform?: NodeJS.Platform): WireRequest;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "universal-opencode-sandbox",
3
- "version": "0.1.1",
3
+ "version": "0.4.0",
4
4
  "description": "Universal OS-level sandbox for OpenCode agent shell commands on macOS, Linux and Windows, enforced by Microsoft MXC",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "sandboxed-shell": "dist/cli.js"
10
+ },
8
11
  "exports": {
9
12
  ".": {
10
13
  "types": "./dist/index.d.ts",
@@ -17,7 +20,7 @@
17
20
  "LICENSE"
18
21
  ],
19
22
  "scripts": {
20
- "build": "bun build ./src/index.ts --outdir dist --target node --format esm --external @microsoft/mxc-sdk --external @opencode/plugin && bun x tsc -p tsconfig.declarations.json",
23
+ "build": "bun build ./src/index.ts ./src/cli.ts --outdir dist --target node --format esm --external @microsoft/mxc-sdk --external @opencode/plugin && bun x tsc -p tsconfig.declarations.json",
21
24
  "dev": "bun run --watch src/index.ts",
22
25
  "test": "bun test",
23
26
  "typecheck": "tsc --noEmit",