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 +178 -10
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +325 -0
- package/dist/index.js +230 -13
- package/dist/policy.d.ts +32 -2
- package/dist/unsandboxed.d.ts +35 -0
- package/dist/wire.d.ts +2 -0
- package/package.json +5 -2
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
// "
|
|
49
|
-
//
|
|
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
|
-
//
|
|
53
|
-
|
|
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
|
|
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
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
|
|
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
|
|
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 ?? "
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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(
|
|
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
|
|
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.
|
|
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",
|