kronk-cli 0.1.0 → 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 +160 -3
- package/package.json +1 -1
- package/src/config.js +21 -0
- package/src/index.js +22 -2
- package/src/sandbox.js +168 -0
- package/src/tools.js +95 -7
package/README.md
CHANGED
|
@@ -23,8 +23,67 @@ entirely on your machine. No network, no API key, no per-token cost.
|
|
|
23
23
|
2104→812 tok · 61.3 tok/s · ttft 240ms · 1980 cached
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
[](https://github.com/BardiaN/kronk-cli/actions/workflows/ci.yml)
|
|
27
|
+
[](https://github.com/BardiaN/kronk-cli/actions/workflows/security.yml)
|
|
28
|
+
[](https://github.com/BardiaN/kronk-cli/actions/workflows/codeql.yml)
|
|
29
|
+
[](https://scorecard.dev/viewer/?uri=github.com/BardiaN/kronk-cli)
|
|
30
|
+
[](https://www.npmjs.com/package/kronk-cli)
|
|
31
|
+
|
|
26
32
|
**Zero dependencies.** It is `fetch` and `readline` against Kronk's OpenAI-compatible API.
|
|
27
33
|
|
|
34
|
+
**It talks to your Kronk server and nothing else** — enforced in CI by running the whole thing
|
|
35
|
+
inside a network namespace with only loopback. See [Verifying this build](#verifying-this-build).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Verifying this build
|
|
40
|
+
|
|
41
|
+
This tool reads your files and runs your commands. You should not have to take anyone's word for
|
|
42
|
+
what it does with them — so every claim here is machine-checkable.
|
|
43
|
+
|
|
44
|
+
### Check the artifact came from this source
|
|
45
|
+
|
|
46
|
+
Every release tarball carries a signed attestation binding it to the repository, commit and
|
|
47
|
+
workflow run that produced it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gh attestation verify kronk-cli-*.tgz --repo BardiaN/kronk-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
npm packages carry the same provenance, shown as a **Provenance** panel on the
|
|
54
|
+
[package page](https://www.npmjs.com/package/kronk-cli), and verifiable locally:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm audit signatures
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Both fail if the artifact was built anywhere other than this repository's CI.
|
|
61
|
+
|
|
62
|
+
### Read the scan results yourself
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| [Security tab](https://github.com/BardiaN/kronk-cli/security/code-scanning) | every CodeQL and Scorecard finding, open and closed |
|
|
67
|
+
| [Scorecard report](https://scorecard.dev/viewer/?uri=github.com/BardiaN/kronk-cli) | per-check supply-chain scores, updated weekly |
|
|
68
|
+
| [Actions](https://github.com/BardiaN/kronk-cli/actions/workflows/security.yml) | full logs of every scan, including the egress proof |
|
|
69
|
+
|
|
70
|
+
These are published by GitHub and the OpenSSF, not by this project. Nobody here can edit them.
|
|
71
|
+
|
|
72
|
+
### Check it does not phone home
|
|
73
|
+
|
|
74
|
+
The strongest claim, and the easiest to test yourself:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Linux — run it with no network except loopback
|
|
78
|
+
sudo unshare --net bash -c 'ip link set lo up; kronk-cli --models'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
CI runs exactly this on every pull request against a stub server, and a second step proves the
|
|
82
|
+
namespace was really isolated so a pass cannot be a false negative.
|
|
83
|
+
|
|
84
|
+
Or just read `src/` — it is under 1,500 lines with no dependencies, and every network call goes
|
|
85
|
+
through one function in `src/client.js`.
|
|
86
|
+
|
|
28
87
|
---
|
|
29
88
|
|
|
30
89
|
## Requirements
|
|
@@ -94,8 +153,102 @@ ln -s "$PWD/src/index.js" ~/.local/bin/kronk-cli
|
|
|
94
153
|
|
|
95
154
|
### Scope
|
|
96
155
|
|
|
97
|
-
The agent roots itself at **the directory you launch it from**.
|
|
98
|
-
|
|
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.
|
|
99
252
|
|
|
100
253
|
---
|
|
101
254
|
|
|
@@ -233,6 +386,9 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
233
386
|
| `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
|
|
234
387
|
| `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
|
|
235
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 |
|
|
236
392
|
| `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
|
|
237
393
|
| `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
|
|
238
394
|
| `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
|
|
@@ -430,7 +586,8 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
|
|
|
430
586
|
| `bash` | ✋ | Run a command; shows it first |
|
|
431
587
|
|
|
432
588
|
`--yes` and `--auto` skip the prompts. Paths resolve against the session directory and cannot
|
|
433
|
-
escape the launch root
|
|
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`
|
|
434
591
|
sticks the way it would in a real shell.
|
|
435
592
|
|
|
436
593
|
---
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -35,6 +35,27 @@ export const config = {
|
|
|
35
35
|
rcPath: RC,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* File contents and command output leave this process in request bodies, which
|
|
40
|
+
* is the whole job. Over loopback that is fine. Pointed at a remote host over
|
|
41
|
+
* plain HTTP it is not, so say so once rather than letting it pass silently.
|
|
42
|
+
*/
|
|
43
|
+
export function warnIfInsecure(warn = console.error) {
|
|
44
|
+
let url;
|
|
45
|
+
try { url = new URL(config.baseUrl); } catch { return false; }
|
|
46
|
+
|
|
47
|
+
const local = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(url.hostname)
|
|
48
|
+
|| url.hostname.endsWith('.local')
|
|
49
|
+
|| url.hostname === 'host.docker.internal';
|
|
50
|
+
|
|
51
|
+
if (url.protocol === 'https:' || local) return false;
|
|
52
|
+
|
|
53
|
+
warn(` warning: ${config.baseUrl} is remote and unencrypted.`);
|
|
54
|
+
warn(' File contents and command output will cross the network in clear text.');
|
|
55
|
+
warn(' Use https, or an SSH tunnel to keep the endpoint on localhost.');
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
export const headers = () => ({
|
|
39
60
|
'Content-Type': 'application/json',
|
|
40
61
|
Authorization: `Bearer ${config.token}`,
|
package/src/index.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import readline from 'node:readline/promises';
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { config, DEFAULT_MODEL } from './config.js';
|
|
5
|
+
import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
|
|
6
6
|
import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
|
|
7
7
|
import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
|
|
8
8
|
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`));
|
|
@@ -322,6 +331,9 @@ async function oneShot(prompt) {
|
|
|
322
331
|
}
|
|
323
332
|
|
|
324
333
|
async function main() {
|
|
334
|
+
// Before anything reaches the network, on every path through the program.
|
|
335
|
+
warnIfInsecure();
|
|
336
|
+
|
|
325
337
|
if (SHOW_MODELS) { await showModels(); return; }
|
|
326
338
|
if (SHOW_MCP) { await showMcp(); process.exit(0); }
|
|
327
339
|
|
|
@@ -344,9 +356,17 @@ async function main() {
|
|
|
344
356
|
if (ctx.isGit) bits.push('git');
|
|
345
357
|
if (ctx.agentFile) bits.push(c.green(ctx.agentFile));
|
|
346
358
|
if (config.contextWindow) bits.push(c.grey(`${(config.contextWindow / 1000).toFixed(0)}k ctx`));
|
|
347
|
-
console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}
|
|
359
|
+
console.log(c.grey(` context`) + ` ${bits.join(c.grey(' · '))}`);
|
|
348
360
|
}
|
|
349
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
|
+
|
|
350
370
|
const messages = [{ role: 'system', content }];
|
|
351
371
|
|
|
352
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('..')
|
|
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)';
|