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 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 will report `Failed to log in` under the sandbox. If you want 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.2.1",
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 { TOOLS, NEEDS_APPROVAL, runTool, describe, preview, mcpNeedsApproval } from './tools.js';
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
- const RC = join(homedir(), '.kronk-cli.json');
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({ messages, model: config.model, signal: ac.signal, approve, mcp, auto: AUTO });
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({ messages, model: config.model, signal: ac.signal, approve, mcp, auto: isAuto() });
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 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.
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) => !allow.some((a) => d === a || d.startsWith(`${a}/`)));
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) => !allow.some((a) => d === a || d.startsWith(`${a}/`)));
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(script, { backend, root, home, cwd, tmp, env = process.env }) {
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 = { root: real(process.cwd()), cwd: real(process.cwd()) };
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')));