universal-opencode-sandbox 0.3.0 → 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
@@ -51,16 +51,22 @@ on purpose: they can be set from a repository's own config.
51
51
  // directory, and writable_roots; "read-only" denies every write.
52
52
  "mode": "workspace-write",
53
53
  "writable_roots": [],
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.
57
- "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"]
58
58
  },
59
59
  // "deny" (default) blocks all network access for sandboxed commands.
60
60
  "network": "deny",
61
61
  // What happens when a tool call asks to skip the sandbox (shell input
62
- // "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.
63
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*"],
64
70
  // Also sandbox user "!" commands and plugin shells, protecting the user from
65
71
  // social engineering ("run this for me") and accidental damage. Default: false.
66
72
  "sandbox_user_commands": true,
@@ -68,7 +74,8 @@ on purpose: they can be set from a repository's own config.
68
74
  // escape routes outside the sandbox, such as a host app's unsandboxed terminal
69
75
  // tools (an agent can otherwise spawn or drive a raw shell there). Unlike
70
76
  // opencode's own permission rules, a repository config cannot re-allow these.
71
- "blocked_tools": ["paseo_create_terminal", "paseo_send_terminal_keys"],
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"],
72
79
  // Per-project overrides, keyed by the workspace's absolute path. Every field is
73
80
  // optional and can only make the policy stricter, never looser.
74
81
  "projects": {
@@ -79,6 +86,44 @@ on purpose: they can be set from a repository's own config.
79
86
 
80
87
  `UNIVERSAL_SANDBOX_CONFIG` (a JSON string) tightens the same fields per invocation.
81
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
+
82
127
  ### Default denied paths
83
128
 
84
129
  Without a `denied_paths` entry, the sandbox denies reading and writing these
@@ -86,20 +131,29 @@ credential stores. A stolen long-lived credential cannot be undone by a restart,
86
131
  so reading is denied even though the network usually is too: a secret read once
87
132
  can be copied into the workspace and walk out through a commit.
88
133
 
89
- - `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.kube`, `~/.azure` - identity
90
- directories: keys, cloud credentials, cluster tokens
91
- - `~/.config/gcloud` - GCP credentials and service-account keys
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)
92
143
  - `~/.npmrc`, `~/.pypirc`, `~/.cargo/credentials`, `~/.docker/config.json` -
93
144
  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
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)
97
150
  - `~/.claude/.credentials.json`, `~/.codex/auth.json` - credentials of the
98
151
  other agent CLIs
99
152
 
100
153
  Setting `denied_paths` replaces this list. On Linux only paths that exist are
