@semalt-ai/code 1.8.5 → 1.19.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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
package/lib/sandbox.js ADDED
@@ -0,0 +1,568 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // OS-level filesystem sandbox for shell commands (Task 4.4)
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Wraps every shell command (and its child processes) in a kernel-enforced
8
+ // filesystem jail so confinement is the OS's job, not trust or pattern-matching.
9
+ // This is an ADDITIONAL boundary UNDER the existing deny-list (lib/deny.js),
10
+ // per-pattern permissions (lib/permissions.js), --readonly, and isPathSafe
11
+ // (lib/tools.js) — defense in depth. All of those still run; the sandbox catches
12
+ // what they miss.
13
+ //
14
+ // Policy model (what's allowed / denied):
15
+ // * Reads are allowed broadly (the whole filesystem is readable).
16
+ // * Writes are confined to the working directory (and a writable temp dir).
17
+ // With --allow-anywhere the whole filesystem becomes writable EXCEPT the
18
+ // protected paths below, which stay read-only regardless.
19
+ // * Network is BINARY (Task 4.4b): a sandboxed command has either normal
20
+ // network (the default — otherwise `npm install`/`pip` are unusable) or
21
+ // NONE, enforced by the kernel: bwrap `--unshare-net` (a fresh network
22
+ // namespace with no real interfaces) on Linux, a Seatbelt `(deny network*)`
23
+ // clause on macOS. There is deliberately NO host proxy, NO domain
24
+ // allowlist, and NO TLS interception — see the rationale below.
25
+ //
26
+ // Why binary, not a domain allowlist (the state-of-the-art lesson):
27
+ // The reference implementation (Claude Code) shipped a domain-allowlist
28
+ // network sandbox via a host-side SOCKS/HTTP proxy. It was bypassed COMPLETELY,
29
+ // twice, by two independent researchers, over 5.5 months — because OS
30
+ // enforcement correctly pins the agent to localhost, but the egress decision is
31
+ // delegated to a host-side proxy with full network privileges, and fooling the
32
+ // proxy makes the HOST dial out. The documented failures: (a) `allowedDomains:
33
+ // []` (the most-restrictive INTENT) was read as "allow all" via an
34
+ // `allowedDomains.length > 0` check — a FAIL-OPEN (CVE-2025-66479);
35
+ // (b) a JS-vs-libc hostname-parser differential (`endsWith()`); (c) TLS MITM in
36
+ // the proxy broke Go binaries (`gh`, `gcloud`). The proxy also rode on an
37
+ // abandoned dependency in the security path.
38
+ // We choose BINARY isolation to remove that entire class of bypass by
39
+ // construction: network is on (normal TLS, Go binaries work) or off
40
+ // (kernel-level), with no proxy, no allowlist, no interception, and no new
41
+ // dependency. Domain-granularity is out of scope (deferred), with the rationale
42
+ // recorded in CLAUDE.md.
43
+ //
44
+ // Anti-fail-open (the allowedDomains:[] lesson, constraint #2): network defaults
45
+ // ON for sandboxed commands, but once a human TOUCHES the network setting
46
+ // (`sandbox.network` in config, or the `--no-network` flag) that is an
47
+ // "isolation-requested" context — and there, anything we do not explicitly
48
+ // recognize as "on" (empty/missing/malformed) resolves to the SAFE isolated
49
+ // state (no-network), NEVER silently back to network. See normalizeSandbox.
50
+ //
51
+ // Platforms:
52
+ // * macOS → Seatbelt via `sandbox-exec` (built-in, nothing to
53
+ // install). An SBPL policy string is generated per call.
54
+ // * Linux / WSL2 → `bwrap` (bubblewrap, unprivileged user namespaces).
55
+ // * Windows / WSL1 → no OS primitive (bwrap needs namespaces WSL1 lacks;
56
+ // native Windows has none). The sandbox is UNAVAILABLE;
57
+ // see the fallback rules in agentExecShell.
58
+ //
59
+ // The three real-CVE constraints this enforces:
60
+ // 1. The agent can NEVER disable the sandbox. There is no tool/flag/config
61
+ // the MODEL can reach that turns it off — `sandbox.mode` lives in the
62
+ // user/project config files (human-edited) and the only runtime opt-out
63
+ // is a human-typed CLI flag. (A blocked agent must not be able to "finish
64
+ // the task" by escaping its jail.)
65
+ // 2. config / hooks / secrets are READ-ONLY inside the jail, INCLUDING files
66
+ // that do not yet exist (CVE-2026-25725): the whole ~/.semalt-ai dir, the
67
+ // secret-file dirs, and system config are bind-mounted read-only, so the
68
+ // sandboxed process cannot create a missing config.json to inject hooks.
69
+ // 3. procfs / symlink / .. rewrites are confined on the RESOLVED real path,
70
+ // not the textual one (the /proc/self/root bypass): bwrap mounts a fresh
71
+ // /proc and the kernel enforces every bind on the resolved path; the
72
+ // protected paths are realpath()-canonicalized before they are bound.
73
+ //
74
+ // Fallback (fail-safe, defaults safe): if the sandbox can't start (missing
75
+ // bwrap, unsupported platform) we do NOT silently run unsandboxed. By default
76
+ // the command falls back to a human approval (`onUnsandboxed`); with no
77
+ // approver (non-TTY/headless) it is REFUSED. `sandbox.failIfUnavailable: true`
78
+ // turns the fallback into a hard error for teams that want a strict gate.
79
+
80
+ const fs = require('fs');
81
+ const os = require('os');
82
+ const path = require('path');
83
+ const { spawnSync } = require('child_process');
84
+
85
+ const { protectedConfigDirs } = require('./constants');
86
+
87
+ const SANDBOX_MODES = ['auto', 'off'];
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Config
91
+ // ---------------------------------------------------------------------------
92
+
93
+ // Validate + canonicalize `config.sandbox`. Pure; consumed by lib/config.js
94
+ // normalizeConfig. `mode` is `auto` (use the sandbox when available) or `off`
95
+ // (a deliberate human opt-out). `failIfUnavailable` makes an unavailable sandbox
96
+ // a hard error instead of an approval fallback. `network` is `on` (the default —
97
+ // the sandbox must stay usable for `npm install`/`pip`) or `off` (kernel-level
98
+ // no-network for sandboxed commands). Unknown input → safe defaults.
99
+ //
100
+ // Anti-fail-open (constraint #2, the allowedDomains:[] → "allow all" CVE-2025-66479
101
+ // lesson): `network` defaults ON only when the human has NOT touched it (the key is
102
+ // absent). The moment the `network` key is PRESENT — an isolation-requested context
103
+ // — anything that is not EXACTLY the string 'on' (empty, malformed, an object, a
104
+ // typo, `false`, `null`, …) resolves to the SAFE isolated state 'off', never
105
+ // silently back to network. So the intended-most-restrictive input is the
106
+ // most-restrictive outcome, and a broken config fails toward isolation.
107
+ function normalizeSandbox(raw) {
108
+ const out = { mode: 'auto', failIfUnavailable: false, network: 'on' };
109
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
110
+ if (raw.mode === 'off') out.mode = 'off';
111
+ if (raw.failIfUnavailable === true) out.failIfUnavailable = true;
112
+ // PRESENT-but-not-exactly-'on' ⇒ 'off' (fail-safe toward isolation). Absent ⇒
113
+ // the default 'on'.
114
+ if ('network' in raw) out.network = raw.network === 'on' ? 'on' : 'off';
115
+ return out;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Protected paths (constraint #2)
120
+ // ---------------------------------------------------------------------------
121
+
122
+ // The paths that must be READ-ONLY inside the jail no matter what — including
123
+ // when they do not yet exist (a not-yet-present config.json cannot be created
124
+ // because its whole parent dir is bound read-only). Resolved real paths so a
125
+ // symlink/.. rewrite cannot dodge them (constraint #3).
126
+ function protectedPaths({ home = os.homedir(), cwd = process.cwd() } = {}) {
127
+ const raw = [
128
+ // The protected-CONFIG set, single-sourced (Pre-Task 5.0b): the whole
129
+ // ~/.semalt-ai dir AND every project .semalt dir from cwd up to the repo
130
+ // root. Binding the project .semalt dir read-only is what stops a sandboxed
131
+ // shell command from creating/modifying .semalt/config.json (or agents/hooks)
132
+ // even though .semalt sits inside the writable CWD — the project equivalent
133
+ // of the not-yet-existing-config CVE-2026-25725 guard for ~/.semalt-ai.
134
+ ...protectedConfigDirs({ home, cwd }),
135
+ path.join(home, '.ssh'),
136
+ path.join(home, '.aws'),
137
+ path.join(home, '.gnupg'),
138
+ '/etc',
139
+ ];
140
+ const out = [];
141
+ const seen = new Set();
142
+ for (const p of raw) {
143
+ let real = p;
144
+ try { real = fs.realpathSync(p); } catch { real = path.resolve(p); }
145
+ if (!seen.has(real)) { seen.add(real); out.push(real); }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Policy generation
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function _sbplQuote(p) {
155
+ // SBPL string literal — escape backslashes and double quotes.
156
+ return '"' + String(p).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
157
+ }
158
+
159
+ // macOS Seatbelt (SBPL) policy. Rule precedence in SBPL is LAST-MATCH-WINS, so
160
+ // the order is: allow everything (keeps reads + network working) → deny all
161
+ // writes → re-allow writes under the writable roots → re-deny the protected
162
+ // paths (last, so they win even if nested under a writable root). With
163
+ // rootWritable (--allow-anywhere) we skip the blanket write-deny but STILL
164
+ // re-deny the protected paths.
165
+ function buildSeatbeltPolicy({ writableRoots = [], protectedPaths: protectedList = [], rootWritable = false, network = 'on' } = {}) {
166
+ const lines = [
167
+ '(version 1)',
168
+ '; semalt-code OS sandbox — filesystem confinement + binary network isolation (Task 4.4/4.4b)',
169
+ '(allow default)',
170
+ ];
171
+ // Binary network isolation (Task 4.4b): deny ALL network operations. Placed
172
+ // right after `(allow default)` so last-match-wins keeps it denied (nothing
173
+ // below re-allows network — the write rules touch only file-write*). This is a
174
+ // kernel/Seatbelt deny, NOT a host proxy: no TLS interception, so it never
175
+ // breaks Go binaries the way a MITM proxy does — it simply removes the network.
176
+ if (network === 'off') lines.push('(deny network*)');
177
+ if (!rootWritable) {
178
+ lines.push('(deny file-write* (subpath "/"))');
179
+ // Standard pseudo-devices must stay writable or shells break (/dev/null, tty).
180
+ lines.push('(allow file-write* (subpath "/dev"))');
181
+ for (const w of writableRoots) {
182
+ if (w) lines.push(`(allow file-write* (subpath ${_sbplQuote(w)}))`);
183
+ }
184
+ }
185
+ for (const p of protectedList) {
186
+ if (p) lines.push(`(deny file-write* (subpath ${_sbplQuote(p)}))`);
187
+ }
188
+ return lines.join('\n');
189
+ }
190
+
191
+ // Linux bubblewrap argument vector. bwrap applies binds IN ORDER and a later
192
+ // bind over an overlapping path WINS, so: bind the whole fs (read-only by
193
+ // default, read-write under --allow-anywhere) → fresh /proc + /dev → re-bind the
194
+ // writable roots read-write → re-bind the protected paths read-only LAST (so
195
+ // they win over a writable root they sit inside, e.g. cwd == $HOME) → chdir.
196
+ function buildBwrapArgs({ writableRoots = [], protectedPaths: protectedList = [], rootWritable = false, chdir, fsExists, network = 'on' } = {}) {
197
+ const exists = typeof fsExists === 'function'
198
+ ? fsExists
199
+ : (p) => { try { return fs.existsSync(p); } catch { return false; } };
200
+ const args = [];
201
+ // Binary network isolation (Task 4.4b): a fresh, unconnected network namespace.
202
+ // `--unshare-net` gives the jail no real interfaces — kernel-enforced no-network,
203
+ // no host proxy, no TLS interception. Placed first so it is unambiguous; bwrap
204
+ // applies unshare flags independent of bind order. Omitted entirely when network
205
+ // is 'on' (the default) so normal egress + TLS work.
206
+ if (network === 'off') args.push('--unshare-net');
207
+ args.push(rootWritable ? '--bind' : '--ro-bind', '/', '/');
208
+ // Fresh procfs is load-bearing: it makes /proc/self/root resolve to the jail
209
+ // root, so a /proc/self/root/<path> rewrite is confined on the resolved path
210
+ // exactly like the textual path (constraint #3).
211
+ args.push('--proc', '/proc');
212
+ args.push('--dev', '/dev');
213
+ for (const w of writableRoots) {
214
+ if (w && exists(w)) args.push('--bind', w, w);
215
+ }
216
+ for (const p of protectedList) {
217
+ if (p && exists(p)) args.push('--ro-bind', p, p);
218
+ }
219
+ if (chdir) args.push('--chdir', chdir);
220
+ return args;
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Detection (cached)
225
+ // ---------------------------------------------------------------------------
226
+
227
+ let _detectionCache = null;
228
+
229
+ function _defaultWhich(name) {
230
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
231
+ const extras = ['/usr/bin', '/bin', '/usr/sbin', '/sbin', '/usr/local/bin', '/opt/homebrew/bin'];
232
+ for (const d of dirs.concat(extras)) {
233
+ const p = path.join(d, name);
234
+ try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* keep looking */ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ // Functional probe: bwrap can be installed yet unusable (WSL1, or a kernel with
240
+ // unprivileged user namespaces disabled). Actually launch a trivial jail and
241
+ // require a clean exit before we trust it.
242
+ function _defaultBwrapProbe(bwrapPath) {
243
+ try {
244
+ const r = spawnSync(bwrapPath, ['--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '/bin/true'], {
245
+ timeout: 5000,
246
+ stdio: 'ignore',
247
+ });
248
+ return r && r.status === 0;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ // Detect the platform + tool availability ONCE and cache it. Injectable deps
255
+ // (`platform`, `which`, `probe`, `readFile`) make every platform path unit
256
+ // testable without the real tools. `force: true` bypasses the cache.
257
+ //
258
+ // Returns: { platform, supported, tool, available, reason, installHint }
259
+ // supported — is this a platform we have a sandbox strategy for at all?
260
+ // available — is the tool present AND functional right now?
261
+ // reason — why unavailable (when !available)
262
+ // installHint— actionable remediation for the user
263
+ function detectSandbox(opts = {}) {
264
+ if (_detectionCache && !opts.force) return _detectionCache;
265
+ const platform = opts.platform || process.platform;
266
+ const which = opts.which || _defaultWhich;
267
+ const probe = opts.probe || _defaultBwrapProbe;
268
+ const readFile = opts.readFile || ((p) => fs.readFileSync(p, 'utf8'));
269
+
270
+ let result;
271
+ if (platform === 'darwin') {
272
+ const binPath = which('sandbox-exec');
273
+ result = binPath
274
+ ? { platform, supported: true, tool: 'sandbox-exec', available: true, reason: null, installHint: null, binPath }
275
+ : { platform, supported: true, tool: 'sandbox-exec', available: false, reason: 'sandbox-exec not found (it ships with macOS; PATH may be stripped)', installHint: 'Ensure /usr/bin is on PATH.' };
276
+ } else if (platform === 'linux') {
277
+ // WSL1 lacks the user/mount namespaces bwrap needs. WSL2 has them. We don't
278
+ // hard-fail on the WSL1 string — the functional probe is the source of truth
279
+ // — but we surface a clearer reason when we can tell it's WSL1.
280
+ let isWsl1 = false;
281
+ try {
282
+ const ver = readFile('/proc/version');
283
+ if (/microsoft/i.test(ver) && !/WSL2/i.test(ver)) isWsl1 = true;
284
+ } catch { /* /proc/version unreadable — fall through to the probe */ }
285
+ const binPath = which('bwrap');
286
+ if (!binPath) {
287
+ result = {
288
+ platform, supported: true, tool: 'bwrap', available: false,
289
+ reason: isWsl1 ? 'bubblewrap not found (and WSL1 cannot run it)' : 'bubblewrap (bwrap) not found',
290
+ installHint: 'Install bubblewrap: `apt install bubblewrap` (Debian/Ubuntu) or `dnf install bubblewrap` (Fedora/RHEL).',
291
+ };
292
+ } else if (!probe(binPath)) {
293
+ result = {
294
+ platform, supported: true, tool: 'bwrap', available: false,
295
+ reason: isWsl1
296
+ ? 'bubblewrap is installed but WSL1 lacks the user/mount namespaces it needs'
297
+ : 'bubblewrap is installed but could not start a jail (unprivileged user namespaces may be disabled)',
298
+ installHint: isWsl1
299
+ ? 'Use WSL2 (`wsl --set-version <distro> 2`) for kernel-level sandboxing.'
300
+ : 'Enable unprivileged user namespaces: `sysctl -w kernel.unprivileged_userns_clone=1`.',
301
+ binPath,
302
+ };
303
+ } else {
304
+ result = { platform, supported: true, tool: 'bwrap', available: true, reason: null, installHint: null, binPath };
305
+ }
306
+ } else if (platform === 'win32') {
307
+ result = {
308
+ platform, supported: false, tool: null, available: false,
309
+ reason: 'native Windows has no OS sandbox primitive for this',
310
+ installHint: 'Run inside WSL2 with bubblewrap installed for kernel-level sandboxing.',
311
+ };
312
+ } else {
313
+ result = {
314
+ platform, supported: false, tool: null, available: false,
315
+ reason: `no sandbox strategy for platform "${platform}"`,
316
+ installHint: null,
317
+ };
318
+ }
319
+
320
+ if (!opts.noCache) _detectionCache = result;
321
+ return result;
322
+ }
323
+
324
+ // Test seam — drop the cached detection.
325
+ function _resetSandboxDetection() { _detectionCache = null; }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Command wrapping
329
+ // ---------------------------------------------------------------------------
330
+
331
+ // Wrap a shell command string into a sandboxed argv for the detected tool.
332
+ // Returns { file, args } to hand to spawn (NO shell:true — the inner /bin/sh
333
+ // runs the command), or null when the tool can't be wrapped.
334
+ //
335
+ // tool 'bwrap' | 'sandbox-exec'
336
+ // command the raw shell command string
337
+ // cwd working dir (the writable root + chdir target)
338
+ // allowAnywhere mirror of --allow-anywhere: make the whole fs writable EXCEPT
339
+ // the protected paths (which stay read-only regardless)
340
+ // network 'on' (default, normal egress) | 'off' (kernel-level no-network:
341
+ // --unshare-net / Seatbelt (deny network*))
342
+ function wrapCommand(command, { tool, cwd = process.cwd(), allowAnywhere = false, home = os.homedir(), tmpDir = os.tmpdir(), binPath, network = 'on' } = {}) {
343
+ if (typeof command !== 'string' || !command) return null;
344
+ let realCwd = cwd;
345
+ try { realCwd = fs.realpathSync(cwd); } catch { realCwd = path.resolve(cwd); }
346
+ const protectedList = protectedPaths({ home, cwd: realCwd });
347
+ // Default: cwd + temp are the only writable roots. --allow-anywhere makes the
348
+ // whole fs writable (rootWritable) so explicit writable roots are redundant.
349
+ const writableRoots = allowAnywhere ? [] : [realCwd, tmpDir].filter(Boolean);
350
+
351
+ if (tool === 'bwrap') {
352
+ const bwrapArgs = buildBwrapArgs({
353
+ writableRoots,
354
+ protectedPaths: protectedList,
355
+ rootWritable: allowAnywhere,
356
+ chdir: realCwd,
357
+ network,
358
+ });
359
+ return { file: binPath || 'bwrap', args: [...bwrapArgs, '/bin/sh', '-c', command] };
360
+ }
361
+ if (tool === 'sandbox-exec') {
362
+ const policy = buildSeatbeltPolicy({
363
+ writableRoots,
364
+ protectedPaths: protectedList,
365
+ rootWritable: allowAnywhere,
366
+ network,
367
+ });
368
+ return { file: binPath || 'sandbox-exec', args: ['-p', policy, '/bin/sh', '-c', command] };
369
+ }
370
+ return null;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Decision (config × detection)
375
+ // ---------------------------------------------------------------------------
376
+
377
+ // Resolve the effective network mode from config + the human-typed CLI flag.
378
+ // 'off' when EITHER the human set `sandbox.network` to anything-not-'on' (handled
379
+ // by normalizeSandbox's anti-fail-open) OR `--no-network` is present. `noNetwork`
380
+ // defaults to the argv flag — a human-only signal the model can never reach (the
381
+ // model controls only the command string). Pure given its inputs.
382
+ function resolveNetworkMode(s, noNetwork) {
383
+ const nn = noNetwork !== undefined ? noNetwork : _argvHasFlag('--no-network');
384
+ return (nn || s.network === 'off') ? 'off' : 'on';
385
+ }
386
+
387
+ // Combine the normalized config with detection into a per-command decision.
388
+ // status 'on' — wrap + run sandboxed (decision.network carries the
389
+ // kernel-enforced network mode 'on'|'off')
390
+ // status 'off' — mode is `off` (a deliberate human opt-out); run plain.
391
+ // Network isolation needs the jail, so an unsandboxed run
392
+ // has the host network (reported as net 'on' downstream).
393
+ // status 'unavailable' — wanted but the tool isn't usable; the caller applies
394
+ // the fallback (failIfUnavailable → hard error; else a
395
+ // human approval; no approver → refuse)
396
+ function decideSandbox({ getConfig, detection, noNetwork } = {}) {
397
+ let cfg = {};
398
+ try { cfg = (getConfig ? getConfig() : {}) || {}; } catch { cfg = {}; }
399
+ const s = normalizeSandbox(cfg.sandbox);
400
+ const network = resolveNetworkMode(s, noNetwork);
401
+ const det = detection || detectSandbox();
402
+ if (s.mode === 'off') {
403
+ return { status: 'off', tool: null, reason: 'mode is off (human opt-out)', failIfUnavailable: s.failIfUnavailable, network };
404
+ }
405
+ if (det.available) {
406
+ return { status: 'on', tool: det.tool, binPath: det.binPath, reason: null, failIfUnavailable: s.failIfUnavailable, network };
407
+ }
408
+ return {
409
+ status: 'unavailable',
410
+ tool: det.tool,
411
+ reason: det.reason,
412
+ installHint: det.installHint,
413
+ supported: det.supported,
414
+ failIfUnavailable: s.failIfUnavailable,
415
+ network,
416
+ };
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Shared sandbox-wrapping shim (Pre-Task 5.0a)
421
+ // ---------------------------------------------------------------------------
422
+ //
423
+ // THE universal shell chokepoint. Every shell-executing path in the codebase —
424
+ // the agent's exec/shell tools (agentExecShell, async spawn), self-verification
425
+ // (lib/verify.js, spawnSync), and command-type lifecycle hooks (lib/hooks.js,
426
+ // spawnSync) — resolves its spawn through THIS function, so the OS sandbox is
427
+ // applied identically everywhere and the model has no path that runs a command
428
+ // outside it.
429
+ //
430
+ // It folds the config×detection decision (decideSandbox), the command wrapping
431
+ // (wrapCommand), and the fail-safe fallback (failIfUnavailable hard error / human
432
+ // approval / refuse) into a single async resolution the caller spawns:
433
+ //
434
+ // { run: true, file, args, useShell, sandbox: 'on'|'off'|'unavailable' }
435
+ // Spawn `file`. When `useShell` is true there are no args and the caller
436
+ // passes { shell: true } (a deliberate UNsandboxed run — mode is off, the
437
+ // human approved an unavailable run, or --dangerously-skip-permissions).
438
+ // When false, spawn `file` with `args` and NO shell (the inner /bin/sh in
439
+ // the wrapped argv runs the command jailed).
440
+ // { run: false, sandbox: 'unavailable', hard, reason, installHint, message }
441
+ // REFUSED — the caller must NOT run the command. `hard` true ⇒
442
+ // failIfUnavailable strict gate; false ⇒ no/declined human approval. Never
443
+ // a silent unsandboxed run.
444
+ //
445
+ // `onUnsandboxed` (the human-approval callback) lives in the executor owner
446
+ // (index.js), never anywhere the model can reach, so the agent can never approve
447
+ // its own escape. `allowAnywhere`/`skipPermissions` default to the human-typed
448
+ // CLI flags — call-level options the model might influence cannot flip them.
449
+ function _argvHasFlag(flag) {
450
+ try { return Array.isArray(process.argv) && process.argv.includes(flag); }
451
+ catch { return false; }
452
+ }
453
+
454
+ async function resolveSandboxedSpawn({
455
+ command,
456
+ getConfig,
457
+ detection,
458
+ onUnsandboxed = null,
459
+ cwd = process.cwd(),
460
+ allowAnywhere,
461
+ skipPermissions,
462
+ noNetwork,
463
+ } = {}) {
464
+ const aa = allowAnywhere !== undefined ? allowAnywhere : _argvHasFlag('--allow-anywhere');
465
+ const sp = skipPermissions !== undefined ? skipPermissions : _argvHasFlag('--dangerously-skip-permissions');
466
+
467
+ // --dangerously-skip-permissions opts out of ALL safety, sandbox included — so
468
+ // there is no jail and the command keeps the host network (net 'on', honest).
469
+ if (sp) return { run: true, file: command, args: [], useShell: true, sandbox: 'off', network: 'on' };
470
+
471
+ const decision = decideSandbox({ getConfig, detection, noNetwork });
472
+ if (decision.status === 'on') {
473
+ const wrapped = wrapCommand(command, {
474
+ tool: decision.tool,
475
+ binPath: decision.binPath,
476
+ cwd,
477
+ allowAnywhere: aa,
478
+ network: decision.network,
479
+ });
480
+ if (wrapped) {
481
+ return { run: true, file: wrapped.file, args: wrapped.args, useShell: false, sandbox: 'on', network: decision.network };
482
+ }
483
+ // Could not build a wrapper — treat as unavailable rather than silently
484
+ // dropping the jail.
485
+ decision.status = 'unavailable';
486
+ decision.reason = decision.reason || 'could not build a sandbox wrapper for this command';
487
+ }
488
+
489
+ if (decision.status === 'unavailable') {
490
+ const why = decision.reason || 'unavailable';
491
+ if (decision.failIfUnavailable) {
492
+ return {
493
+ run: false, sandbox: 'unavailable', hard: true, reason: why, installHint: decision.installHint,
494
+ message: `OS sandbox unavailable (${why}) and sandbox.failIfUnavailable is set — refusing to run: ${command}.`,
495
+ };
496
+ }
497
+ let approved = false;
498
+ if (onUnsandboxed) {
499
+ try { approved = await onUnsandboxed({ command, reason: why, installHint: decision.installHint }); }
500
+ catch { approved = false; }
501
+ }
502
+ if (!approved) {
503
+ const hint = decision.installHint ? ` ${decision.installHint}` : '';
504
+ return {
505
+ run: false, sandbox: 'unavailable', hard: false, reason: why, installHint: decision.installHint,
506
+ message: `OS sandbox unavailable (${why}); refused to run unsandboxed without human approval: ${command}.${hint} To run without a sandbox, a human can set sandbox.mode "off" in config or pass --dangerously-skip-permissions.`,
507
+ };
508
+ }
509
+ // Human approved an unsandboxed run — proceed with a plain shell. No jail ⇒
510
+ // network isolation cannot be enforced, so the command has the host network
511
+ // (net 'on', reported honestly even if --no-network was requested).
512
+ return { run: true, file: command, args: [], useShell: true, sandbox: 'unavailable', network: 'on' };
513
+ }
514
+
515
+ // status 'off' — a deliberate human opt-out; run plain (host network).
516
+ return { run: true, file: command, args: [], useShell: true, sandbox: 'off', network: 'on' };
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------
520
+ // Status report (/sandbox and `semalt-code sandbox`)
521
+ // ---------------------------------------------------------------------------
522
+
523
+ function sandboxStatusReport({ getConfig, detection, noNetwork } = {}) {
524
+ let cfg = {};
525
+ try { cfg = (getConfig ? getConfig() : {}) || {}; } catch { cfg = {}; }
526
+ const s = normalizeSandbox(cfg.sandbox);
527
+ const det = detection || detectSandbox();
528
+ const network = resolveNetworkMode(s, noNetwork); // 'on' | 'off' (config + --no-network)
529
+ const lines = ['OS Sandbox (filesystem + binary network isolation for shell commands):'];
530
+ lines.push(` mode: ${s.mode}${s.mode === 'off' ? ' (sandbox disabled by config)' : ''}`);
531
+ lines.push(` failIfUnavailable: ${s.failIfUnavailable}`);
532
+ lines.push(` network: ${network}${network === 'off' ? ' (kernel-level no-network for sandboxed commands)' : ''}`);
533
+ lines.push(` platform: ${det.platform}`);
534
+ lines.push(` tool: ${det.tool || '(none)'}`);
535
+ lines.push(` supported: ${det.supported}`);
536
+ lines.push(` available: ${det.available}`);
537
+
538
+ let effective;
539
+ if (s.mode === 'off') effective = 'OFF (disabled by config)';
540
+ else if (det.available) effective = `ON (net:${network})`;
541
+ else effective = s.failIfUnavailable ? 'UNAVAILABLE → shell commands are HARD-BLOCKED (failIfUnavailable)' : 'UNAVAILABLE → shell commands require human approval to run unsandboxed';
542
+ lines.push(` effective: ${effective}`);
543
+
544
+ if (network === 'off' && (s.mode === 'off' || !det.available)) {
545
+ // Honest caveat: no-network is enforced BY the jail; with no active jail there
546
+ // is nothing to enforce it, so the command would have the host network.
547
+ lines.push(' note: no-network requires an active sandbox; with the sandbox off/unavailable the command is NOT network-isolated.');
548
+ }
549
+
550
+ if (!det.available && det.reason) lines.push(` reason: ${det.reason}`);
551
+ if (!det.available && det.installHint) lines.push(` install: ${det.installHint}`);
552
+ lines.push(' scope: writes confined to the working dir; ~/.semalt-ai, secrets & /etc read-only; network is BINARY (on or kernel-level none) — no host proxy, no domain allowlist, no TLS interception.');
553
+ return lines.join('\n');
554
+ }
555
+
556
+ module.exports = {
557
+ SANDBOX_MODES,
558
+ normalizeSandbox,
559
+ protectedPaths,
560
+ buildSeatbeltPolicy,
561
+ buildBwrapArgs,
562
+ detectSandbox,
563
+ _resetSandboxDetection,
564
+ wrapCommand,
565
+ decideSandbox,
566
+ resolveSandboxedSpawn,
567
+ sandboxStatusReport,
568
+ };