universal-opencode-sandbox 0.1.1 → 0.3.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,11 +47,13 @@ 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.
54
+ // Paths the sandboxed command can neither read nor write. Unset (default):
55
+ // the standard credential list below. Setting the list replaces it: the
56
+ // entries are then yours, and per-project overrides only add to them.
53
57
  "denied_paths": ["~/.ssh", "~/.aws"]
54
58
  },
55
59
  // "deny" (default) blocks all network access for sandboxed commands.
@@ -57,6 +61,14 @@ on purpose: they can be set from a repository's own config.
57
61
  // What happens when a tool call asks to skip the sandbox (shell input
58
62
  // "sandbox: false"): "deny" (default) blocks it, "allow" runs it unsandboxed.
59
63
  "unsandboxed": "deny",
64
+ // Also sandbox user "!" commands and plugin shells, protecting the user from
65
+ // social engineering ("run this for me") and accidental damage. Default: false.
66
+ "sandbox_user_commands": true,
67
+ // Tool names the agent may not call at all; "*" is a wildcard. Use this to close
68
+ // escape routes outside the sandbox, such as a host app's unsandboxed terminal
69
+ // tools (an agent can otherwise spawn or drive a raw shell there). Unlike
70
+ // opencode's own permission rules, a repository config cannot re-allow these.
71
+ "blocked_tools": ["paseo_create_terminal", "paseo_send_terminal_keys"],
60
72
  // Per-project overrides, keyed by the workspace's absolute path. Every field is
61
73
  // optional and can only make the policy stricter, never looser.
