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 +105 -21
- package/dist/cli.js +30 -7
- package/dist/index.js +159 -10
- package/dist/policy.d.ts +14 -1
- package/dist/unsandboxed.d.ts +35 -0
- package/package.json +1 -1
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
95
|
-
the GitHub CLI token
|
|
96
|
-
-
|
|
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
|
|
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.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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(
|
|
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
|
+
"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",
|