kronk-cli 0.1.1 → 0.1.3

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
@@ -50,6 +50,16 @@ workflow run that produced it:
50
50
  gh attestation verify kronk-cli-*.tgz --repo BardiaN/kronk-cli
51
51
  ```
52
52
 
53
+ The same attestation is attached to every release as a file, so it can be checked without
54
+ GitHub's attestations API in the loop — `<tarball>.sigstore.json` for `gh attestation verify`
55
+ and cosign, `<tarball>.intoto.jsonl` for SLSA tooling:
56
+
57
+ ```bash
58
+ gh release download v0.1.3 --repo BardiaN/kronk-cli
59
+ gh attestation verify kronk-cli-0.1.3.tgz --repo BardiaN/kronk-cli \
60
+ --bundle kronk-cli-0.1.3.tgz.sigstore.json
61
+ ```
62
+
53
63
  npm packages carry the same provenance, shown as a **Provenance** panel on the
54
64
  [package page](https://www.npmjs.com/package/kronk-cli), and verifiable locally:
55
65
 
@@ -153,8 +163,102 @@ ln -s "$PWD/src/index.js" ~/.local/bin/kronk-cli
153
163
 
154
164
  ### Scope
155
165
 
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.
166
+ The agent roots itself at **the directory you launch it from**. `cd` into a project first.
167
+
168
+ Two separate things keep it there, and they are worth telling apart:
169
+
170
+ | | Enforced by | Covers |
171
+ |---|---|---|
172
+ | **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 |
173
+ | **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 |
174
+
175
+ The startup banner says which is in force:
176
+
177
+ ```
178
+ sandbox paths + seatbelt
179
+ ```
180
+
181
+ If no backend is available, it says so rather than implying one:
182
+
183
+ ```
184
+ sandbox paths only — bwrap not installed, shell commands are unconfined
185
+ ```
186
+
187
+ **What shell confinement does not do.** Reads stay open outside the deny-list, because denying them
188
+ wholesale breaks every compiler and runtime the agent needs. The network is not blocked — the agent
189
+ has to be able to run `npm install`. So it stops a command from *writing* outside your project or
190
+ reading your keys; it does not make a hostile command harmless.
191
+
192
+ The write half is categorical: both backends start from "nothing is writable" and hand back the
193
+ project, the temp directories and the build caches. The read half is a deny-list, and a deny-list is
194
+ only as good as its entries — which is exactly why it is kept narrow rather than broad. See
195
+ [Authenticated CLIs](#authenticated-clis) for the trade that produced it.
196
+
197
+ Two more limits worth stating:
198
+
199
+ - **Symlinks out of the project are refused, even benign ones.** An `npm link`ed package under
200
+ `node_modules` resolves outside the root, so `read_file` and `write_file` will decline it. Use the
201
+ real path, which is inside a project the agent was launched in.
202
+ - **Only filesystem operations are constrained.** The macOS profile allows everything else by
203
+ design, so a command that persuades an *already-running* unsandboxed process to act on its behalf
204
+ is not covered by it. Confinement limits what a command reaches directly; it is not a substitute
205
+ for reading the command before approving it.
206
+
207
+ `KRONK_SANDBOX=strict` refuses to run `bash` at all when no backend is available, which is the
208
+ setting to use if you need the guarantee rather than the best effort. `KRONK_SANDBOX=off` disables
209
+ confinement.
210
+
211
+ On Linux, install bubblewrap to get it: `apt install bubblewrap` / `dnf install bubblewrap`.
212
+
213
+ ### Authenticated CLIs
214
+
215
+ **Tools you are already logged in to keep working.** `kubectl`, `argocd`, `aws`, `docker` and the
216
+ like read their session tokens from `~/.kube`, `~/.config/argocd`, `~/.aws` and so on, and those
217
+ stay readable:
218
+
219
+ ```console
220
+ › run lint on all apps, then show me the current kube context
221
+ 1 ⚙ bash: npx nx run-many -t lint
222
+ ✓ 174 lines
223
+ 2 ⚙ bash: kubectl config current-context
224
+ ✓ prod
225
+ ```
226
+
227
+ An earlier version of this denied those directories too. It broke `kubectl` and `gh` outright while
228
+ still missing `argocd`, whose token lives in `~/.config/argocd` and which nobody had thought to add
229
+ — a deny-list that blocks the tools you use and misses the ones you forgot costs real work and buys
230
+ little, since an attacker just takes whichever store was not on the list. So the default covers
231
+ material that is pivot-grade and never legitimately read by a build.
232
+
233
+ **The one exception is the macOS keychain**, which is denied by default. `gh` stores its token
234
+ there, so it will report `Failed to log in` under the sandbox. If you want it:
235
+
236
+ ```bash
237
+ KRONK_SANDBOX_ALLOW=~/Library/Keychains kronk-cli
238
+ ```
239
+
240
+ **Logging in from inside the agent will not work**, by design — `argocd login`, `gh auth login` and
241
+ `kubectl config set-context` all write outside the project. Log in yourself, in your own shell,
242
+ before starting a session. If a tool genuinely must write to its config directory, allow just that:
243
+
244
+ ```bash
245
+ KRONK_SANDBOX_ALLOW=~/.config/argocd kronk-cli
246
+ ```
247
+
248
+ `KRONK_SANDBOX_ALLOW` makes a path fully available — writable, and readable even if it is denied by
249
+ default. `KRONK_SANDBOX_DENY` goes the other way and hides more, if you would rather the agent could
250
+ not read your cluster credentials at all:
251
+
252
+ ```bash
253
+ KRONK_SANDBOX_DENY=~/.kube,~/.aws kronk-cli # kubectl and aws will now fail
254
+ ```
255
+
256
+ Both take a comma- or colon-separated list, and `~` expands.
257
+
258
+ > ⚠️ `bwrap` needs unprivileged user namespaces, which several hardened distros and most CI
259
+ > runners disable — GitHub's included. Where they are off, `bwrap` is installed but cannot start,
260
+ > and the banner will say the shell is unconfined. That is why the backend is probed rather than
261
+ > assumed, and why the check is worth reading rather than trusting the presence of the binary.
158
262
 
159
263
  ---
160
264
 
@@ -221,6 +325,45 @@ kronk-cli --auto "make the tests pass" # unattended, runs the whole task
221
325
 
222
326
  ---
223
327
 
328
+ ## Startup: the model is loaded before you type
329
+
330
+ Kronk has no load command. It lists a model in `GET /v1/models` as soon as the
331
+ server starts, but the weights only reach VRAM on the first inference request —
332
+ so on a fresh server the first prompt you type pays a 10–30 s cold load, and
333
+ looks like a hang.
334
+
335
+ `kronk-cli` pays it at boot instead. It asks Kronk what is resident, and if the
336
+ selected model is not, sends the cheapest completion there is — one token, no
337
+ reasoning — to trigger admission:
338
+
339
+ ```console
340
+ $ kronk-cli
341
+ loaded unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 11.4s
342
+
343
+ ██ kronk-cli · local agent, no network
344
+ ```
345
+
346
+ A model already in the pool is left alone; nothing is sent. If the selected one
347
+ cannot be admitted — it will not fit next to what is already resident — the
348
+ fallback runs the same order the CLI uses when you name nothing: the configured
349
+ default, then the best id Kronk is serving. Each is tried once, and a failure
350
+ says why:
351
+
352
+ ```console
353
+ $ kronk-cli -m Qwen3.6-27B
354
+ unsloth/Qwen3.6-27B-Q4_K_M failed to load — 507 /chat/completions — insufficient VRAM
355
+ loaded unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 2.0s · fallback
356
+ ```
357
+
358
+ If nothing loads, the original pick stands and the first turn reports the real
359
+ error. A warm-up is a convenience, not a gate — it never decides whether the CLI
360
+ starts.
361
+
362
+ Skip it with `--no-warm` or `KRONK_WARM=false` and the first prompt pays the load,
363
+ as before.
364
+
365
+ ---
366
+
224
367
  ## Command-line options
225
368
 
226
369
  | Flag | Default | |
@@ -229,6 +372,7 @@ kronk-cli --auto "make the tests pass" # unattended, runs the whole task
229
372
  | `-l`, `--models`, `--list` | — | List the models Kronk is serving, then exit |
230
373
  | `--no-context` | off | Skip the startup scan of the working directory |
231
374
  | `--no-compact` | off | Never auto-compact; fail when the window fills instead |
375
+ | `--no-warm` | off | Don't preload the model at startup; let the first prompt trigger the load |
232
376
  | `--mcp [names]` | off | Attach MCP servers — bare for all, or a comma list |
233
377
  | `--mcp-list` | — | Show configured MCP servers and their tools, then exit |
234
378
  | `-a`, `--auto` | off | Autonomous: auto-approve tools **and** run until the task is done. Implies `--yes` |
@@ -292,8 +436,12 @@ The per-turn usage line still prints after each response; this one is the runnin
292
436
  | `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
