kronk-cli 0.2.1 → 0.3.1
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 +41 -2
- package/package.json +2 -1
- package/src/agent.js +39 -2
- package/src/config.js +10 -1
- package/src/index.js +36 -2
- package/src/sandbox.js +29 -10
- package/src/tools.js +166 -4
package/README.md
CHANGED
|
@@ -234,12 +234,45 @@ little, since an attacker just takes whichever store was not on the list. So the
|
|
|
234
234
|
material that is pivot-grade and never legitimately read by a build.
|
|
235
235
|
|
|
236
236
|
**The one exception is the macOS keychain**, which is denied by default. `gh` stores its token
|
|
237
|
-
there, so it
|
|
237
|
+
there, so it reports `Failed to log in` under the sandbox even though you are logged in.
|
|
238
|
+
|
|
239
|
+
You no longer have to know that in advance. When the agent is about to run a command that needs a
|
|
240
|
+
credential store the sandbox denies, it asks first:
|
|
241
|
+
|
|
242
|
+
```console
|
|
243
|
+
1 ⚙ bash: gh issue view 34
|
|
244
|
+
gh reads ~/Library/Keychains, which the sandbox denies.
|
|
245
|
+
allow read-only access for this session? [y/N/always]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- `y` grants it for the rest of the session. The seatbelt profile is rebuilt for every command, so
|
|
249
|
+
it takes effect on the next one — no restart.
|
|
250
|
+
- `always` also writes the path to `~/.kronk-cli.json` under `sandboxReadable`, so later sessions
|
|
251
|
+
do not ask. It says which file it wrote.
|
|
252
|
+
- Anything else declines, and **the command still runs**. Declining is not an error; the command
|
|
253
|
+
fails the way it would have failed anyway, and the result carries a note saying a denial is the
|
|
254
|
+
likely cause.
|
|
255
|
+
|
|
256
|
+
You are asked at most once per path per session, whichever way you answered. With `--yes` or
|
|
257
|
+
`--auto` there is nobody to ask, so the grant is automatic and announced on stderr — piping stdout
|
|
258
|
+
still gets only the answer.
|
|
259
|
+
|
|
260
|
+
The grant is **read-only**. That is the difference from the older escape hatch, which still works
|
|
261
|
+
and is still the right tool if you would rather set it before you start:
|
|
238
262
|
|
|
239
263
|
```bash
|
|
240
264
|
KRONK_SANDBOX_ALLOW=~/Library/Keychains kronk-cli
|
|
241
265
|
```
|
|
242
266
|
|
|
267
|
+
`KRONK_SANDBOX_ALLOW` makes a path fully available — **readable *and writable***. For a credential
|
|
268
|
+
store that is a poor trade: reading it is what a logged-in CLI needs, and writing to it is what an
|
|
269
|
+
attacker needs. The prompt grants only the read.
|
|
270
|
+
|
|
271
|
+
Be honest with yourself about what you are granting either way: it is the whole `~/Library/Keychains`
|
|
272
|
+
directory, not one tool's item in it. macOS `securityd` gates individual items by per-application
|
|
273
|
+
ACL independently of file reads, so a process still cannot read another application's item without
|
|
274
|
+
your consent — but that is a mitigation on top, not the boundary you drew here.
|
|
275
|
+
|
|
243
276
|
**Logging in from inside the agent will not work**, by design — `argocd login`, `gh auth login` and
|
|
244
277
|
`kubectl config set-context` all write outside the project. Log in yourself, in your own shell,
|
|
245
278
|
before starting a session. If a tool genuinely must write to its config directory, allow just that:
|
|
@@ -632,6 +665,7 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
632
665
|
| `KRONK_SANDBOX` | `auto` | `auto` confines `bash` when the OS can, `strict` refuses to run it when it cannot, `off` disables it |
|
|
633
666
|
| `KRONK_SANDBOX_ALLOW` | — | Paths to make fully available inside the sandbox, comma or colon separated |
|
|
634
667
|
| `KRONK_SANDBOX_DENY` | — | Extra paths to hide from `bash`, comma or colon separated |
|
|
668
|
+
| `KRONK_CONFIG` | `~/.kronk-cli.json` | Path to the config file, so tests and throwaway runs stay off the real one |
|
|
635
669
|
| `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
|
|
636
670
|
| `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
|
|
637
671
|
| `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
|
|
@@ -655,10 +689,15 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
655
689
|
"compactAt": 0.85,
|
|
656
690
|
"noThink": true,
|
|
657
691
|
"preserveThinking": true,
|
|
658
|
-
"replayReasoning": true
|
|
692
|
+
"replayReasoning": true,
|
|
693
|
+
"sandboxReadable": ["~/Library/Keychains"]
|
|
659
694
|
}
|
|
660
695
|
```
|
|
661
696
|
|
|
697
|
+
`sandboxReadable` is the list `always` writes at the credential prompt — paths the sandbox lifts
|
|
698
|
+
its **read** denial on, and nothing more. Unlike `KRONK_SANDBOX_ALLOW` it never grants writes.
|
|
699
|
+
Entries may be absolute or `~`-relative, and one that no longer exists is ignored in silence.
|
|
700
|
+
|
|
662
701
|
---
|
|
663
702
|
|
|
664
703
|
## Choosing a model
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kronk-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node src/index.js",
|
|
31
31
|
"test": "node --test test/*.test.js",
|
|
32
|
+
"changelog": "node scripts/changelog.mjs",
|
|
32
33
|
"lint": "eslint .",
|
|
33
34
|
"check": "npm run lint && npm test"
|
|
34
35
|
},
|
package/src/agent.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { streamChat } from './client.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
TOOLS, NEEDS_APPROVAL, runTool, describe, preview, mcpNeedsApproval,
|
|
4
|
+
grantsNeeded, rememberGrants, declineGrants, credentialMatches,
|
|
5
|
+
} from './tools.js';
|
|
3
6
|
import { config, shouldPreserveThinking } from './config.js';
|
|
4
7
|
import { forRequest } from './reasoning.js';
|
|
5
8
|
import { c, fmtUsage, spinner, liveLine, toolResultLines } from './ui.js';
|
|
@@ -73,6 +76,8 @@ function endTurn(messages) {
|
|
|
73
76
|
/**
|
|
74
77
|
* Run one user turn to completion, looping while the model requests tools.
|
|
75
78
|
*
|
|
79
|
+
* `grant(binary, paths)` resolves to 'yes' | 'no' | 'always'; asked before a
|
|
80
|
+
* command that needs a credential store the sandbox denies.
|
|
76
81
|
* `approve(name, args)` returns a boolean; used for mutating tools. `auto` is
|
|
77
82
|
* autonomous mode — the system prompt in force, not `--yes` — and decides
|
|
78
83
|
* both whether a premature "done" is handed back or accepted, and (via
|
|
@@ -80,7 +85,7 @@ function endTurn(messages) {
|
|
|
80
85
|
* replayed on the wire.
|
|
81
86
|
*/
|
|
82
87
|
export async function runTurn({
|
|
83
|
-
messages, model, signal, approve, mcp, auto = false, maxSteps = config.maxSteps,
|
|
88
|
+
messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
|
|
84
89
|
}) {
|
|
85
90
|
const tools = mcp ? [...TOOLS, ...mcp.toolDefs()] : TOOLS;
|
|
86
91
|
let totalUsage = null;
|
|
@@ -251,6 +256,38 @@ export async function runTurn({
|
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
// Ask here, not in the tool layer, and not after this point.
|
|
260
|
+
//
|
|
261
|
+
// `liveLine()` below starts a 120 ms interval that rewrites the current
|
|
262
|
+
// line with `\r…\x1b[K` unconditionally, so a question printed once it is
|
|
263
|
+
// running gets overwritten between the asking and the answering. This is
|
|
264
|
+
// the last moment at which stdout belongs to us, and it is next door to
|
|
265
|
+
// the approval prompt the user already expects, which is where a second
|
|
266
|
+
// question belongs anyway.
|
|
267
|
+
if (call.name === 'bash' && grant) {
|
|
268
|
+
const needed = grantsNeeded(args.cmd);
|
|
269
|
+
if (needed.length) {
|
|
270
|
+
// Name the tool the user recognises, from the same scan that found
|
|
271
|
+
// the paths — so the question cannot name one binary while granting
|
|
272
|
+
// for another.
|
|
273
|
+
const binary = credentialMatches(args.cmd)
|
|
274
|
+
.find((m) => m.paths.some((p) => needed.includes(p)))?.bin ?? 'this command';
|
|
275
|
+
const answer = await grant(binary, needed);
|
|
276
|
+
if (answer === 'yes' || answer === 'always') {
|
|
277
|
+
try {
|
|
278
|
+
const wrote = rememberGrants(needed, { persist: answer === 'always' });
|
|
279
|
+
if (wrote) console.log(c.grey(` remembered in ${wrote}`));
|
|
280
|
+
} catch (e) {
|
|
281
|
+
// Failing to persist must not fail the command. The grant is
|
|
282
|
+
// already in force for this session either way.
|
|
283
|
+
console.log(c.yellow(` ${e.message}`));
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
declineGrants(needed);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
254
291
|
const live = liveLine();
|
|
255
292
|
let result;
|
|
256
293
|
try {
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,10 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// KRONK_CONFIG exists so tests never touch the real file. Everything here is
|
|
6
|
+
// read at module load, so a test that sets it must `await import()` rather than
|
|
7
|
+
// use a static import — a static one is hoisted and runs before the assignment.
|
|
8
|
+
const RC = process.env.KRONK_CONFIG ?? join(homedir(), '.kronk-cli.json');
|
|
6
9
|
const MODEL_CONFIG = join(homedir(), '.kronk', 'models', 'model_config.yaml');
|
|
7
10
|
|
|
8
11
|
function fileConfig() {
|
|
@@ -56,6 +59,12 @@ export const config = {
|
|
|
56
59
|
// Kronk's per-model runtime settings. `setup` is the only thing that writes
|
|
57
60
|
// it; the override exists so tests never go near the real one.
|
|
58
61
|
modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
|
|
62
|
+
// Paths a previous session granted read-only to the sandbox, via `always` at
|
|
63
|
+
// the credential prompt. Read-only is the whole point: KRONK_SANDBOX_ALLOW
|
|
64
|
+
// grants writes as well, and these are credential stores.
|
|
65
|
+
sandboxReadable: (Array.isArray(file.sandboxReadable) ? file.sandboxReadable : [])
|
|
66
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
67
|
+
.map((p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p)),
|
|
59
68
|
lastUsed: 0,
|
|
60
69
|
contextWindow: null, // filled in at boot from Kronk
|
|
61
70
|
nativeContext: null,
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import readline from 'node:readline/promises';
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { readFile } from 'node:fs/promises';
|
|
5
6
|
import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
|
|
6
7
|
import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
|
|
@@ -76,6 +77,9 @@ if (args.help) {
|
|
|
76
77
|
// --auto: run the whole task unattended (implies --yes)
|
|
77
78
|
const AUTO = args.auto;
|
|
78
79
|
const AUTO_YES = args.yes || AUTO;
|
|
80
|
+
|
|
81
|
+
/** `~/Library/Keychains` reads better than the absolute path in a prompt. */
|
|
82
|
+
const tilde = (p) => (p.startsWith(`${homedir()}/`) ? `~/${p.slice(homedir().length + 1)}` : p);
|
|
79
83
|
const SHOW_MODELS = args.models;
|
|
80
84
|
const SHOW_MCP = args.mcpList;
|
|
81
85
|
const NO_CONTEXT = args.noContext;
|
|
@@ -312,8 +316,21 @@ async function oneShot(prompt) {
|
|
|
312
316
|
console.log(c.yellow(` ✗ ${name} needs approval; re-run with --yes to allow it`));
|
|
313
317
|
return false;
|
|
314
318
|
};
|
|
319
|
+
|
|
320
|
+
// Nobody is here to answer, and this mode already auto-approves everything
|
|
321
|
+
// else. Granting silently would reproduce the exact failure #21 is about —
|
|
322
|
+
// `gh` reporting a broken login — in the mode where it is hardest to notice,
|
|
323
|
+
// so it is announced. On stderr, so a piped stdout still carries only the
|
|
324
|
+
// answer.
|
|
325
|
+
const grant = async (binary, paths) => {
|
|
326
|
+
if (!AUTO_YES) return 'no';
|
|
327
|
+
console.error(c.grey(` granted read-only ${paths.map(tilde).join(', ')} to ${binary} (--yes)`));
|
|
328
|
+
return 'yes';
|
|
329
|
+
};
|
|
315
330
|
try {
|
|
316
|
-
await runTurn({
|
|
331
|
+
await runTurn({
|
|
332
|
+
messages, model: config.model, signal: ac.signal, approve, grant, mcp, auto: AUTO,
|
|
333
|
+
});
|
|
317
334
|
} catch (e) {
|
|
318
335
|
if (e.name !== 'AbortError') { console.error(c.red(` ${e.message}`)); process.exitCode = 1; }
|
|
319
336
|
} finally {
|
|
@@ -414,6 +431,21 @@ async function main() {
|
|
|
414
431
|
return a === 'y' || a === 'yes';
|
|
415
432
|
};
|
|
416
433
|
|
|
434
|
+
// Read-only, and said so: the answer to "why is my gh broken" should not be a
|
|
435
|
+
// flag you had to know about before you started. `always` writes the path to
|
|
436
|
+
// ~/.kronk-cli.json so the next session does not ask.
|
|
437
|
+
const grant = async (binary, paths) => {
|
|
438
|
+
if (autoApprove) {
|
|
439
|
+
console.error(c.grey(` granted read-only ${paths.map(tilde).join(', ')} to ${binary} (--yes)`));
|
|
440
|
+
return 'yes';
|
|
441
|
+
}
|
|
442
|
+
console.log(c.yellow(` ${binary} reads ${paths.map(tilde).join(', ')}, which the sandbox denies.`));
|
|
443
|
+
const a = (await rl.question(c.yellow(' allow read-only access for this session? [y/N/always] ')))
|
|
444
|
+
.trim().toLowerCase();
|
|
445
|
+
if (a === 'always' || a === 'a') return 'always';
|
|
446
|
+
return a === 'y' || a === 'yes' ? 'yes' : 'no';
|
|
447
|
+
};
|
|
448
|
+
|
|
417
449
|
for (;;) {
|
|
418
450
|
const status = statusLine({
|
|
419
451
|
model: config.model,
|
|
@@ -524,7 +556,9 @@ async function main() {
|
|
|
524
556
|
messages.push({ role: 'user', content: input });
|
|
525
557
|
ac = new AbortController();
|
|
526
558
|
try {
|
|
527
|
-
await runTurn({
|
|
559
|
+
await runTurn({
|
|
560
|
+
messages, model: config.model, signal: ac.signal, approve, grant, mcp, auto: isAuto(),
|
|
561
|
+
});
|
|
528
562
|
} catch (e) {
|
|
529
563
|
if (e.name === 'AbortError') messages.push({ role: 'assistant', content: '(interrupted)' });
|
|
530
564
|
else console.error(c.red(`\n ${e.message}`));
|
package/src/sandbox.js
CHANGED
|
@@ -37,9 +37,13 @@ export const cacheDirs = (home) => CACHE_DIRS.map((d) => join(home, d));
|
|
|
37
37
|
* exfiltrates whichever credential store was not on it.
|
|
38
38
|
*
|
|
39
39
|
* So the default is narrow and covers material that is pivot-grade and never
|
|
40
|
-
* legitimately read by a build. Session tokens
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* legitimately read by a build. Session tokens that CLIs keep in their own
|
|
41
|
+
* config directories — `~/.config/gh`, `~/.aws`, `~/.kube` — stay readable. A
|
|
42
|
+
* token in the macOS keychain does not: `Library/Keychains` is on the list
|
|
43
|
+
* below, which is why `gh` reports a login failure here even though it is
|
|
44
|
+
* logged in. That is what the `readable` grant exists to lift, one path at a
|
|
45
|
+
* time and without granting writes. Add more with KRONK_SANDBOX_DENY if your
|
|
46
|
+
* threat model wants them gone, at the cost of those commands failing.
|
|
43
47
|
*
|
|
44
48
|
* The write confinement is the half that holds categorically. This half is
|
|
45
49
|
* best-effort, and the README says so.
|
|
@@ -66,7 +70,7 @@ const sb = (p) => `"${p.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
|
66
70
|
* dylib, locale file and device node a toolchain touches, and getting that wrong
|
|
67
71
|
* fails closed in ways that look like a broken CLI rather than a blocked write.
|
|
68
72
|
*/
|
|
69
|
-
export function seatbeltProfile({ root, home, tmp, allow = [], deny = [] }) {
|
|
73
|
+
export function seatbeltProfile({ root, home, tmp, allow = [], deny = [], readable = [] }) {
|
|
70
74
|
const writable = [root, '/dev', '/private/tmp', '/private/var/tmp', '/private/var/folders', '/tmp']
|
|
71
75
|
.concat(tmp ? [tmp] : [])
|
|
72
76
|
.concat(cacheDirs(home))
|
|
@@ -75,8 +79,14 @@ export function seatbeltProfile({ root, home, tmp, allow = [], deny = [] }) {
|
|
|
75
79
|
// ALLOW means "this path is fully available", so it also lifts a default
|
|
76
80
|
// denial. Without that there is no way to run a keychain-backed CLI like `gh`
|
|
77
81
|
// short of turning the sandbox off entirely, which is a worse trade.
|
|
82
|
+
//
|
|
83
|
+
// `readable` lifts the same denial and stops there — note it is absent from
|
|
84
|
+
// `writable` above. Reading a credential store is what a logged-in CLI needs;
|
|
85
|
+
// writing to one is what an attacker needs, and handing over the second to
|
|
86
|
+
// obtain the first is the trade this parameter exists to refuse.
|
|
87
|
+
const lifted = allow.concat(readable);
|
|
78
88
|
const unreadable = SECRET_DIRS.map((d) => join(home, d)).concat(deny)
|
|
79
|
-
.filter((d) => !
|
|
89
|
+
.filter((d) => !lifted.some((a) => d === a || d.startsWith(`${a}/`)));
|
|
80
90
|
|
|
81
91
|
return [
|
|
82
92
|
'(version 1)',
|
|
@@ -99,7 +109,7 @@ export function seatbeltProfile({ root, home, tmp, allow = [], deny = [] }) {
|
|
|
99
109
|
* shape that matches what the README promises, and it is what seatbelt does on
|
|
100
110
|
* the other side.
|
|
101
111
|
*/
|
|
102
|
-
export function bwrapArgs({ root, home, cwd, tmp, allow = [], deny = [] }) {
|
|
112
|
+
export function bwrapArgs({ root, home, cwd, tmp, allow = [], deny = [], readable = [] }) {
|
|
103
113
|
// /dev and /proc must be fresh mounts: a read-only bind of the host's would
|
|
104
114
|
// leave a shell unable to write to its own stdout or read /proc/self.
|
|
105
115
|
const args = ['--die-with-parent', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc'];
|
|
@@ -121,8 +131,14 @@ export function bwrapArgs({ root, home, cwd, tmp, allow = [], deny = [] }) {
|
|
|
121
131
|
// A tmpfs makes a credential directory exist but be empty; /dev/null over a
|
|
122
132
|
// file makes it readable and empty. Both beat a missing path, which tools
|
|
123
133
|
// report as a confusing ENOENT rather than an obvious denial.
|
|
134
|
+
// A `readable` path is simply left out of this loop. It needs no bind of its
|
|
135
|
+
// own: `--ro-bind / /` above already made the whole filesystem readable, so
|
|
136
|
+
// not hiding it *is* the grant. An explicit `--ro-bind` would be redundant,
|
|
137
|
+
// and worse, would read as though write access were being withheld by that
|
|
138
|
+
// line rather than never offered — so there is none.
|
|
139
|
+
const lifted = allow.concat(readable);
|
|
124
140
|
const hidden = SECRET_DIRS.map((x) => join(home, x)).concat(deny)
|
|
125
|
-
.filter((d) => !
|
|
141
|
+
.filter((d) => !lifted.some((a) => d === a || d.startsWith(`${a}/`)));
|
|
126
142
|
for (const d of hidden) {
|
|
127
143
|
if (existsSync(d) && statSync(d).isDirectory()) args.push('--tmpfs', d);
|
|
128
144
|
}
|
|
@@ -153,16 +169,19 @@ export function detectBackend({ platform = process.platform, env = process.env }
|
|
|
153
169
|
* Build the argv that runs `script` under `backend`.
|
|
154
170
|
* Returns `['bash', ['-c', script]]` shaped output for spawn().
|
|
155
171
|
*/
|
|
156
|
-
export function sandboxArgv(
|
|
172
|
+
export function sandboxArgv(
|
|
173
|
+
script,
|
|
174
|
+
{ backend, root, home, cwd, tmp, env = process.env, readable = [] },
|
|
175
|
+
) {
|
|
157
176
|
const allow = extraPaths(env.KRONK_SANDBOX_ALLOW, home);
|
|
158
177
|
const deny = extraPaths(env.KRONK_SANDBOX_DENY, home);
|
|
159
178
|
|
|
160
179
|
if (backend === 'seatbelt') {
|
|
161
180
|
return ['sandbox-exec',
|
|
162
|
-
['-p', seatbeltProfile({ root, home, tmp, allow, deny }), 'bash', '-c', script]];
|
|
181
|
+
['-p', seatbeltProfile({ root, home, tmp, allow, deny, readable }), 'bash', '-c', script]];
|
|
163
182
|
}
|
|
164
183
|
if (backend === 'bwrap') {
|
|
165
|
-
return ['bwrap', [...bwrapArgs({ root, home, cwd, tmp, allow, deny }), '-c', script]];
|
|
184
|
+
return ['bwrap', [...bwrapArgs({ root, home, cwd, tmp, allow, deny, readable }), '-c', script]];
|
|
166
185
|
}
|
|
167
186
|
return ['bash', ['-c', script]];
|
|
168
187
|
}
|
package/src/tools.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
-
import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
|
|
5
|
-
import { realpathSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { resolve, relative, dirname, basename, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { realpathSync, mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir, tmpdir } from 'node:os';
|
|
7
7
|
import { detectBackend, sandboxArgv, cacheDirs } from './sandbox.js';
|
|
8
|
+
import { config } from './config.js';
|
|
8
9
|
import { setPlan, MAX_ITEMS } from './plan.js';
|
|
9
10
|
import { c } from './ui.js';
|
|
10
11
|
|
|
@@ -21,7 +22,130 @@ const TOOL_TIMEOUT = Number(process.env.KRONK_TOOL_TIMEOUT ?? 900) * 1000; //
|
|
|
21
22
|
* re-running `pwd` and `cd` trying to work out where it is. We keep the cwd
|
|
22
23
|
* here and hand it back to every subsequent call.
|
|
23
24
|
*/
|
|
24
|
-
export const session = {
|
|
25
|
+
export const session = {
|
|
26
|
+
root: real(process.cwd()),
|
|
27
|
+
cwd: real(process.cwd()),
|
|
28
|
+
// Paths granted read-only to the sandbox for the rest of this run, and paths
|
|
29
|
+
// the user was asked about and said no to. Both are needed: without
|
|
30
|
+
// `declined`, a refusal would be re-asked on the very next command, which is
|
|
31
|
+
// how a prompt trains people to stop reading it.
|
|
32
|
+
//
|
|
33
|
+
// The seatbelt profile is rebuilt per command — `runBash` calls `sandboxArgv`
|
|
34
|
+
// every time — so adding to this set takes effect on the next command with no
|
|
35
|
+
// restart. That is what makes asking at the moment of need possible at all.
|
|
36
|
+
grants: new Set(config.sandboxReadable),
|
|
37
|
+
declined: new Set(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Credential stores a tool needs to read, and cannot reach under the sandbox.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately tiny, and grown only on measurement. Under the denied profile on
|
|
44
|
+
* macOS, `kubectl config current-context` and `aws configure list` both exit 0 —
|
|
45
|
+
* their tokens live in their own config directories, which are readable — so
|
|
46
|
+
* neither belongs here. `heroku` uses `~/.netrc` and `vault` uses
|
|
47
|
+
* `~/.vault-token`; both are SECRET_FILES rather than SECRET_DIRS, and this
|
|
48
|
+
* makes no file grantable, so neither belongs here either.
|
|
49
|
+
*
|
|
50
|
+
* A speculative entry would prompt for something that was never denied, which
|
|
51
|
+
* is worse than not prompting: it teaches the user that the question is noise.
|
|
52
|
+
*/
|
|
53
|
+
export const CREDENTIAL_TOOLS = {
|
|
54
|
+
gh: ['Library/Keychains'], // verified: fails denied, succeeds granted
|
|
55
|
+
docker: ['Library/Keychains'], // unverified; credsStore: "desktop" uses the keychain
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Which known binaries a command line actually runs.
|
|
60
|
+
*
|
|
61
|
+
* Split on the separators that start a new command, then take the first token
|
|
62
|
+
* of each segment after stepping over leading `NAME=value` assignments. That is
|
|
63
|
+
* a heuristic and is meant to be: its only outputs are "ask" and "do not ask",
|
|
64
|
+
* so a miss costs one un-asked question and a false hit costs one declined
|
|
65
|
+
* prompt. It never rewrites, blocks or delays the command.
|
|
66
|
+
*
|
|
67
|
+
* `echo gh` must not match — `gh` is an argument there, not the command — and
|
|
68
|
+
* neither must `mygh`, which is why the first token is compared whole.
|
|
69
|
+
*/
|
|
70
|
+
export function credentialMatches(cmd, home = homedir()) {
|
|
71
|
+
const hits = new Map();
|
|
72
|
+
for (const segment of String(cmd ?? '').split(/[;&|\n]+/)) {
|
|
73
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
76
|
+
if (!tokens[i]) continue;
|
|
77
|
+
const bin = basename(tokens[i]);
|
|
78
|
+
const paths = CREDENTIAL_TOOLS[bin];
|
|
79
|
+
if (paths && !hits.has(bin)) hits.set(bin, paths.map((rel) => join(home, rel)));
|
|
80
|
+
}
|
|
81
|
+
return [...hits].map(([bin, paths]) => ({ bin, paths }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Just the paths, deduplicated across however many tools matched. */
|
|
85
|
+
export function credentialPaths(cmd, home = homedir()) {
|
|
86
|
+
return [...new Set(credentialMatches(cmd, home).flatMap((m) => m.paths))];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The paths this command needs that are denied right now — nothing to ask about
|
|
91
|
+
* when the sandbox is off, when they are already granted, or when the path does
|
|
92
|
+
* not exist on this machine, which is what keeps Linux silent about a macOS
|
|
93
|
+
* keychain.
|
|
94
|
+
*/
|
|
95
|
+
export function ungranted(cmd, { home = homedir(), backend = sandbox.backend } = {}) {
|
|
96
|
+
if (backend === 'none' || backend === 'pending') return [];
|
|
97
|
+
return credentialPaths(cmd, home)
|
|
98
|
+
.filter((p) => !session.grants.has(p) && existsSync(p));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What this command would need asking about: denied, not granted, and not
|
|
103
|
+
* already refused this session. Empty means say nothing and run it.
|
|
104
|
+
*
|
|
105
|
+
* `declined` is filtered here rather than at the call site so that "ask at most
|
|
106
|
+
* once per path per session" is one rule in one place, true of a refusal
|
|
107
|
+
* exactly as it is of a grant.
|
|
108
|
+
*/
|
|
109
|
+
export function grantsNeeded(cmd, opts = {}) {
|
|
110
|
+
return ungranted(cmd, opts).filter((p) => !session.declined.has(p));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Record a grant, and optionally remember it past this session.
|
|
115
|
+
*
|
|
116
|
+
* Persisting is a read-modify-write of `~/.kronk-cli.json` rather than a
|
|
117
|
+
* rewrite: the file is the user's, and it holds their model, their base URL and
|
|
118
|
+
* whatever else they have set. Losing those to record a keychain path would be
|
|
119
|
+
* a bad trade for a convenience feature. A file that cannot be parsed is left
|
|
120
|
+
* alone and reported, never silently replaced.
|
|
121
|
+
*
|
|
122
|
+
* Returns the path written to, or null when nothing was persisted.
|
|
123
|
+
*/
|
|
124
|
+
export function rememberGrants(paths, { persist = false, home = homedir() } = {}) {
|
|
125
|
+
for (const p of paths) {
|
|
126
|
+
session.grants.add(p);
|
|
127
|
+
session.declined.delete(p);
|
|
128
|
+
}
|
|
129
|
+
if (!persist || !paths.length) return null;
|
|
130
|
+
|
|
131
|
+
const tilde = (p) => (p.startsWith(`${home}/`) ? `~/${p.slice(home.length + 1)}` : p);
|
|
132
|
+
let current = {};
|
|
133
|
+
try { current = JSON.parse(readFileSync(config.rcPath, 'utf8')); }
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (e.code !== 'ENOENT') throw new Error(`${config.rcPath} is not valid JSON — leaving it alone`, { cause: e });
|
|
136
|
+
}
|
|
137
|
+
const merged = [...new Set([
|
|
138
|
+
...(Array.isArray(current.sandboxReadable) ? current.sandboxReadable : []),
|
|
139
|
+
...paths.map(tilde),
|
|
140
|
+
])];
|
|
141
|
+
writeFileSync(config.rcPath, `${JSON.stringify({ ...current, sandboxReadable: merged }, null, 2)}\n`);
|
|
142
|
+
return config.rcPath;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Remember a refusal, so the same command does not ask again this session. */
|
|
146
|
+
export function declineGrants(paths) {
|
|
147
|
+
for (const p of paths) session.declined.add(p);
|
|
148
|
+
}
|
|
25
149
|
|
|
26
150
|
/**
|
|
27
151
|
* Resolve symlinks before comparing paths.
|
|
@@ -186,6 +310,31 @@ function applyCwd(out, mark) {
|
|
|
186
310
|
return { body, pwd: next, escaped };
|
|
187
311
|
}
|
|
188
312
|
|
|
313
|
+
/**
|
|
314
|
+
* One line telling the model that a sandbox denial is the likely cause, when it
|
|
315
|
+
* actually is.
|
|
316
|
+
*
|
|
317
|
+
* The gate is narrow on purpose: this fires only when the failing command named
|
|
318
|
+
* a tool in CREDENTIAL_TOOLS *and* the path it needs is still ungranted.
|
|
319
|
+
* Loosening it to "the sandbox denies something" would fire on every failing
|
|
320
|
+
* `npm test` and every failing build, because SECRET_FILES are denied
|
|
321
|
+
* unconditionally under any active backend — something is always denied. A hint
|
|
322
|
+
* that appears on every failure teaches the model to blame the sandbox for
|
|
323
|
+
* ordinary bugs, which is worse than no hint at all.
|
|
324
|
+
*
|
|
325
|
+
* KRONK_SANDBOX_ALLOW is deliberately not suggested here. It is the read-write
|
|
326
|
+
* hatch the grant prompt exists to replace.
|
|
327
|
+
*/
|
|
328
|
+
export function credentialHint(cmd, opts = {}) {
|
|
329
|
+
const denied = ungranted(cmd, opts);
|
|
330
|
+
if (!denied.length) return null;
|
|
331
|
+
const home = opts.home ?? homedir();
|
|
332
|
+
const tilde = (p) => (p.startsWith(`${home}/`) ? `~/${p.slice(home.length + 1)}` : p);
|
|
333
|
+
const match = credentialMatches(cmd, home).find((m) => m.paths.some((p) => denied.includes(p)));
|
|
334
|
+
return `note: the sandbox denies reads of ${denied.map(tilde).join(', ')}, which `
|
|
335
|
+
+ `${match?.bin ?? 'this command'} uses. Re-run and allow it if this failed on credentials.`;
|
|
336
|
+
}
|
|
337
|
+
|
|
189
338
|
/** The directory a command ran in, and a warning when that was outside the root. */
|
|
190
339
|
function whereLines(pwd, escaped) {
|
|
191
340
|
const ran = pwd ? `ran in: ${pwd}` : `ran in: ${session.cwd} (final directory unknown)`;
|
|
@@ -234,6 +383,7 @@ export function resolveSandbox({ platform = process.platform, env = process.env
|
|
|
234
383
|
|
|
235
384
|
const [bin, argv] = sandboxArgv('exit 0', {
|
|
236
385
|
backend: wanted, root: session.root, home: homedir(), cwd: session.cwd, tmp: real(tmpdir()),
|
|
386
|
+
readable: [...session.grants],
|
|
237
387
|
});
|
|
238
388
|
const probe = spawnSync(bin, argv, { stdio: 'ignore', timeout: 10_000 });
|
|
239
389
|
|
|
@@ -273,8 +423,13 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
273
423
|
return resolve(`error: refusing to run unconfined — KRONK_SANDBOX=strict and ${sandbox.reason}.`);
|
|
274
424
|
}
|
|
275
425
|
|
|
426
|
+
// Read straight from module state rather than from `opts`. The profile is
|
|
427
|
+
// rebuilt here on every command, so a grant made a moment ago is in force
|
|
428
|
+
// now; threading it through the caller would add a second route for the
|
|
429
|
+
// same fact, and the two would eventually disagree.
|
|
276
430
|
const [bin, argv] = sandboxArgv(script, {
|
|
277
431
|
backend, root: session.root, home: homedir(), cwd: session.cwd, tmp: real(tmpdir()),
|
|
432
|
+
readable: [...session.grants],
|
|
278
433
|
});
|
|
279
434
|
const child = spawn(bin, argv, {
|
|
280
435
|
cwd: session.cwd,
|
|
@@ -338,13 +493,19 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
338
493
|
err.trim() && `stderr:\n${err.trim()}`,
|
|
339
494
|
].filter(Boolean).join('\n\n');
|
|
340
495
|
|
|
496
|
+
// Right after `error:` and `ran in:`, never appended at the end: the tool
|
|
497
|
+
// result is capped for display, and a hint below the cap is a hint the
|
|
498
|
+
// reader never sees.
|
|
499
|
+
const hint = credentialHint(cmd);
|
|
500
|
+
|
|
341
501
|
if (timedOut) {
|
|
342
502
|
return resolve(clip([
|
|
343
503
|
`error: killed after ${secs}s (timeout ${Math.round(timeoutMs / 1000)}s).`,
|
|
344
504
|
'The command may simply be slow — re-run a narrower scope, or raise KRONK_TOOL_TIMEOUT.',
|
|
345
505
|
...where,
|
|
506
|
+
hint,
|
|
346
507
|
tail || '(no output before it was killed)',
|
|
347
|
-
].join('\n')));
|
|
508
|
+
].filter(Boolean).join('\n')));
|
|
348
509
|
}
|
|
349
510
|
if (code === 0) {
|
|
350
511
|
const okBody = clip(body + err);
|
|
@@ -359,6 +520,7 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
|
359
520
|
? `error: killed by ${signal} after ${secs}s`
|
|
360
521
|
: `error: exit code ${code} after ${secs}s`,
|
|
361
522
|
...where,
|
|
523
|
+
hint,
|
|
362
524
|
tail || '(no output)',
|
|
363
525
|
truncated ? '[earlier output dropped]' : '',
|
|
364
526
|
].filter(Boolean).join('\n')));
|