101
154
  enforced (an upstream deny bug would otherwise create stray directories,
102
- microsoft/mxc#1780); on macOS not-yet-existing paths are protected too.
155
+ microsoft/mxc#1780); on macOS and Windows not-yet-existing paths are protected
156
+ too.
103
157
 
104
158
  ## What is enforced
105
159
 
@@ -148,13 +202,35 @@ disable the admin terminal for regular users. The plugin confines opencode agent
148
202
  terminals spawned by a host app (for example Paseo) are confined by having the host
149
203
  start them through `sandboxed-shell` instead of a plain shell. Whoever controls
150
204
  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:
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.
155
231
 
156
232
  ```jsonc
157
- // opencode.jsonc, next to this plugin in your config
233
+ // opencode.jsonc - example: denying the Paseo terminal tools
158
234
  {
159
235
  "permissions": [
160
236
  { "action": "paseo_create_terminal", "resource": "*", "effect": "deny" },
@@ -164,8 +240,10 @@ hide them from the agent's tool catalog entirely:
164
240
  }
165
241
  ```
166
242
 
167
- Use the `permissions` array: the older `permission` object is deprecated and, on
168
- current releases, silently ignored.
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.
169
247
 
170
248
  ## Known limitations
171
249
 
@@ -176,7 +254,13 @@ current releases, silently ignored.
176
254
  commands, prefer this plugin's `denied_paths` and filesystem policy.
177
255
  - Intel macOS is unsupported: the MXC npm package ships no darwin-x64 runtime yet.
178
256
  - MXC release builds on Windows emit required Microsoft diagnostic telemetry; see the
179
- [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.
180
264
  - MXC's process streams do not deliver under Bun (microsoft/mxc#1782). This plugin is
181
265
  unaffected: the executor is a native binary whose output the shell tool captures.
182
266
 
package/dist/cli.js CHANGED
@@ -16,15 +16,18 @@ var defaultDeniedPaths = [
16
16
  "~/.aws",
17
17
  "~/.gnupg",
18
18
  "~/.netrc",
19
+ "~/_netrc",
19
20
  "~/.kube",
20
21
  "~/.azure",
21
22
  "~/.config/gcloud",
23
+ "~/AppData/Roaming/gcloud",
22
24
  "~/.npmrc",
23
25
  "~/.pypirc",
24
26
  "~/.cargo/credentials",
25
27
  "~/.docker/config.json",
26
28
  "~/.config/gh/hosts.yml",
27
29
  "~/Library/Application Support/gh/hosts.yml",
30
+ "~/AppData/Roaming/gh/hosts.yml",
28
31
  "~/.local/share/opencode/auth.json",
29
32
  "~/.claude/.credentials.json",
30
33
  "~/.codex/auth.json"
@@ -32,7 +35,7 @@ var defaultDeniedPaths = [
32
35
  var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
33
36
  var configPath = () => path.join(configDirectory(), "config.json");
34
37
  function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
35
- let merged = { writableRoots: [], deniedPaths: [], blockedTools: [] };
38
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [], unsandboxedAllow: [] };
36
39
  const global = readConfig(globalFile);
37
40
  if (global)
38
41
  merged = tighten(merged, global);
@@ -56,7 +59,8 @@ function resolvePolicy(workspace, environment = process.env, globalFile = config
56
59
  network: merged.network ?? "deny",
57
60
  unsandboxed: merged.unsandboxed ?? "deny",
58
61
  userCommands: merged.userCommands ?? false,
59
- blockedTools: [...new Set(merged.blockedTools)]
62
+ blockedTools: [...new Set(merged.blockedTools)],
63
+ unsandboxedAllow: [...new Set(merged.unsandboxedAllow)]
60
64
  };
61
65
  }
62
66
  function readConfig(file) {
@@ -82,14 +86,20 @@ function tighten(merged, raw) {
82
86
  if (typeof item === "string")
83
87
  merged.blockedTools.push(item);
84
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
+ }
85
94
  return {
86
95
  enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
87
96
  mode: strictest(merged.mode, raw.mode, "enforce", "permissive"),
88
97
  filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
89
98
  network: strictest(merged.network, raw.network, "deny", "allow"),
90
- unsandboxed: strictest(merged.unsandboxed, raw.unsandboxed, "deny", "allow"),
99
+ unsandboxed: strictestUnsandboxed(merged.unsandboxed, raw.unsandboxed),
91
100
  userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
92
101
  blockedTools: merged.blockedTools,
102
+ unsandboxedAllow: merged.unsandboxedAllow,
93
103
  writableRoots: merged.writableRoots,
94
104
  deniedPaths: merged.deniedPaths,
95
105
  deniedPathsExplicit: merged.deniedPathsExplicit
@@ -102,14 +112,27 @@ function strictest(current, incoming, strict, loose) {
102
112
  return loose;
103
113
  return current;
104
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
+ }
105
124
  function expandHome(item, home = os.homedir()) {
106
125
  return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
107
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
+ }
108
134
  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
- });
135
+ return patterns.some((pattern) => matchesWildcard(tool, pattern));
113
136
  }
114
137
 
115
138
  // src/wire.ts
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
 
@@ -14,15 +14,18 @@ var defaultDeniedPaths = [
14
14
  "~/.aws",
15
15
  "~/.gnupg",
16
16
  "~/.netrc",
17
+ "~/_netrc",
17
18
  "~/.kube",
18
19
  "~/.azure",
19
20
  "~/.config/gcloud",
21
+ "~/AppData/Roaming/gcloud",
20
22
  "~/.npmrc",
21
23
  "~/.pypirc",
22
24
  "~/.cargo/credentials",
23
25
  "~/.docker/config.json",
24
26
  "~/.config/gh/hosts.yml",
25
27
  "~/Library/Application Support/gh/hosts.yml",
28
+ "~/AppData/Roaming/gh/hosts.yml",
26
29
  "~/.local/share/opencode/auth.json",
27
30
  "~/.claude/.credentials.json",
28
31
  "~/.codex/auth.json"
@@ -30,7 +33,7 @@ var defaultDeniedPaths = [
30
33
  var configDirectory = () => path.join(process.env.XDG_CONFIG_HOME ?? path.join(realHome(), ".config"), "universal-opencode-sandbox");
31
34
  var configPath = () => path.join(configDirectory(), "config.json");
32
35
  function resolvePolicy(workspace, environment = process.env, globalFile = configPath()) {
33
- let merged = { writableRoots: [], deniedPaths: [], blockedTools: [] };
36
+ let merged = { writableRoots: [], deniedPaths: [], blockedTools: [], unsandboxedAllow: [] };
34
37
  const global = readConfig(globalFile);
35
38
  if (global)
36
39
  merged = tighten(merged, global);
@@ -54,7 +57,8 @@ function resolvePolicy(workspace, environment = process.env, globalFile = config
54
57
  network: merged.network ?? "deny",
55
58
  unsandboxed: merged.unsandboxed ?? "deny",
56
59
  userCommands: merged.userCommands ?? false,
57
- blockedTools: [...new Set(merged.blockedTools)]
60
+ blockedTools: [...new Set(merged.blockedTools)],
61
+ unsandboxedAllow: [...new Set(merged.unsandboxedAllow)]
58
62
  };
59
63
  }
60
64
  function readConfig(file) {
@@ -80,14 +84,20 @@ function tighten(merged, raw) {
80
84
  if (typeof item === "string")
81
85
  merged.blockedTools.push(item);
82
86
  }
87
+ if (Array.isArray(raw.unsandboxed_allow)) {
88
+ for (const item of raw.unsandboxed_allow)
89
+ if (typeof item === "string")
90
+ merged.unsandboxedAllow.push(item);
91
+ }
83
92
  return {
84
93
  enabled: raw.enabled === undefined ? merged.enabled : raw.enabled === false ? false : merged.enabled === false ? false : true,
85
94
  mode: strictest(merged.mode, raw.mode, "enforce", "permissive"),
86
95
  filesystemMode: strictest(merged.filesystemMode, raw.filesystem?.mode, "read-only", "workspace-write"),
87
96
  network: strictest(merged.network, raw.network, "deny", "allow"),
88
- unsandboxed: strictest(merged.unsandboxed, raw.unsandboxed, "deny", "allow"),
97
+ unsandboxed: strictestUnsandboxed(merged.unsandboxed, raw.unsandboxed),
89
98
  userCommands: raw.sandbox_user_commands === true || merged.userCommands === true,
90
99
  blockedTools: merged.blockedTools,
100
+ unsandboxedAllow: merged.unsandboxedAllow,
91
101
  writableRoots: merged.writableRoots,
92
102
  deniedPaths: merged.deniedPaths,
93
103
  deniedPathsExplicit: merged.deniedPathsExplicit
@@ -100,14 +110,27 @@ function strictest(current, incoming, strict, loose) {
100
110
  return loose;
101
111
  return current;
102
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
+ }
103
122
  function expandHome(item, home = os.homedir()) {
104
123
  return item === "~" ? home : item.startsWith("~/") ? path.join(home, item.slice(2)) : item;
105
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
+ }
106
132
  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
- });
133
+ return patterns.some((pattern) => matchesWildcard(tool, pattern));
111
134
  }
112
135
 
113
136
  // src/wire.ts
@@ -225,6 +248,75 @@ function windowsArgument(value) {
225
248
  return quoted + "\\".repeat(backslashes * 2) + '"';
226
249
  }
227
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
+
228
320
  // src/index.ts
229
321
  var BLOCKED = "universal-opencode-sandbox unavailable; command blocked";
230
322
  var src_default = Plugin.define({
@@ -243,7 +335,7 @@ var src_default = Plugin.define({
243
335
  log(enforce ? "error" : "warn", `${reason}; ${enforce ? 'shell commands will be blocked (set mode "permissive" to run unsandboxed)' : "commands will run without a sandbox"}`);
244
336
  }
245
337
  const inFlight = new Map;
246
- const requestDirectory = path3.join(os3.tmpdir(), "universal-opencode-sandbox");
338
+ const requestDirectory = path3.join(os4.tmpdir(), "universal-opencode-sandbox");
247
339
  fs3.mkdirSync(requestDirectory, { recursive: true });
248
340
  const cleanup = (callID) => {
249
341
  const pending = inFlight.get(callID);
@@ -252,11 +344,50 @@ var src_default = Plugin.define({
252
344
  inFlight.delete(callID);
253
345
  fs3.rmSync(pending.file, { force: true });
254
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;
255
370
  const before = await context.tool.hook("execute.before", async (event) => {
256
371
  if (toolBlocked(event.tool, policy.blockedTools)) {
257
372
  log("warn", `tool call to ${event.tool} blocked by the sandbox config (blocked_tools)`);
258
373
  throw new Error(`universal-opencode-sandbox: the tool ${event.tool} is blocked by the sandbox config`);
259
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
+ }
260
391
  if (event.tool !== "shell")
261
392
  return;
262
393
  const input = event.input ?? {};
@@ -265,9 +396,23 @@ var src_default = Plugin.define({
265
396
  return;
266
397
  if (input.sandbox === false) {
267
398
  if (policy.unsandboxed === "deny") {
268
- 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)`);
269
400
  throw new Error("universal-opencode-sandbox: running this command unsandboxed is not allowed");
270
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
+ }
271
416
  return;
272
417
  }
273
418
  if (unavailable || gap) {
@@ -332,6 +477,10 @@ var src_default = Plugin.define({
332
477
  await after.dispose();
333
478
  if (shellBefore)
334
479
  await shellBefore.dispose();
480
+ if (escapeTool)
481
+ await escapeTool.dispose();
482
+ if (askGuard)
483
+ await askGuard.dispose();
335
484
  for (const callID of [...inFlight.keys()])
336
485
  cleanup(callID);
337
486
  };
package/dist/policy.d.ts CHANGED
@@ -10,11 +10,13 @@ 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
14
  /** Also sandbox user "!" commands and plugin shells, not just agent shell tool calls. */
15
15
  readonly userCommands: boolean;
16
16
  /** Tool names the agent may not call at all, with "*" as a wildcard ("paseo_*"). */
17
17
  readonly blockedTools: string[];
18
+ /** Commands pre-approved to run unsandboxed without a prompt, with "*" wildcards. */
19
+ readonly unsandboxedAllow: string[];
18
20
  }
19
21
  /**
20
22
  * Credential stores a sandboxed command can neither read nor write by default: the
@@ -23,6 +25,11 @@ export interface Policy {
23
25
  * by a restart, so reading is denied even though the network usually is too: a secret
24
26
  * read once can be copied into the workspace and walk out through a commit. Setting
25
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.
26
33
  */
27
34
  export declare const defaultDeniedPaths: string[];
28
35
  export declare const defaultPolicy: Policy;
@@ -35,5 +42,11 @@ export declare const defaultPolicy: Policy;
35
42
  */
36
43
  export declare function resolvePolicy(workspace: string, environment?: NodeJS.ProcessEnv, globalFile?: string): Policy;
37
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;
38
51
  /** True when the tool name matches a blocked pattern; "*" in a pattern matches any sequence. */
39
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-opencode-sandbox",
3
- "version": "0.3.0",
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",