62
74
  "projects": {
@@ -67,6 +79,28 @@ on purpose: they can be set from a repository's own config.
67
79
 
68
80
  `UNIVERSAL_SANDBOX_CONFIG` (a JSON string) tightens the same fields per invocation.
69
81
 
82
+ ### Default denied paths
83
+
84
+ Without a `denied_paths` entry, the sandbox denies reading and writing these
85
+ credential stores. A stolen long-lived credential cannot be undone by a restart,
86
+ so reading is denied even though the network usually is too: a secret read once
87
+ can be copied into the workspace and walk out through a commit.
88
+
89
+ - `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.kube`, `~/.azure` - identity
90
+ directories: keys, cloud credentials, cluster tokens
91
+ - `~/.config/gcloud` - GCP credentials and service-account keys
92
+ - `~/.npmrc`, `~/.pypirc`, `~/.cargo/credentials`, `~/.docker/config.json` -
93
+ registry tokens of the major package managers and Docker
94
+ - `~/.config/gh/hosts.yml` and `~/Library/Application Support/gh/hosts.yml` -
95
+ the GitHub CLI token (both config-directory conventions)
96
+ - `~/.local/share/opencode/auth.json` - OpenCode provider API keys
97
+ - `~/.claude/.credentials.json`, `~/.codex/auth.json` - credentials of the
98
+ other agent CLIs
99
+
100
+ Setting `denied_paths` replaces this list. On Linux only paths that exist are
101
+ enforced (an upstream deny bug would otherwise create stray directories,
102
+ microsoft/mxc#1780); on macOS not-yet-existing paths are protected too.
103
+
70
104
  ## What is enforced
71
105
 
72
106
  - **Filesystem**: in `workspace-write` mode, writes are allowed in the workspace and
@@ -86,6 +120,53 @@ on purpose: they can be set from a repository's own config.
86
120
  Linux), or a missing executor blocks the command in `enforce` mode instead of
87
121
  running it unconfined. `mode: "permissive"` trades that guarantee for convenience.
88
122
 
123
+ ## Sandboxed terminal
124
+
125
+ The package also ships `sandboxed-shell`, a small CLI that starts an **interactive
126
+ shell inside the same sandbox**: the same filesystem and network policy as the agent
127
+ commands, applied to everything you (or anything else) type into it.
128
+
129
+ ```bash
130
+ # Safe terminal: interactive zsh under Seatbelt/bubblewrap/ProcessContainer
131
+ sandboxed-shell --workspace /path/to/project
132
+ ```
133
+
134
+ This gives you two terminal types next to each other:
135
+
136
+ | Terminal type | How | What runs inside |
137
+ |---|---|---|
138
+ | Safe terminal | `sandboxed-shell` (or `exec` it in an existing terminal) | The MXC sandbox policy from your config |
139
+ | Admin terminal | your normal shell | Unconfined, for server restarts and admin work |
140
+
141
+ The safe terminal fails closed: if the sandbox cannot run, no shell is started, and
142
+ exiting it ends the session with no unsandboxed fallback. It inherits your
143
+ environment (PATH, TERM, HOME) so it behaves like a normal terminal; secrets in the
144
+ environment stay put because egress is denied and the filesystem policy still applies.
145
+
146
+ **Locking it down for other users**: the two-type design is exactly what lets you
147
+ disable the admin terminal for regular users. The plugin confines opencode agents;
148
+ terminals spawned by a host app (for example Paseo) are confined by having the host
149
+ start them through `sandboxed-shell` instead of a plain shell. Whoever controls
150
+ terminal creation controls the choice: point every terminal at the wrapper and nobody
151
+ gets an unsandboxed one. Inside opencode itself, two layers close the agent's route
152
+ to a host app's terminals: `blocked_tools` denies the terminal tools at every tool
153
+ call (a repository config cannot re-allow it), and opencode's own `permissions` rules
154
+ hide them from the agent's tool catalog entirely:
155
+
156
+ ```jsonc
157
+ // opencode.jsonc, next to this plugin in your config
158
+ {
159
+ "permissions": [
160
+ { "action": "paseo_create_terminal", "resource": "*", "effect": "deny" },
161
+ { "action": "paseo_send_terminal_keys", "resource": "*", "effect": "deny" },
162
+ { "action": "paseo_capture_terminal", "resource": "*", "effect": "deny" }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ Use the `permissions` array: the older `permission` object is deprecated and, on
168
+ current releases, silently ignored.
169
+
89
170
  ## Known limitations
90
171
 
91
172
  - With the sandbox enabled, OpenCode's per-command shell permission rules see the
@@ -106,7 +187,10 @@ and Windows 11 (ProcessContainer): output and exit codes stream through, writes
106
187
  the workspace succeed, writes outside are denied, protected config files cannot be
107
188
  planted, and network egress is blocked (except the documented Windows gap). Also
108
189
  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.
190
+ writes inside the workspace and is denied outside it. The `sandboxed-shell` terminal
191
+ was verified interactively on macOS: commands run with full environment, writes inside
192
+ the workspace succeed, writes outside are denied, network egress is blocked, and
193
+ `denied_paths` (for example `~/.ssh`) cannot be read.
110
194
 
111
195
  ## License
112
196
 
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,302 @@
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
+ "~/.kube",
20
+ "~/.azure",
21
+ "~/.config/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
+ "~/.local/share/opencode/auth.json",
29
+ "~/.claude/.credentials.json",
30
+ "~/.codex/auth.json"
31
+ ];
32
+ var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
33
+ var configPath = () => path.join(configDirectory(), "config.json");
34
+ function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
35
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [] };
36
+ const global = readConfig(globalFile);
37
+ if (global)
38
+ merged = tighten(merged, global);
39
+ const project = global?.projects?.[workspace];
40
+ if (project)
41
+ merged = tighten(merged, project);
42
+ const injected = environment.UNIVERSAL_SANDBOX_CONFIG;
43
+ if (injected) {
44
+ try {
45
+ merged = tighten(merged, JSON.parse(injected));
46
+ } catch {}
47
+ }
48
+ return {
49
+ enabled: merged.enabled ?? true,
50
+ mode: merged.mode ?? "enforce",
51
+ filesystem: {
52
+ mode: merged.filesystemMode ?? "workspace-write",
53
+ writableRoots: [...new Set(merged.writableRoots)],
54
+ deniedPaths: merged.deniedPathsExplicit ? [...new Set(merged.deniedPaths)] : defaultDeniedPaths
55
+ },
56
+ network: merged.network ?? "deny",
57
+ unsandboxed: merged.unsandboxed ?? "deny",
58
+ userCommands: merged.userCommands ?? false,
59
+ blockedTools: [...new Set(merged.blockedTools)]
60
+ };
61
+ }
62
+ function readConfig(file) {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(file, "utf8"));
65
+ } catch {
66
+ return;
67
+ }
68
+ }
69
+ function tighten(merged, raw) {
70
+ if (Array.isArray(raw.filesystem?.writable_roots)) {
71
+ for (const root of raw.filesystem.writable_roots)
72
+ if (typeof root === "string")
73
+ merged.writableRoots.push(root);
74
+ }
75
+ if (Array.isArray(raw.filesystem?.denied_paths)) {
76
+ const items = raw.filesystem.denied_paths.filter((item) => typeof item === "string");
77
+ merged.deniedPaths = merged.deniedPathsExplicit ? [...merged.deniedPaths, ...items] : items;
78
+ merged.deniedPathsExplicit = true;
79
+ }
80
+ if (Array.isArray(raw.blocked_tools)) {
81
+ for (const item of raw.blocked_tools)
82
+ if (typeof item === "string")
83
+ merged.blockedTools.push(item);
84
+ }
85
+ return {
86
+ enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
87
+ mode: strictest(merged.mode, raw.mode, "enforce", "permissive"),
88
+ filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
89
+ network: strictest(merged.network, raw.network, "deny", "allow"),
90
+ unsandboxed: strictest(merged.unsandboxed, raw.unsandboxed, "deny", "allow"),
91
+ userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
92
+ blockedTools: merged.blockedTools,
93
+ writableRoots: merged.writableRoots,
94
+ deniedPaths: merged.deniedPaths,
95
+ deniedPathsExplicit: merged.deniedPathsExplicit
96
+ };
97
+ }
98
+ function strictest(current, incoming, strict, loose) {
99
+ if (current === strict || incoming === strict)
100
+ return strict;
101
+ if (incoming === loose)
102
+ return loose;
103
+ return current;
104
+ }
105
+ function expandHome(item, home = os.homedir()) {
106
+ return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
107
+ }
108
+ function toolBlocked(tool, patterns) {
109
+ return patterns.some((pattern) => {
110
+ const source = pattern.split("*").map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")).join(".*");
111
+ return new RegExp(`^${source}$`).test(tool);
112
+ });
113
+ }
114
+
115
+ // src/wire.ts
116
+ import { randomBytes } from "node:crypto";
117
+ import fs2 from "node:fs";
118
+ import os2 from "node:os";
119
+ import path2 from "node:path";
120
+ import { fileURLToPath } from "node:url";
121
+ var PROTECTED = ["opencode.json", "opencode.jsonc", ".opencode", ".claude", ".agents"];
122
+ function wireRequest(input, platform = process.platform) {
123
+ const workspace = real(input.workspace);
124
+ const cwd = real(input.cwd);
125
+ const writable = [
126
+ workspace,
127
+ ...(input.tmpRoots ?? [os2.tmpdir()]).map(real),
128
+ ...input.policy.filesystem.writableRoots.map((root) => real(expandHome(root)))
129
+ ];
130
+ const protectedPaths = PROTECTED.map((item) => path2.join(workspace, item));
131
+ const deniedPaths = input.policy.filesystem.deniedPaths.map((item) => real(expandHome(item)));
132
+ let filesystem;
133
+ if (input.policy.filesystem.mode !== "workspace-write") {
134
+ filesystem = { readwritePaths: [], readonlyPaths: [input.workspace], deniedPaths: existing(deniedPaths, platform) };
135
+ } else if (platform === "win32") {
136
+ filesystem = { readwritePaths: writable, readonlyPaths: [], deniedPaths: [...protectedPaths, ...deniedPaths] };
137
+ } else if (platform === "linux") {
138
+ filesystem = {
139
+ readwritePaths: writable,
140
+ readonlyPaths: protectedPaths.filter((item) => exists(item)),
141
+ deniedPaths: existing(deniedPaths, platform)
142
+ };
143
+ } else {
144
+ filesystem = { readwritePaths: writable, readonlyPaths: protectedPaths, deniedPaths };
145
+ }
146
+ const request = {
147
+ version: "1.0.0",
148
+ containerId: randomBytes(16).toString("hex"),
149
+ containment: platform === "darwin" ? "seatbelt" : platform === "win32" ? "processcontainer" : "bubblewrap",
150
+ ...platform === "darwin" ? { seatbelt: {} } : {},
151
+ ...platform === "win32" ? {
152
+ processContainer: {
153
+ capabilities: input.policy.network === "allow" ? ["internetClient"] : [],
154
+ ui: { isolation: "container", desktopSystemControl: false, systemSettings: "none", ime: false },
155
+ filesystem: undefined,
156
+ network: undefined
157
+ }
158
+ } : {},
159
+ lifecycle: { destroyOnExit: true, preservePolicy: false },
160
+ process: {
161
+ commandLine: input.command,
162
+ timeout: input.timeout,
163
+ cwd,
164
+ inheritDefaultEnv: true,
165
+ ...input.environment === undefined ? {} : { env: input.environment }
166
+ },
167
+ filesystem,
168
+ ...input.policy.network === "deny" ? { network: { egress: { default: "deny" }, ingress: { default: "deny", hostLoopback: "deny" } } } : { network: { egress: { default: "allow" } } }
169
+ };
170
+ return request;
171
+ }
172
+ function exists(item) {
173
+ try {
174
+ fs2.statSync(item);
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+ function real(item) {
181
+ try {
182
+ return fs2.realpathSync(item);
183
+ } catch {
184
+ return item;
185
+ }
186
+ }
187
+ function existing(paths, platform) {
188
+ return platform === "linux" ? paths.filter(exists) : paths;
189
+ }
190
+ function unsupportedNetwork(platform, network) {
191
+ return platform === "win32" && network === "deny";
192
+ }
193
+ function resolveExecutor(platform = process.platform, arch = process.arch) {
194
+ const name = platform === "darwin" ? "mxc-exec-mac" : platform === "win32" ? "wxc-exec.exe" : platform === "linux" ? "lxc-exec" : undefined;
195
+ if (!name)
196
+ return `no MXC executor exists for ${platform}`;
197
+ const sdk = path2.dirname(fileURLToPath(import.meta.resolve("@microsoft/mxc-sdk/package.json")));
198
+ const executor = path2.join(sdk, "bin", arch === "arm64" ? "arm64" : "x64", name);
199
+ if (!fs2.existsSync(executor)) {
200
+ 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}`;
201
+ }
202
+ return executor;
203
+ }
204
+ function quote(value, platform = process.platform) {
205
+ if (platform === "win32")
206
+ return windowsArgument(value);
207
+ return `'${value.replaceAll("'", `'\\''`)}'`;
208
+ }
209
+ function windowsArgument(value) {
210
+ if (!/[\s"]/.test(value))
211
+ return value;
212
+ let quoted = '"';
213
+ let backslashes = 0;
214
+ for (const character of value) {
215
+ if (character === "\\") {
216
+ backslashes++;
217
+ continue;
218
+ }
219
+ if (character === '"') {
220
+ quoted += "\\".repeat(backslashes * 2 + 1) + '"';
221
+ backslashes = 0;
222
+ continue;
223
+ }
224
+ quoted += "\\".repeat(backslashes) + character;
225
+ backslashes = 0;
226
+ }
227
+ return quoted + "\\".repeat(backslashes * 2) + '"';
228
+ }
229
+
230
+ // src/cli.ts
231
+ var HELP = `sandboxed-shell: an interactive shell inside the MXC sandbox
232
+
233
+ Usage: sandboxed-shell [--workspace <dir>] [--shell <path>]
234
+
235
+ Options:
236
+ -w, --workspace <dir> The workspace the policy treats as writable (default: cwd)
237
+ -s, --shell <path> The shell to run inside the sandbox (default: $SHELL, /bin/zsh)
238
+ -h, --help Show this help
239
+
240
+ The policy comes from the same config as the plugin:
241
+ ~/.config/universal-opencode-sandbox/config.json
242
+
243
+ Fails closed: when the sandbox cannot run, nothing runs.`;
244
+ function fail(message) {
245
+ console.error(`sandboxed-shell: ${message}`);
246
+ process.exit(1);
247
+ }
248
+ var workspace = process.cwd();
249
+ var shell = process.env.SHELL ?? "/bin/zsh";
250
+ var args = process.argv.slice(2);
251
+ for (let index = 0;index < args.length; index++) {
252
+ const arg = args[index];
253
+ if (arg === "-h" || arg === "--help") {
254
+ console.log(HELP);
255
+ process.exit(0);
256
+ } else if (arg === "-w" || arg === "--workspace") {
257
+ workspace = args[++index] ?? fail(`${arg} needs a value`);
258
+ } else if (arg === "-s" || arg === "--shell") {
259
+ shell = args[++index] ?? fail(`${arg} needs a value`);
260
+ } else {
261
+ fail(`unknown argument: ${arg}
262
+
263
+ ${HELP}`);
264
+ }
265
+ }
266
+ if (!fs3.existsSync(workspace))
267
+ fail(`workspace does not exist: ${workspace}`);
268
+ var policy = resolvePolicy(workspace);
269
+ if (!policy.enabled) {
270
+ fail("the sandbox is disabled in the config; refusing to start an unsandboxed shell (set enabled true to use this terminal)");
271
+ }
272
+ var executor = resolveExecutor();
273
+ if (!fs3.existsSync(executor))
274
+ fail(executor);
275
+ if (unsupportedNetwork(process.platform, policy.network)) {
276
+ fail('the sandbox backend on Windows cannot enforce network denial yet; set network "allow" in the sandbox config to use this terminal');
277
+ }
278
+ var environment = Object.entries(process.env).filter((entry) => typeof entry[1] === "string").map(([key, value]) => `${key}=${value}`);
279
+ var request = wireRequest({
280
+ command: quote(shell),
281
+ workspace,
282
+ cwd: workspace,
283
+ timeout: 0,
284
+ policy,
285
+ environment
286
+ });
287
+ var requestDirectory = path3.join(os3.tmpdir(), "universal-opencode-sandbox");
288
+ fs3.mkdirSync(requestDirectory, { recursive: true });
289
+ var file = path3.join(requestDirectory, `terminal_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.json`);
290
+ fs3.writeFileSync(file, JSON.stringify(request), { mode: 384 });
291
+ var child = spawn(executor, [file], { stdio: "inherit" });
292
+ var remove = () => fs3.rmSync(file, { force: true });
293
+ child.on("error", (error) => {
294
+ remove();
295
+ fail(error.message);
296
+ });
297
+ child.on("exit", (code, signal) => {
298
+ remove();
299
+ if (signal !== null)
300
+ process.kill(process.pid, signal);
301
+ process.exit(code ?? 1);
302
+ });
package/dist/index.js CHANGED
@@ -8,10 +8,29 @@ 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
+ "~/.kube",
18
+ "~/.azure",
19
+ "~/.config/gcloud",
20
+ "~/.npmrc",
21
+ "~/.pypirc",
22
+ "~/.cargo/credentials",
23
+ "~/.docker/config.json",
24
+ "~/.config/gh/hosts.yml",
25
+ "~/Library/Application Support/gh/hosts.yml",
26
+ "~/.local/share/opencode/auth.json",
27
+ "~/.claude/.credentials.json",
28
+ "~/.codex/auth.json"
29
+ ];
30
+ var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
12
31
  var configPath = () => path.join(configDirectory(), "config.json");
13
32
  function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
14
- let merged = { writableRoots: [], deniedPaths: [] };
33
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [] };
15
34
  const global = readConfig(globalFile);
16
35
  if (global)
17
36
  merged = tighten(merged, global);
@@ -28,12 +47,14 @@ function resolvePolicy(workspace, environment = process.env, globalFile = config
28
47
  enabled: merged.enabled ?? true,
29
48
  mode: merged.mode ?? "enforce",
30
49
  filesystem: {
31
- mode: merged.filesystemMode ?? "read-only",
50
+ mode: merged.filesystemMode ?? "workspace-write",
32
51
  writableRoots: [...new Set(merged.writableRoots)],
33
- deniedPaths: [...new Set(merged.deniedPaths)]
52
+ deniedPaths: merged.deniedPathsExplicit ? [...new Set(merged.deniedPaths)] : defaultDeniedPaths
34
53
  },
35
54
  network: merged.network ?? "deny",
36
- unsandboxed: merged.unsandboxed ?? "deny"
55
+ unsandboxed: merged.unsandboxed ?? "deny",
56
+ userCommands: merged.userCommands ?? false,
57
+ blockedTools: [...new Set(merged.blockedTools)]
37
58
  };
38
59
  }
39
60
  function readConfig(file) {
@@ -50,9 +71,14 @@ function tighten(merged, raw) {
50
71
  merged.writableRoots.push(root);
51
72
  }
52
73
  if (Array.isArray(raw.filesystem?.denied_paths)) {
53
- for (const item of raw.filesystem.denied_paths)
74
+ const items = raw.filesystem.denied_paths.filter((item) => typeof item === "string");
75
+ merged.deniedPaths = merged.deniedPathsExplicit ? [...merged.deniedPaths, ...items] : items;
76
+ merged.deniedPathsExplicit = true;
77
+ }
78
+ if (Array.isArray(raw.blocked_tools)) {
79
+ for (const item of raw.blocked_tools)
54
80
  if (typeof item === "string")
55
- merged.deniedPaths.push(item);
81
+ merged.blockedTools.push(item);
56
82
  }
57
83
  return {
58
84
  enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
@@ -60,8 +86,11 @@ function tighten(merged, raw) {
60
86
  filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
61
87
  network: strictest(merged.network, raw.network, "deny", "allow"),
62
88
  unsandboxed: strictest(merged.unsandboxed, raw.unsandboxed, "deny", "allow"),
89
+ userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
90
+ blockedTools: merged.blockedTools,
63
91
  writableRoots: merged.writableRoots,
64
- deniedPaths: merged.deniedPaths
92
+ deniedPaths: merged.deniedPaths,
93
+ deniedPathsExplicit: merged.deniedPathsExplicit
65
94
  };
66
95
  }
67
96
  function strictest(current, incoming, strict, loose) {
@@ -74,6 +103,12 @@ function strictest(current, incoming, strict, loose) {
74
103
  function expandHome(item, home = os.homedir()) {
75
104
  return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
76
105
  }
106
+ function toolBlocked(tool, patterns) {
107
+ return patterns.some((pattern) => {
108
+ const source = pattern.split("*").map((part) => part.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")).join(".*");
109
+ return new RegExp(`^${source}$`).test(tool);
110
+ });
111
+ }
77
112
 
78
113
  // src/wire.ts
79
114
  import { randomBytes } from "node:crypto";
@@ -124,7 +159,8 @@ function wireRequest(input, platform = process.platform) {
124
159
  commandLine: input.command,
125
160
  timeout: input.timeout,
126
161
  cwd,
127
- inheritDefaultEnv: true
162
+ inheritDefaultEnv: true,
163
+ ...input.environment === undefined ? {} : { env: input.environment }
128
164
  },
129
165
  filesystem,
130
166
  ...input.policy.network === "deny" ? { network: { egress: { default: "deny" }, ingress: { default: "deny", hostLoopback: "deny" } } } : { network: { egress: { default: "allow" } } }
@@ -217,6 +253,10 @@ var src_default = Plugin.define({
217
253
  fs3.rmSync(pending.file, { force: true });
218
254
  };
219
255
  const before = await context.tool.hook("execute.before", async (event) => {
256
+ if (toolBlocked(event.tool, policy.blockedTools)) {
257
+ log("warn", `tool call to ${event.tool} blocked by the sandbox config (blocked_tools)`);
258
+ throw new Error(`universal-opencode-sandbox: the tool ${event.tool} is blocked by the sandbox config`);
259
+ }
220
260
  if (event.tool !== "shell")
221
261
  return;
222
262
  const input = event.input ?? {};
@@ -253,6 +293,32 @@ var src_default = Plugin.define({
253
293
  log("warn", `failed to wrap the command; running unsandboxed: ${error instanceof Error ? error.message : String(error)}`);
254
294
  }
255
295
  });
296
+ const shellBefore = policy.userCommands ? await context.shell.hook("create.before", async (invocation) => {
297
+ if (invocation.command.startsWith(`${quote(executor)} `))
298
+ return;
299
+ if (unavailable || gap) {
300
+ if (enforce)
301
+ throw new Error(`${BLOCKED}: ${reason}`);
302
+ return;
303
+ }
304
+ try {
305
+ const request = wireRequest({
306
+ command: invocation.command,
307
+ workspace: context.location.directory,
308
+ cwd: invocation.cwd,
309
+ timeout: invocation.timeout || 0,
310
+ policy
311
+ });
312
+ const id = `shell_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
313
+ const file = path3.join(requestDirectory, `${id}.json`);
314
+ fs3.writeFileSync(file, JSON.stringify(request), { mode: 384 });
315
+ invocation.command = `${quote(executor)} ${quote(file)}`;
316
+ } catch (error) {
317
+ if (enforce)
318
+ throw new Error(`${BLOCKED}: ${error instanceof Error ? error.message : String(error)}`);
319
+ log("warn", `failed to wrap a shell command; running unsandboxed: ${error instanceof Error ? error.message : String(error)}`);
320
+ }
321
+ }) : undefined;
256
322
  const after = await context.tool.hook("execute.after", async (event) => {
257
323
  const pending = inFlight.get(event.id);
258
324
  if (pending === undefined)
@@ -264,6 +330,8 @@ var src_default = Plugin.define({
264
330
  return async () => {
265
331
  await before.dispose();
266
332
  await after.dispose();
333
+ if (shellBefore)
334
+ await shellBefore.dispose();
267
335
  for (const callID of [...inFlight.keys()])
268
336
  cleanup(callID);
269
337
  };
package/dist/policy.d.ts CHANGED
@@ -11,12 +11,29 @@ export interface Policy {
11
11
  readonly network: "deny" | "allow";
12
12
  /** What happens when a tool call asks to skip the sandbox (shell input "sandbox: false"). */
13
13
  readonly unsandboxed: "deny" | "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[];
14
18
  }
19
+ /**
20
+ * Credential stores a sandboxed command can neither read nor write by default: the
21
+ * classic identity directories plus the plaintext credential files of the major
22
+ * CLIs, cloud SDKs and agent tools. A stolen long-lived credential cannot be undone
23
+ * by a restart, so reading is denied even though the network usually is too: a secret
24
+ * read once can be copied into the workspace and walk out through a commit. Setting
25
+ * denied_paths in the config replaces this list; the entries are then the user's.
26
+ */
27
+ export declare const defaultDeniedPaths: string[];
15
28
  export declare const defaultPolicy: Policy;
16
29
  /**
17
30
  * Resolves the sandbox policy for a workspace: the global config file, tightened by the
18
31
  * matching per-project override, tightened by UNIVERSAL_SANDBOX_CONFIG. Security fields
19
- * only ever get stricter across explicit values; roots and denied paths accumulate.
32
+ * only ever get stricter across explicit values; roots and blocked tools accumulate.
33
+ * Denied paths start from defaultDeniedPaths and are replaced wholesale by the first
34
+ * layer that sets denied_paths, then extended by later layers.
20
35
  */
21
36
  export declare function resolvePolicy(workspace: string, environment?: NodeJS.ProcessEnv, globalFile?: string): Policy;
22
37
  export declare function expandHome(item: string, home?: string): string;
38
+ /** True when the tool name matches a blocked pattern; "*" in a pattern matches any sequence. */
39
+ export declare function toolBlocked(tool: string, patterns: readonly string[]): boolean;
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.3.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",