293
437
  | `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
294
438
  | `KRONK_TOOL_TIMEOUT` | `900` | Seconds before a shell command is killed |
439
+ | `KRONK_SANDBOX` | `auto` | `auto` confines `bash` when the OS can, `strict` refuses to run it when it cannot, `off` disables it |
440
+ | `KRONK_SANDBOX_ALLOW` | — | Paths to make fully available inside the sandbox, comma or colon separated |
441
+ | `KRONK_SANDBOX_DENY` | — | Extra paths to hide from `bash`, comma or colon separated |
295
442
  | `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
296
443
  | `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
444
+ | `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
297
445
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
298
446
  | `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
299
447
  | `NO_COLOR` | — | Any value disables colour |
@@ -489,7 +637,8 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
489
637
  | `bash` | ✋ | Run a command; shows it first |
490
638
 
491
639
  `--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`
640
+ escape the launch root including through a symlink. `bash` additionally runs under an OS
641
+ 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
642
  sticks the way it would in a real shell.
494
643
 
495
644
  ---
@@ -749,7 +898,7 @@ previous prompt prefix — watch `cached` climb in the usage line.
749
898
  |---|---|
750
899
  | `Cannot reach Kronk` | `kronk server start --detach` |
751
900
  | `Kronk is running but has no models` | `kronk model pull <id>` |
752
- | First response takes ~25 s | Cold model load. Keep it warm with `--pool-ttl 1h` on the server |
901
+ | First response takes ~25 s | Cold model load, and you started with `--no-warm`. Drop the flag, or keep the model warm with `--pool-ttl 1h` on the server |
753
902
  | Long silence before text | The model is reasoning. `--no-think`, or `/thinking` to watch it |
754
903
  | `(model produced no answer)` | Reasoning consumed the whole budget. Raise `KRONK_MAX_TOKENS` or use `--no-think` |
755
904
  | `kronk-cli: command not found` after an nvm switch | Re-run `npm link`, or see [Using nvm?](#using-nvm) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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/boot.js ADDED
@@ -0,0 +1,59 @@
1
+ import { config, DEFAULT_MODEL } from './config.js';
2
+ import { listLoaded, warm } from './client.js';
3
+ import { c, spinner } from './ui.js';
4
+
5
+ /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
6
+ export function pickDefault(ids) {
7
+ const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
8
+ const agent = chat.filter((id) => id.endsWith('/AGENT'));
9
+ const pool = agent.length ? agent : chat;
10
+ return pool.sort((a, b) => b.length - a.length)[0] ?? null;
11
+ }
12
+
13
+ /**
14
+ * Get the chosen model resident before the first prompt.
15
+ *
16
+ * A freshly started Kronk serves model *ids* but holds nothing in VRAM — it
17
+ * admits a model on its first inference request, and there is no endpoint that
18
+ * does it sooner. Left alone, that 10–25 s cold load lands on the first thing
19
+ * you type, looking like a hang. Do it here, where a spinner explains the wait
20
+ * and where a model that will not fit can still fall back to one that will.
21
+ *
22
+ * Never fatal: if nothing warms, the original pick stands and the first turn
23
+ * reports the real error. A warm-up is a convenience, not a gate.
24
+ *
25
+ * Returns the id left in `config.model`.
26
+ */
27
+ export async function ensureLoaded(ids, log = console.error) {
28
+ const loaded = await listLoaded();
29
+ const resident = new Set((Array.isArray(loaded) ? loaded : []).map((l) => l.id));
30
+ if (resident.has(config.model)) return config.model;
31
+
32
+ const chosen = config.model;
33
+ // chosen → configured default → best guess; each distinct id tried once.
34
+ const chain = [...new Set([
35
+ chosen,
36
+ ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : null,
37
+ pickDefault(ids),
38
+ ])].filter(Boolean);
39
+
40
+ for (const id of chain) {
41
+ if (resident.has(id)) { config.model = id; return id; }
42
+ const t0 = Date.now();
43
+ const spin = spinner(`loading ${id.split('/').pop()} — first run takes 10-30s`);
44
+ try {
45
+ await warm(id);
46
+ spin.stop();
47
+ const how = `${((Date.now() - t0) / 1000).toFixed(1)}s${id === chosen ? '' : ' · fallback'}`;
48
+ log(c.grey(` loaded ${id} · ${how}`));
49
+ config.model = id;
50
+ return id;
51
+ } catch (e) {
52
+ spin.stop();
53
+ log(c.yellow(` ${id} failed to load — ${e.message.split('\n')[0].slice(0, 160)}`));
54
+ }
55
+ }
56
+ // Nothing would load. Keep the original pick and let the first turn say why.
57
+ config.model = chosen;
58
+ return chosen;
59
+ }
package/src/client.js CHANGED
@@ -113,3 +113,25 @@ export async function* streamChat({ model, messages, tools, signal, maxTokens, n
113
113
 
114
114
  yield { type: 'done', calls: [...calls.values()], finish };
115
115
  }
116
+
117
+ /**
118
+ * Load a model into the pool.
119
+ *
120
+ * Kronk has no explicit "load" endpoint — admission happens on the first
121
+ * inference request, so the cheapest possible completion *is* the load
122
+ * command. A 23 GB MoE takes ~10–25 s off disk. The reply is discarded;
123
+ * only whether it succeeded matters.
124
+ */
125
+ export async function warm(id, signal) {
126
+ const res = await req('/chat/completions', {
127
+ method: 'POST',
128
+ signal,
129
+ body: JSON.stringify({
130
+ model: id,
131
+ messages: [{ role: 'user', content: 'hi' }],
132
+ max_completion_tokens: 1,
133
+ enable_thinking: false,
134
+ }),
135
+ });
136
+ await res.text();
137
+ }
package/src/config.js CHANGED
@@ -23,6 +23,9 @@ export const config = {
23
23
  maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
24
24
  showThinking: (process.env.KRONK_THINKING ?? String(file.showThinking ?? 'true')) !== 'false',
25
25
  noThink: (process.env.KRONK_NO_THINK ?? String(file.noThink ?? '')) === '1',
26
+ // Kronk admits a model on its first inference request, not at server start.
27
+ // Pay that cold load at boot rather than on the first typed prompt.
28
+ warm: (process.env.KRONK_WARM ?? String(file.warm ?? 'true')) !== 'false',
26
29
  autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
27
30
  compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
28
31
  // Large tool output is summarized in a throwaway context so the raw text
package/src/index.js CHANGED
@@ -4,11 +4,13 @@ import { stdin, stdout } from 'node:process';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
6
6
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
7
+ import { pickDefault, ensureLoaded } from './boot.js';
7
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
8
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
9
10
  import { projectContext } from './context.js';
10
11
  import { compact, report } from './compact.js';
11
12
  import { loadServers, McpHub, reportFailures } from './mcp.js';
13
+ import { resolveSandbox, sandbox } from './tools.js';
12
14
 
13
15
  // ---- argv -------------------------------------------------------------
14
16
  const argv = process.argv.slice(2);
@@ -28,6 +30,7 @@ const SHOW_MODELS = flag('--models', '-l', '--list');
28
30
  const SHOW_MCP = flag('--mcp-list');
29
31
  const NO_CONTEXT = flag('--no-context');
30
32
  if (flag('--no-compact')) config.autoCompact = false;
33
+ if (flag('--no-warm')) config.warm = false;
31
34
 
32
35
  if (flag('-h', '--help')) {
33
36
  console.log(`
