universal-opencode-sandbox 0.1.0 → 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 +91 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +302 -0
- package/dist/index.js +77 -9
- package/dist/policy.d.ts +18 -1
- 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,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
|
-
// "
|
|
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
|
-
// Paths the sandboxed command can neither read nor write
|
|
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
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
|
|
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 ?? "
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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",
|