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 +153 -4
- package/package.json +1 -1
- package/src/boot.js +59 -0
- package/src/client.js +22 -0
- package/src/config.js +3 -0
- package/src/index.js +34 -10
- package/src/sandbox.js +168 -0
- package/src/tools.js +95 -7
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**.
|
|
157
|
-
|
|
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
|
|
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
|
|
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
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) {
|
|
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(' · '))}
|
|
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('..')
|
|
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
|
-
|
|
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
|
-
|
|
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)';
|