@@ -42,6 +45,7 @@ if (flag('-h', '--help')) {
42
45
  -l, --models list the models Kronk is serving, then exit
43
46
  --no-context skip the startup scan of the working directory
44
47
  --no-compact never auto-compact; fail instead when the window fills
48
+ --no-warm don't preload the model; let the first prompt trigger it
45
49
  --mcp [names] attach MCP servers; bare for all, or a comma list
46
50
  --mcp-list show configured MCP servers and their tools, then exit
47
51
  -m, --model <id> model to use; substring is enough, /AGENT profiles win
@@ -59,6 +63,7 @@ if (flag('-h', '--help')) {
59
63
  KRONK_MAX_TOKENS output cap per response (default 8192)
60
64
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
61
65
  KRONK_NO_THINK set to 1 to disable reasoning
66
+ KRONK_WARM false to skip the boot-time model preload
62
67
  KRONK_AUTO_COMPACT false to disable automatic compaction
63
68
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
64
69
 
@@ -95,14 +100,6 @@ let MCP_WANTED = null;
95
100
  const stepsArg = opt('--steps');
96
101
  if (stepsArg) config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(stepsArg) ? Infinity : Number(stepsArg);
97
102
 
98
- /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
99
- function pickDefault(ids) {
100
- const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
101
- const agent = chat.filter((id) => id.endsWith('/AGENT'));
102
- const pool = agent.length ? agent : chat;
103
- return pool.sort((a, b) => b.length - a.length)[0] ?? null;
104
- }
105
-
106
103
  async function boot() {
107
104
  let ids;
108
105
  try {
@@ -138,6 +135,8 @@ async function boot() {
138
135
  }
139
136
  if (!config.model) config.model = pickDefault(ids);
140
137
 
138
+ if (config.warm) await ensureLoaded(ids);
139
+
141
140
  const { configured, native } = await modelLimits(config.model);
142
141
  config.contextWindow = configured;
143
142
  config.nativeContext = native;
@@ -307,6 +306,14 @@ async function oneShot(prompt) {
307
306
  ];
308
307
  const ac = new AbortController();
309
308
  process.on('SIGINT', () => ac.abort());
309
+
310
+ // One shot prints no banner, so the mode that auto-approves every command was
311
+ // also the one that said nothing about what was confining them. On stderr, so
312
+ // piping the answer somewhere still gets just the answer.
313
+ if (resolveSandbox() === 'none' && AUTO_YES) {
314
+ console.error(c.yellow(` warning: shell commands run unconfined — ${sandbox.reason}`));
315
+ }
316
+
310
317
  const approve = async (name) => {
311
318
  if (AUTO_YES) return true;
312
319
  console.log(c.yellow(` ✗ ${name} needs approval; re-run with --yes to allow it`));
@@ -334,7 +341,16 @@ async function main() {
334
341
  // Without one, stdin IS the prompt, so wait longer before giving up.
335
342
  const piped = await readStdin(inline ? 200 : 10_000);
336
343
  const oneShotPrompt = inline && piped ? `${inline}\n\n${piped}` : (inline || piped);
337
- if (oneShotPrompt) { await oneShot(oneShotPrompt); return; }
344
+ if (oneShotPrompt) {
345
+ // stdin has given us everything it is going to. When it is a pipe the
346
+ // caller never closes — a script, an editor task, a CI step — the read
347
+ // above stays pending and its handle would keep the process alive long
348
+ // after the answer was printed. Let go of it before answering.
349
+ stdin.pause();
350
+ stdin.unref?.();
351
+ await oneShot(oneShotPrompt);
352
+ return;
353
+ }
338
354
 
339
355
  const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
340
356
  await boot();
@@ -347,9 +363,17 @@ async function main() {
347
363
  if (ctx.isGit) bits.push('git');
348
364
  if (ctx.agentFile) bits.push(c.green(ctx.agentFile));
349
365
  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`);
366
+ console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}`);
351
367
  }
352
368
 
369
+ // Say which of the two confinements is actually in force. Printing nothing
370
+ // would let the README's word "sandbox" stand in for a guarantee the kernel
371
+ // is not making on this machine.
372
+ const backend = resolveSandbox();
373
+ console.log(`${c.grey(' sandbox')} ${backend === 'none'
374
+ ? c.yellow(`paths only — ${sandbox.reason}, shell commands are unconfined`)
375
+ : c.grey(`paths + ${backend}`)}\n`);
376
+
353
377
  const messages = [{ role: 'system', content }];
354
378
 
355
379
  // 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)';