kronk-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,8 +153,102 @@ ln -s "$PWD/src/index.js" ~/.local/bin/kronk-cli
153
153
 
154
154
  ### Scope
155
155
 
156
- The agent roots itself at **the directory you launch it from**. That becomes its sandbox — file
157
- tools cannot read or write outside it, and `bash` cannot `cd` out of it. `cd` into a project first.
156
+ The agent roots itself at **the directory you launch it from**. `cd` into a project first.
157
+
158
+ Two separate things keep it there, and they are worth telling apart:
159
+
160
+ | | Enforced by | Covers |
161
+ |---|---|---|
162
+ | **Path containment** | `kronk-cli` | `read_file`, `write_file`, `list_dir`, `search` — resolved through symlinks, so a link inside the project cannot point out of it |
163
+ | **Shell confinement** | the kernel — `sandbox-exec` on macOS, [`bwrap`](https://github.com/containers/bubblewrap) on Linux | `bash`: writes outside the project are denied, and key material (`~/.ssh`, `~/.gnupg`, `~/.password-store`, the macOS keychain, `~/.netrc`, `~/.npmrc`) is unreadable |
164
+
165
+ The startup banner says which is in force:
166
+
167
+ ```
168
+ sandbox paths + seatbelt
169
+ ```
170
+
171
+ If no backend is available, it says so rather than implying one:
172
+
173
+ ```
174
+ sandbox paths only — bwrap not installed, shell commands are unconfined
175
+ ```
176
+
177
+ **What shell confinement does not do.** Reads stay open outside the deny-list, because denying them
178
+ wholesale breaks every compiler and runtime the agent needs. The network is not blocked — the agent
179
+ has to be able to run `npm install`. So it stops a command from *writing* outside your project or
180
+ reading your keys; it does not make a hostile command harmless.
181
+
182
+ The write half is categorical: both backends start from "nothing is writable" and hand back the
183
+ project, the temp directories and the build caches. The read half is a deny-list, and a deny-list is
184
+ only as good as its entries — which is exactly why it is kept narrow rather than broad. See
185
+ [Authenticated CLIs](#authenticated-clis) for the trade that produced it.
186
+
187
+ Two more limits worth stating:
188
+
189
+ - **Symlinks out of the project are refused, even benign ones.** An `npm link`ed package under
190
+ `node_modules` resolves outside the root, so `read_file` and `write_file` will decline it. Use the
191
+ real path, which is inside a project the agent was launched in.
192
+ - **Only filesystem operations are constrained.** The macOS profile allows everything else by
193
+ design, so a command that persuades an *already-running* unsandboxed process to act on its behalf
194
+ is not covered by it. Confinement limits what a command reaches directly; it is not a substitute
195
+ for reading the command before approving it.
196
+
197
+ `KRONK_SANDBOX=strict` refuses to run `bash` at all when no backend is available, which is the
198
+ setting to use if you need the guarantee rather than the best effort. `KRONK_SANDBOX=off` disables
199
+ confinement.
200
+
201
+ On Linux, install bubblewrap to get it: `apt install bubblewrap` / `dnf install bubblewrap`.
202
+
203
+ ### Authenticated CLIs
204
+
205
+ **Tools you are already logged in to keep working.** `kubectl`, `argocd`, `aws`, `docker` and the
206
+ like read their session tokens from `~/.kube`, `~/.config/argocd`, `~/.aws` and so on, and those
207
+ stay readable:
208
+
209
+ ```console
210
+ › run lint on all apps, then show me the current kube context
211
+ 1 ⚙ bash: npx nx run-many -t lint
212
+ ✓ 174 lines
213
+ 2 ⚙ bash: kubectl config current-context
214
+ ✓ prod
215
+ ```
216
+
217
+ An earlier version of this denied those directories too. It broke `kubectl` and `gh` outright while
218
+ still missing `argocd`, whose token lives in `~/.config/argocd` and which nobody had thought to add
219
+ — a deny-list that blocks the tools you use and misses the ones you forgot costs real work and buys
220
+ little, since an attacker just takes whichever store was not on the list. So the default covers
221
+ material that is pivot-grade and never legitimately read by a build.
222
+
223
+ **The one exception is the macOS keychain**, which is denied by default. `gh` stores its token
224
+ there, so it will report `Failed to log in` under the sandbox. If you want it:
225
+
226
+ ```bash
227
+ KRONK_SANDBOX_ALLOW=~/Library/Keychains kronk-cli
228
+ ```
229
+
230
+ **Logging in from inside the agent will not work**, by design — `argocd login`, `gh auth login` and
231
+ `kubectl config set-context` all write outside the project. Log in yourself, in your own shell,
232
+ before starting a session. If a tool genuinely must write to its config directory, allow just that:
233
+
234
+ ```bash
235
+ KRONK_SANDBOX_ALLOW=~/.config/argocd kronk-cli
236
+ ```
237
+
238
+ `KRONK_SANDBOX_ALLOW` makes a path fully available — writable, and readable even if it is denied by
239
+ default. `KRONK_SANDBOX_DENY` goes the other way and hides more, if you would rather the agent could
240
+ not read your cluster credentials at all:
241
+
242
+ ```bash
243
+ KRONK_SANDBOX_DENY=~/.kube,~/.aws kronk-cli # kubectl and aws will now fail
244
+ ```
245
+
246
+ Both take a comma- or colon-separated list, and `~` expands.
247
+
248
+ > ⚠️ `bwrap` needs unprivileged user namespaces, which several hardened distros and most CI
249
+ > runners disable — GitHub's included. Where they are off, `bwrap` is installed but cannot start,
250
+ > and the banner will say the shell is unconfined. That is why the backend is probed rather than
251
+ > assumed, and why the check is worth reading rather than trusting the presence of the binary.
158
252
 
159
253
  ---
160
254
 
@@ -292,6 +386,9 @@ The per-turn usage line still prints after each response; this one is the runnin
292
386
  | `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
293
387
  | `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
294
388
  | `KRONK_TOOL_TIMEOUT` | `900` | Seconds before a shell command is killed |
389
+ | `KRONK_SANDBOX` | `auto` | `auto` confines `bash` when the OS can, `strict` refuses to run it when it cannot, `off` disables it |
390
+ | `KRONK_SANDBOX_ALLOW` | — | Paths to make fully available inside the sandbox, comma or colon separated |
391
+ | `KRONK_SANDBOX_DENY` | — | Extra paths to hide from `bash`, comma or colon separated |
295
392
  | `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
296
393
  | `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
297
394
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
@@ -489,7 +586,8 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
489
586
  | `bash` | ✋ | Run a command; shows it first |
490
587
 
491
588
  `--yes` and `--auto` skip the prompts. Paths resolve against the session directory and cannot
492
- escape the launch root. `bash` keeps its working directory **between calls**, so a bare `cd`
589
+ escape the launch root including through a symlink. `bash` additionally runs under an OS
590
+ sandbox where one is available; see [Scope](#scope) for exactly what that covers. `bash` keeps its working directory **between calls**, so a bare `cd`
493
591
  sticks the way it would in a real shell.
494
592
 
495
593
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { c, banner, fmtContext, statusLine } from './ui.js';
9
9
  import { projectContext } from './context.js';
10
10
  import { compact, report } from './compact.js';
11
11
  import { loadServers, McpHub, reportFailures } from './mcp.js';
12
+ import { resolveSandbox, sandbox } from './tools.js';
12
13
 
13
14
  // ---- argv -------------------------------------------------------------
14
15
  const argv = process.argv.slice(2);
@@ -307,6 +308,14 @@ async function oneShot(prompt) {
307
308
  ];
308
309
  const ac = new AbortController();
309
310
  process.on('SIGINT', () => ac.abort());
311
+
312
+ // One shot prints no banner, so the mode that auto-approves every command was
313
+ // also the one that said nothing about what was confining them. On stderr, so
314
+ // piping the answer somewhere still gets just the answer.
315
+ if (resolveSandbox() === 'none' && AUTO_YES) {
316
+ console.error(c.yellow(` warning: shell commands run unconfined — ${sandbox.reason}`));
317
+ }
318
+
310
319
  const approve = async (name) => {
311
320
  if (AUTO_YES) return true;
312
321
  console.log(c.yellow(` ✗ ${name} needs approval; re-run with --yes to allow it`));
@@ -347,9 +356,17 @@ async function main() {
347
356
  if (ctx.isGit) bits.push('git');
348
357
  if (ctx.agentFile) bits.push(c.green(ctx.agentFile));
349
358
  if (config.contextWindow) bits.push(c.grey(`${(config.contextWindow / 1000).toFixed(0)}k ctx`));
350
- console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}\n`);
359
+ console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}`);
351
360
  }
352
361
 
362
+ // Say which of the two confinements is actually in force. Printing nothing
363
+ // would let the README's word "sandbox" stand in for a guarantee the kernel
364
+ // is not making on this machine.
365
+ const backend = resolveSandbox();
366
+ console.log(`${c.grey(' sandbox')} ${backend === 'none'
367
+ ? c.yellow(`paths only — ${sandbox.reason}, shell commands are unconfined`)
368
+ : c.grey(`paths + ${backend}`)}\n`);
369
+
353
370
  const messages = [{ role: 'system', content }];
354
371
 
355
372
  // Ctrl-C aborts the in-flight request instead of killing the process.
package/src/sandbox.js ADDED
@@ -0,0 +1,168 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join, delimiter } from 'node:path';
3
+
4
+ /**
5
+ * OS-level confinement for `bash`.
6
+ *
7
+ * The file tools resolve paths and refuse to leave the launch root, but `bash`
8
+ * had no such guard: `cat ~/.ssh/id_rsa` ran fine, and the README called the
9
+ * launch root a sandbox anyway. A path check in JavaScript cannot constrain a
10
+ * process it has already handed the whole machine to, so the confinement has to
11
+ * come from the kernel.
12
+ *
13
+ * This module is pure — it builds an argv and nothing else. Spawning stays in
14
+ * the tool layer, which is the only place allowed to start processes.
15
+ */
16
+
17
+ /** Directories a build legitimately writes to outside the project. */
18
+ const CACHE_DIRS = ['.npm', '.cache', '.yarn', '.pnpm-store', 'Library/Caches'];
19
+
20
+ /**
21
+ * Absolute cache paths. Exported because with the filesystem read-only these
22
+ * have to exist *before* the sandbox starts — bwrap cannot create a mountpoint
23
+ * under a read-only parent, so a machine with no ~/.npm yet could not run
24
+ * `npm install` at all. The tool layer creates them; this module stays pure.
25
+ */
26
+ export const cacheDirs = (home) => CACHE_DIRS.map((d) => join(home, d));
27
+
28
+ /**
29
+ * Key material the agent has no reason to read, and that no build step needs.
30
+ *
31
+ * This list was once wider — it covered `~/.kube`, `~/.aws`, `~/.config/gh` and
32
+ * friends. That broke `kubectl`, `gh` and anything else the user had already
33
+ * logged in to, while still missing tools nobody thought of (`argocd` keeps its
34
+ * token in `~/.config/argocd`, and sailed straight through). A deny-list that
35
+ * blocks the tools you use and misses the ones you forgot is worse than an
36
+ * honest boundary: it costs real work and buys little, because an attacker
37
+ * exfiltrates whichever credential store was not on it.
38
+ *
39
+ * So the default is narrow and covers material that is pivot-grade and never
40
+ * legitimately read by a build. Session tokens for CLIs you are already logged
41
+ * in to stay readable — add them with KRONK_SANDBOX_DENY if your threat model
42
+ * wants them gone, at the cost of those commands failing.
43
+ *
44
+ * The write confinement is the half that holds categorically. This half is
45
+ * best-effort, and the README says so.
46
+ */
47
+ const SECRET_DIRS = ['.ssh', '.gnupg', '.password-store', 'Library/Keychains'];
48
+
49
+ const SECRET_FILES = ['.npmrc', '.netrc', '.pypirc', '.git-credentials'];
50
+
51
+ /** `A:B` or `A,B`, absolute or `~`-relative. Empty entries are dropped. */
52
+ export function extraPaths(value, home) {
53
+ return (value ?? '')
54
+ .split(/[:,]/)
55
+ .map((p) => p.trim())
56
+ .filter(Boolean)
57
+ .map((p) => (p.startsWith('~/') ? join(home, p.slice(2)) : p));
58
+ }
59
+
60
+ /** Seatbelt string literals are double-quoted; only `\` and `"` need escaping. */
61
+ const sb = (p) => `"${p.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
62
+
63
+ /**
64
+ * Allow everything, then take away writes outside the project and reads of
65
+ * credential stores. Starting from `(deny default)` would mean enumerating every
66
+ * dylib, locale file and device node a toolchain touches, and getting that wrong
67
+ * fails closed in ways that look like a broken CLI rather than a blocked write.
68
+ */
69
+ export function seatbeltProfile({ root, home, tmp, allow = [], deny = [] }) {
70
+ const writable = [root, '/dev', '/private/tmp', '/private/var/tmp', '/private/var/folders', '/tmp']
71
+ .concat(tmp ? [tmp] : [])
72
+ .concat(cacheDirs(home))
73
+ .concat(allow);
74
+
75
+ // ALLOW means "this path is fully available", so it also lifts a default
76
+ // denial. Without that there is no way to run a keychain-backed CLI like `gh`
77
+ // short of turning the sandbox off entirely, which is a worse trade.
78
+ const unreadable = SECRET_DIRS.map((d) => join(home, d)).concat(deny)
79
+ .filter((d) => !allow.some((a) => d === a || d.startsWith(`${a}/`)));
80
+
81
+ return [
82
+ '(version 1)',
83
+ '(allow default)',
84
+ '(deny file-write*)',
85
+ `(allow file-write* ${writable.map((p) => `(subpath ${sb(p)})`).join(' ')})`,
86
+ `(deny file-read* ${unreadable.map((d) => `(subpath ${sb(d)})`).join(' ')} `
87
+ + `${SECRET_FILES.map((f) => `(literal ${sb(join(home, f))})`).join(' ')})`,
88
+ ].join('\n');
89
+ }
90
+
91
+ /**
92
+ * bubblewrap equivalent: the filesystem read-only, then hand back what has to
93
+ * be writable.
94
+ *
95
+ * This started as `--dev-bind / /` (everything read-write) with `$HOME` made
96
+ * read-only afterwards, which protected `$HOME` and nothing else — `/etc`,
97
+ * `/opt`, `/usr/local` and `/var/tmp` all stayed writable. That is the escape
98
+ * this module exists to prevent, merely relocated. Deny-by-default is the only
99
+ * shape that matches what the README promises, and it is what seatbelt does on
100
+ * the other side.
101
+ */
102
+ export function bwrapArgs({ root, home, cwd, tmp, allow = [], deny = [] }) {
103
+ // /dev and /proc must be fresh mounts: a read-only bind of the host's would
104
+ // leave a shell unable to write to its own stdout or read /proc/self.
105
+ const args = ['--die-with-parent', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc'];
106
+
107
+ // The project is bound unconditionally: it is the working directory, it has to
108
+ // exist, and guarding it on existsSync meant a root that could not be stat-ed
109
+ // silently produced a read-only project instead of an error.
110
+ args.push('--bind', root, root);
111
+
112
+ const optional = ['/tmp', '/var/tmp']
113
+ .concat(tmp ? [tmp] : [])
114
+ .concat(cacheDirs(home))
115
+ .concat(allow);
116
+
117
+ for (const p of optional) {
118
+ if (existsSync(p)) args.push('--bind', p, p);
119
+ }
120
+
121
+ // A tmpfs makes a credential directory exist but be empty; /dev/null over a
122
+ // file makes it readable and empty. Both beat a missing path, which tools
123
+ // report as a confusing ENOENT rather than an obvious denial.
124
+ const hidden = SECRET_DIRS.map((x) => join(home, x)).concat(deny)
125
+ .filter((d) => !allow.some((a) => d === a || d.startsWith(`${a}/`)));
126
+ for (const d of hidden) {
127
+ if (existsSync(d) && statSync(d).isDirectory()) args.push('--tmpfs', d);
128
+ }
129
+ for (const f of SECRET_FILES.map((x) => join(home, x))) {
130
+ if (existsSync(f)) args.push('--ro-bind', '/dev/null', f);
131
+ }
132
+
133
+ return args.concat('--chdir', cwd, 'bash');
134
+ }
135
+
136
+ /** First match in PATH, without shelling out to `which`. */
137
+ export function onPath(bin, { env = process.env } = {}) {
138
+ return (env.PATH ?? '').split(delimiter).some((d) => d && existsSync(join(d, bin)));
139
+ }
140
+
141
+ /**
142
+ * Which backend this machine can offer, before we know whether it works.
143
+ * `KRONK_SANDBOX=off` skips confinement; `strict` refuses to run unconfined.
144
+ */
145
+ export function detectBackend({ platform = process.platform, env = process.env } = {}) {
146
+ if ((env.KRONK_SANDBOX ?? 'auto') === 'off') return 'none';
147
+ if (platform === 'darwin' && onPath('sandbox-exec', { env })) return 'seatbelt';
148
+ if (platform === 'linux' && onPath('bwrap', { env })) return 'bwrap';
149
+ return 'none';
150
+ }
151
+
152
+ /**
153
+ * Build the argv that runs `script` under `backend`.
154
+ * Returns `['bash', ['-c', script]]` shaped output for spawn().
155
+ */
156
+ export function sandboxArgv(script, { backend, root, home, cwd, tmp, env = process.env }) {
157
+ const allow = extraPaths(env.KRONK_SANDBOX_ALLOW, home);
158
+ const deny = extraPaths(env.KRONK_SANDBOX_DENY, home);
159
+
160
+ if (backend === 'seatbelt') {
161
+ return ['sandbox-exec',
162
+ ['-p', seatbeltProfile({ root, home, tmp, allow, deny }), 'bash', '-c', script]];
163
+ }
164
+ if (backend === 'bwrap') {
165
+ return ['bwrap', [...bwrapArgs({ root, home, cwd, tmp, allow, deny }), '-c', script]];
166
+ }
167
+ return ['bash', ['-c', script]];
168
+ }
package/src/tools.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
2
- import { execFile, spawn } from 'node:child_process';
2
+ import { execFile, spawn, spawnSync } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
- import { resolve, relative } from 'node:path';
5
- import { realpathSync } from 'node:fs';
4
+ import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
5
+ import { realpathSync, mkdirSync } from 'node:fs';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { detectBackend, sandboxArgv, cacheDirs } from './sandbox.js';
6
8
  import { c } from './ui.js';
7
9
 
8
10
  const exec = promisify(execFile);
@@ -31,6 +33,30 @@ function real(p) {
31
33
  try { return realpathSync(p); } catch { return p; }
32
34
  }
33
35
 
36
+ /**
37
+ * Resolve symlinks all the way down, including for a path that does not exist
38
+ * yet.
39
+ *
40
+ * Comparing the textual path against the root let a symlink inside the project
41
+ * point anywhere: `ln -s /etc/passwd notes` and then `read_file notes` passed
42
+ * the containment check and read the target. Writes were worse — a symlinked
43
+ * directory meant `write_file` landed outside the root entirely. So we resolve
44
+ * the deepest ancestor that exists and re-attach the rest, which is the path the
45
+ * filesystem will actually use.
46
+ */
47
+ function realDeep(abs) {
48
+ const tail = [];
49
+ let cur = abs;
50
+ for (;;) {
51
+ try { return tail.length ? resolve(realpathSync(cur), ...tail) : realpathSync(cur); }
52
+ catch { /* does not exist yet — walk up */ }
53
+ const parent = dirname(cur);
54
+ if (parent === cur) return abs;
55
+ tail.unshift(basename(cur));
56
+ cur = parent;
57
+ }
58
+ }
59
+
34
60
  /**
35
61
  * Trim from the MIDDLE, never the end.
36
62
  *
@@ -47,9 +73,11 @@ export const clip = (s) => {
47
73
 
48
74
  /** Resolve against the session cwd, and keep the agent inside the launch root. */
49
75
  export function safe(p) {
50
- const abs = resolve(real(session.cwd), p);
76
+ const abs = realDeep(resolve(real(session.cwd), p));
51
77
  const rel = relative(real(session.root), abs);
52
- if (rel.startsWith('..')) throw new Error(`refusing to touch path outside ${session.root}: ${p}`);
78
+ if (rel.startsWith('..') || isAbsolute(rel)) {
79
+ throw new Error(`refusing to touch path outside ${session.root}: ${p}`);
80
+ }
53
81
  return abs;
54
82
  }
55
83
 
@@ -123,6 +151,55 @@ function applyCwd(out, mark) {
123
151
 
124
152
  const MARK = '__KRONK_CWD__';
125
153
 
154
+ /**
155
+ * What is actually confining `bash`, resolved once and reported in the banner.
156
+ * `pending` until the first command runs, because the preflight costs a process.
157
+ */
158
+ export const sandbox = { backend: 'pending', reason: null };
159
+
160
+ /**
161
+ * Ask the kernel, do not assume.
162
+ *
163
+ * `sandbox-exec` exists on every Mac and `bwrap` may be installed but unusable
164
+ * — unprivileged user namespaces are off on some distros, and a container often
165
+ * has neither. A backend that fails to launch would turn every command into a
166
+ * confusing startup error, so it is tried against `true` once and dropped if it
167
+ * does not work.
168
+ */
169
+ export function resolveSandbox({ platform = process.platform, env = process.env } = {}) {
170
+ if (sandbox.backend !== 'pending') return sandbox.backend;
171
+
172
+ const mode = env.KRONK_SANDBOX ?? 'auto';
173
+ const wanted = detectBackend({ platform, env });
174
+
175
+ if (wanted === 'none') {
176
+ sandbox.backend = 'none';
177
+ sandbox.reason = mode === 'off'
178
+ ? 'disabled by KRONK_SANDBOX=off'
179
+ : platform === 'linux' ? 'bwrap not installed' : 'no sandbox backend on this platform';
180
+ return sandbox.backend;
181
+ }
182
+
183
+ // With the filesystem read-only inside the sandbox these cannot be created
184
+ // from within it, and a missing ~/.npm would break `npm install` outright.
185
+ for (const d of cacheDirs(homedir())) {
186
+ try { mkdirSync(d, { recursive: true }); } catch { /* not fatal — it just stays unwritable */ }
187
+ }
188
+
189
+ const [bin, argv] = sandboxArgv('exit 0', {
190
+ backend: wanted, root: session.root, home: homedir(), cwd: session.cwd, tmp: real(tmpdir()),
191
+ });
192
+ const probe = spawnSync(bin, argv, { stdio: 'ignore', timeout: 10_000 });
193
+
194
+ if (probe.error || probe.status !== 0) {
195
+ sandbox.backend = 'none';
196
+ sandbox.reason = `${wanted} failed to start`;
197
+ } else {
198
+ sandbox.backend = wanted;
199
+ }
200
+ return sandbox.backend;
201
+ }
202
+
126
203
  /**
127
204
  * Run a shell command, streaming progress to `onProgress` as it goes.
128
205
  *
@@ -141,7 +218,16 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
141
218
  // Appending `printf` naively made every command look successful, so
142
219
  // failures never reached the agent at all.
143
220
  const script = `${cmd}\n__kronk_st=$?\nprintf '\\n${MARK}%s' "$(pwd)"\nexit $__kronk_st`;
144
- const child = spawn('bash', ['-c', script], {
221
+
222
+ const backend = resolveSandbox();
223
+ if (backend === 'none' && (process.env.KRONK_SANDBOX ?? 'auto') === 'strict') {
224
+ return resolve(`error: refusing to run unconfined — KRONK_SANDBOX=strict and ${sandbox.reason}.`);
225
+ }
226
+
227
+ const [bin, argv] = sandboxArgv(script, {
228
+ backend, root: session.root, home: homedir(), cwd: session.cwd, tmp: real(tmpdir()),
229
+ });
230
+ const child = spawn(bin, argv, {
145
231
  cwd: session.cwd,
146
232
  env: { ...process.env, TERM: 'dumb', CI: process.env.CI ?? '1' },
147
233
  detached: true,
@@ -246,7 +332,9 @@ export async function runTool(name, args, opts = {}) {
246
332
  }
247
333
 
248
334
  case 'search': {
249
- const where = args.path ?? '.';
335
+ // Went straight to ripgrep unchecked, so `search` with an absolute path
336
+ // read anything on the machine while read_file was busy refusing to.
337
+ const where = safe(args.path ?? '.');
250
338
  try {
251
339
  const { stdout } = await exec('rg', ['-n', '--no-heading', '-m', '200', args.pattern, where]);
252
340
  return clip(stdout) || '(no matches)';