klyro 1.0.14 → 1.0.16

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
@@ -51,6 +51,7 @@ node dist/index.js chat
51
51
  | `KLYRO_YES` / `--yes` | **Commit only** — auto-approves `klyro commit` prompts; nothing else reads it |
52
52
  | `KLYRO_NO_UPDATE_CHECK=1` | Disables the 24h update check |
53
53
  | `KLYRO_ALLOW_MAIN_PUSH=1` | Per-risk escape for protected-branch push |
54
+ | `KLYRO_TRUST_PROJECT_HOOKS=1` | Per-shell opt-in to run repo-authored `.klyro/hooks.json` commands without pinning them via `klyro hooks trust` |
54
55
  | `KLYRO_CREDENTIALS_INSECURE_OK=1` | Warn (don't refuse) on group-readable credentials |
55
56
  | `KLYRO_LSP=0` | Force language tools off |
56
57
  | `KLYRO_SYMBOLS=0` | Force `find_symbol` off |
@@ -60,6 +61,7 @@ node dist/index.js chat
60
61
  ## New in recent releases
61
62
 
62
63
  - `klyro run --bare` — deterministic runs: skips MCP, hooks, memory/KLYRO.md/context, persistence
64
+ - `klyro hooks trust` — project `.klyro/hooks.json` commands run only after explicit review: the file is hash-pinned in `~/.klyro/trusted-hooks.json` and any edit re-locks it (a cloned repo can no longer execute code just because you ran `klyro` in it)
63
65
  - `klyro mcp trust <name>` / `mcp prompts [server]` / `mcp add <name> <https-url>` — remote MCP + prompt trust
64
66
  - `klyro agents lint` — validate `.klyro/agents/*.md` (ids, tool names)
65
67
  - `klyro init` — scan-seeded `KLYRO.md` + `.mcp.json` (never overwrites)
@@ -72,13 +74,20 @@ node dist/index.js chat
72
74
 
73
75
  ## Documentation
74
76
 
77
+ Progress: Klyro is **complete through Level 10** ("Klyro 1.0" milestone) of the
78
+ 20-level plan; see [`docs/STATUS.md`](docs/STATUS.md) for the audited
79
+ level-by-level grading.
80
+
75
81
  | Doc | Purpose |
76
82
  |---|---|
77
- | [`docs/done.md`](docs/done.md) | **Status** — what's built, what's verified, what isn't |
78
- | [`docs/plan.md`](docs/plan.md) | **Roadmap** — 20-level plan from bare CLI to super-harness |
79
- | [`docs/PRD.md`](docs/PRD.md) | (authoritative) product vision |
80
- | [`docs/HarnessFlow.md`](docs/HarnessFlow.md) | (authoritative) system flow |
81
- | [`docs/MVP.md`](docs/MVP.md) | (authoritative) MVP scope |
83
+ | [`docs/STATUS.md`](docs/STATUS.md) | **Status (authoritative)** — release state + plan level progress |
84
+ | [`plan.md`](plan.md) | **Roadmap** — 20-level / 100-sub-level plan from bare CLI to super-harness |
85
+ | [`PRD.md`](PRD.md) | Product vision and requirements |
86
+ | [`READ.md`](READ.md) | Full build documentation / architecture walkthrough |
87
+ | [`review.md`](review.md) | Point-in-time repository review (1.0.9) |
88
+ | [`comparison.md`](comparison.md) | Architectural audit vs Claude Code (36 rounds) |
89
+ | [`commands.md`](commands.md) | CLI + slash-command reference |
90
+ | [`TUI_DESIGN.md`](TUI_DESIGN.md) | TUI design notes (layout, scroll model) |
82
91
 
83
92
  ## Code structure
84
93
 
@@ -33,6 +33,11 @@ import { spawn } from 'node:child_process';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { existsSync } from 'node:fs';
35
35
  import { run } from './runtime.js';
36
+ import { cappedOutput } from '../shared/output-cap.js';
37
+ /** Child results are ONE JSON line, but a misbehaving child can print forever. */
38
+ const MAX_CHILD_STDOUT_BYTES = 8 * 1024 * 1024;
39
+ /** stderr keeps its TAIL — callers slice(-500/-1000) for the failure reason. */
40
+ const MAX_CHILD_STDERR_CHARS = 16_000;
36
41
  /**
37
42
  * Differently-phrased crash, distinct from an in-process throw the parent
38
43
  * would catch. The child either exited non-zero without a parseable result
@@ -81,12 +86,18 @@ export async function forkChild(entry, payload, opts = {}) {
81
86
  windowsHide: true,
82
87
  shell: false,
83
88
  });
84
- let stdout = '';
89
+ const stdoutCap = cappedOutput(MAX_CHILD_STDOUT_BYTES);
85
90
  let stderr = '';
86
91
  child.stdout.setEncoding('utf8');
87
92
  child.stderr.setEncoding('utf8');
88
- child.stdout.on('data', (c) => { stdout += c; });
89
- child.stderr.on('data', (c) => { opts.onStderr?.(c); stderr += c; });
93
+ // Keep the HEAD of stdout (the ChildResult line) and the TAIL of stderr
94
+ // (where the reason lives), so neither can grow the parent's heap without
95
+ // bound over the child's 10-minute budget.
96
+ child.stdout.on('data', (c) => { stdoutCap.push(Buffer.from(c, 'utf8')); });
97
+ child.stderr.on('data', (c) => {
98
+ opts.onStderr?.(c);
99
+ stderr = (stderr + c).slice(-MAX_CHILD_STDERR_CHARS);
100
+ });
90
101
  // Write the payload to stdin, then signal EOF so the child knows it has
91
102
  // the whole payload before it starts running.
92
103
  child.stdin.on('error', () => { });
@@ -119,7 +130,7 @@ export async function forkChild(entry, payload, opts = {}) {
119
130
  });
120
131
  child.on('close', (code, signal) => {
121
132
  cleanup();
122
- const first = stdout.split('\n').find((l) => l.trim().length > 0);
133
+ const first = stdoutCap.text().split('\n').find((l) => l.trim().length > 0);
123
134
  if (code === 0 && first) {
124
135
  try {
125
136
  resolve(JSON.parse(first));
@@ -14,6 +14,7 @@ import * as fs from 'node:fs/promises';
14
14
  import * as fsSync from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import * as crypto from 'node:crypto';
17
+ import { cappedOutput } from '../shared/output-cap.js';
17
18
  function ckptDir(cwd) {
18
19
  return path.join(cwd, '.klyro', 'checkpoints');
19
20
  }
@@ -107,7 +108,7 @@ export async function snapshot(cwd, files) {
107
108
  const args = ['diff', '--', ...kept.slice(0, 20)];
108
109
  const diffText = await new Promise((resolve) => {
109
110
  const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
110
- const chunks = [];
111
+ const sink = cappedOutput(20 * 1024);
111
112
  let done = false;
112
113
  const t = setTimeout(() => {
113
114
  if (!done) {
@@ -119,16 +120,13 @@ export async function snapshot(cwd, files) {
119
120
  resolve('');
120
121
  }
121
122
  }, 10_000);
122
- child.stdout.on('data', (b) => {
123
- if (Buffer.concat(chunks).length < 20 * 1024)
124
- chunks.push(b);
125
- });
123
+ child.stdout.on('data', (b) => sink.push(b));
126
124
  child.on('close', () => {
127
125
  if (done)
128
126
  return;
129
127
  done = true;
130
128
  clearTimeout(t);
131
- resolve(Buffer.concat(chunks).toString('utf-8').slice(0, 20 * 1024));
129
+ resolve(sink.text());
132
130
  });
133
131
  child.on('error', () => {
134
132
  if (done)
@@ -214,9 +212,9 @@ export async function diff(cwd, id) {
214
212
  const { spawn } = await import('node:child_process');
215
213
  return new Promise((resolve) => {
216
214
  const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
217
- let out = '';
218
- child.stdout.on('data', (b) => { out += b.toString(); });
219
- child.on('close', () => resolve(out || 'No diff'));
215
+ const sink = cappedOutput(64 * 1024);
216
+ child.stdout.on('data', (b) => sink.push(b));
217
+ child.on('close', () => resolve(sink.text() || 'No diff'));
220
218
  child.on('error', () => resolve('No git diff available'));
221
219
  });
222
220
  }
@@ -328,6 +328,91 @@ export function loadConfigSync() {
328
328
  }
329
329
  return {};
330
330
  }
331
+ /**
332
+ * Project-layer trust boundary (P1): `.klyro/settings*.json` ship with the
333
+ * repo, so a hostile checkout can plant them. API keys are NEVER accepted
334
+ * from project layers — keys load only from ~/.klyro, env, or flags. A
335
+ * project baseUrl pointing at a public host (which would receive the user's
336
+ * key + prompts) warns loudly; loopback/RFC1918 stay silent for shared
337
+ * local setups (Ollama etc.).
338
+ */
339
+ function isPublicHostUrl(raw) {
340
+ let host = '';
341
+ try {
342
+ host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
343
+ }
344
+ catch {
345
+ return true; // unparseable endpoint — treat as hostile, warn
346
+ }
347
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1')
348
+ return false;
349
+ if (host.endsWith('.localhost') || host.endsWith('.local'))
350
+ return false;
351
+ if (host.startsWith('127.'))
352
+ return false;
353
+ if (/^10\.\d+\.\d+\.\d+$/.test(host))
354
+ return false;
355
+ if (/^192\.168\.\d+\.\d+$/.test(host))
356
+ return false;
357
+ if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(host))
358
+ return false;
359
+ return true;
360
+ }
361
+ function scrubProjectLayer(obj, file) {
362
+ const warn = (msg) => {
363
+ try {
364
+ process.stderr.write(msg);
365
+ }
366
+ catch { /* ignore */ }
367
+ };
368
+ for (const k of ['apiKey', 'api_key']) {
369
+ if (obj[k] !== undefined) {
370
+ delete obj[k];
371
+ warn(`klyro: ignoring apiKey in project config ${file} — keys load only from ~/.klyro/settings.json, env (KLYRO_API_KEY), or --api-key\n`);
372
+ }
373
+ }
374
+ for (const k of ['baseUrl', 'baseURL']) {
375
+ const v = obj[k];
376
+ if (typeof v === 'string' && v.length > 0 && isPublicHostUrl(v)) {
377
+ warn(`klyro: warning: project config ${file} points the provider at ${v} — API requests (including your key) will go there\n`);
378
+ }
379
+ }
380
+ scrubProjectFailover(obj['providers'], file, warn);
381
+ return obj;
382
+ }
383
+ /**
384
+ * Failover entries from a project layer get the same trust treatment as the
385
+ * primary endpoint. Two reasons `apiKeyEnv` is dropped rather than warned
386
+ * about: (a) a fallback fires *without a prompt*, so a repo-chosen env var
387
+ * name would silently ship the user's credential to a repo-chosen host, and
388
+ * (b) the name can also cross providers (`provider:'openai'` + an Anthropic
389
+ * env var). A literal `apiKey` in project config is left alone — that is the
390
+ * repo's own key, not the user's secret — matching the accepted
391
+ * `providers.failover[].apiKey` behaviour. Keys for a project-authored
392
+ * fallback belong in ~/.klyro/settings.json.
393
+ */
394
+ function scrubProjectFailover(providers, file, warn) {
395
+ if (providers === null || typeof providers !== 'object')
396
+ return;
397
+ const failover = providers.failover;
398
+ if (!Array.isArray(failover))
399
+ return;
400
+ for (const entry of failover) {
401
+ if (entry === null || typeof entry !== 'object')
402
+ continue;
403
+ const e = entry;
404
+ if (e['apiKeyEnv'] !== undefined) {
405
+ delete e['apiKeyEnv'];
406
+ warn(`klyro: ignoring apiKeyEnv in project config ${file} — a repo must not choose which env var holds your key; configure that fallback in ~/.klyro/settings.json\n`);
407
+ }
408
+ for (const k of ['baseURL', 'baseUrl']) {
409
+ const v = e[k];
410
+ if (typeof v === 'string' && v.length > 0 && isPublicHostUrl(v)) {
411
+ warn(`klyro: warning: project config ${file} routes a failover provider to ${v} — API requests (including your key) will go there\n`);
412
+ }
413
+ }
414
+ }
415
+ }
331
416
  // --- Merged load with 5-layer precedence ---
332
417
  export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
333
418
  const layers = [];
@@ -351,7 +436,7 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
351
436
  try {
352
437
  const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.json'), 'utf-8');
353
438
  const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.json'));
354
- layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.json')));
439
+ layers.push(scrubProjectLayer(validateConfig(obj, path.join(cwd, '.klyro/settings.json')), path.join(cwd, '.klyro/settings.json')));
355
440
  }
356
441
  catch (err) {
357
442
  const e = err;
@@ -362,7 +447,7 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
362
447
  try {
363
448
  const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.local.json'), 'utf-8');
364
449
  const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.local.json'));
365
- layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.local.json')));
450
+ layers.push(scrubProjectLayer(validateConfig(obj, path.join(cwd, '.klyro/settings.local.json')), path.join(cwd, '.klyro/settings.local.json')));
366
451
  }
367
452
  catch (err) {
368
453
  const e = err;
@@ -1,3 +1,5 @@
1
1
  export declare function parseDotenv(text: string): Record<string, string>;
2
+ /** True when a `.env` key would change process behaviour or loosen a boundary. */
3
+ export declare function isBlockedDotenvKey(key: string): boolean;
2
4
  /** Load `<cwd>/.env` into `process.env` (no-clobber). Returns loaded keys. */
3
5
  export declare function loadDotenv(cwd: string): string[];
@@ -36,6 +36,69 @@ export function parseDotenv(text) {
36
36
  }
37
37
  return out;
38
38
  }
39
+ /**
40
+ * Process-control variables a repository `.env` must never set.
41
+ *
42
+ * `.env` ships WITH the repo, so it is untrusted content, and these keys do
43
+ * not configure Klyro — they change how the OS and node execute every child
44
+ * process Klyro spawns (MCP servers, hooks, verify commands, shell_exec),
45
+ * because children inherit `process.env`:
46
+ * - `NODE_OPTIONS=--require ./evil.js` injects a module into every node child
47
+ * - `PATH`/`PATHEXT`/`LD_PRELOAD`/`DYLD_*` hijack which binary actually runs
48
+ * - `EDITOR`/`VISUAL`/`PAGER` turn `klyro config edit` into a shell execution
49
+ * - `npm_config_*` redirects package installs to another registry
50
+ * Legitimate project config (KLYRO_*, provider keys, tool settings) is
51
+ * unaffected — only the launcher/process-control namespace is refused.
52
+ */
53
+ const BLOCKED_DOTENV_KEYS = new Set([
54
+ 'PATH',
55
+ 'PATHEXT',
56
+ 'COMSPEC',
57
+ 'SHELL',
58
+ 'SYSTEMROOT',
59
+ 'WINDIR',
60
+ 'NODE_OPTIONS',
61
+ 'NODE_PATH',
62
+ 'NODE_REPL_EXTERNAL_MODULE',
63
+ 'LD_PRELOAD',
64
+ 'LD_LIBRARY_PATH',
65
+ 'DYLD_INSERT_LIBRARIES',
66
+ 'DYLD_LIBRARY_PATH',
67
+ 'EDITOR',
68
+ 'VISUAL',
69
+ 'PAGER',
70
+ 'GIT_SSH',
71
+ 'GIT_SSH_COMMAND',
72
+ 'GIT_EXTERNAL_DIFF',
73
+ 'GIT_ASKPASS',
74
+ 'SSH_ASKPASS',
75
+ 'BASH_ENV',
76
+ 'PROMPT_COMMAND',
77
+ ]);
78
+ /**
79
+ * Klyro's own de-hardening switches. A repo may not loosen a security
80
+ * boundary for the person who clones it: these are documented as explicit
81
+ * user decisions (README "Environment"), so they are refused from `.env`
82
+ * and must come from the real environment or ~/.klyro/settings.json.
83
+ */
84
+ const BLOCKED_DOTENV_SECURITY_RELAXATIONS = new Set([
85
+ 'KLYRO_ALLOW_INSECURE',
86
+ 'KLYRO_CREDENTIALS_INSECURE_OK',
87
+ 'KLYRO_ALLOW_MAIN_PUSH',
88
+ 'KLYRO_YES',
89
+ 'KLYRO_WORKER',
90
+ ]);
91
+ /** npm reads `npm_config_*` from the environment — a repo must not pick the registry. */
92
+ const BLOCKED_DOTENV_PREFIXES = ['NPM_CONFIG_'];
93
+ /** True when a `.env` key would change process behaviour or loosen a boundary. */
94
+ export function isBlockedDotenvKey(key) {
95
+ const k = key.toUpperCase();
96
+ if (BLOCKED_DOTENV_KEYS.has(k))
97
+ return true;
98
+ if (BLOCKED_DOTENV_SECURITY_RELAXATIONS.has(k))
99
+ return true;
100
+ return BLOCKED_DOTENV_PREFIXES.some((p) => k.startsWith(p));
101
+ }
39
102
  /** Load `<cwd>/.env` into `process.env` (no-clobber). Returns loaded keys. */
40
103
  export function loadDotenv(cwd) {
41
104
  let text;
@@ -47,11 +110,22 @@ export function loadDotenv(cwd) {
47
110
  }
48
111
  const parsed = parseDotenv(text);
49
112
  const loaded = [];
113
+ const blocked = [];
50
114
  for (const [k, v] of Object.entries(parsed)) {
115
+ if (isBlockedDotenvKey(k)) {
116
+ blocked.push(k);
117
+ continue;
118
+ }
51
119
  if (process.env[k] === undefined) {
52
120
  process.env[k] = v;
53
121
  loaded.push(k);
54
122
  }
55
123
  }
124
+ if (blocked.length > 0) {
125
+ try {
126
+ process.stderr.write(`klyro: ignoring process-control vars in .env (${blocked.join(', ')}) — a repo's .env may not change how your shell, node, or editor runs\n`);
127
+ }
128
+ catch { /* ignore */ }
129
+ }
56
130
  return loaded;
57
131
  }
@@ -74,8 +74,39 @@ export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
74
74
  /**
75
75
  * Load hooks for a run. Global first, then project — a project hook with
76
76
  * the same `name` replaces the global one. Never throws.
77
+ *
78
+ * Project hooks are repo-authored and run with `shell: true`, so they are
79
+ * gated on trust (see `projectHooksTrusted`): a cloned repo must not execute
80
+ * code just because someone ran `klyro` in it, and an untrusted project can
81
+ * not shadow a global hook by name.
77
82
  */
78
83
  export declare function loadHooks(cwd: string): Hook[];
84
+ /** Absolute path of the project's hook file (may not exist). */
85
+ export declare function projectHooksPath(cwd: string): string;
86
+ /**
87
+ * Is the project's hook file allowed to run? Two paths:
88
+ * 1. `KLYRO_TRUST_PROJECT_HOOKS=1` — explicit per-shell opt-in, or
89
+ * 2. the file's current sha256 is pinned in `~/.klyro/trusted-hooks.json`
90
+ * (recorded by an explicit `klyro hooks trust`; any later edit changes
91
+ * the hash, so the file re-locks until reviewed again).
92
+ */
93
+ export declare function projectHooksTrusted(cwd: string): boolean;
94
+ /** Describe the project's hook file for the `klyro hooks` surface. */
95
+ export declare function projectHooksStatus(cwd: string): {
96
+ path: string;
97
+ exists: boolean;
98
+ trusted: boolean;
99
+ };
100
+ /**
101
+ * Trust (or re-trust) the project's hooks file at its current contents.
102
+ * Throws when there is no project hooks file.
103
+ */
104
+ export declare function trustProjectHooks(cwd: string): {
105
+ path: string;
106
+ hash: string;
107
+ };
108
+ /** Remove the project's hook file from the trust store. False when absent. */
109
+ export declare function untrustProjectHooks(cwd: string): boolean;
79
110
  export interface HookContext {
80
111
  toolName: string;
81
112
  input: unknown;
package/dist/cli/hooks.js CHANGED
@@ -28,10 +28,12 @@
28
28
  * stdin (`{ event, tool, input, sessionId, ... }`).
29
29
  */
30
30
  import { spawn } from 'node:child_process';
31
+ import { createHash } from 'node:crypto';
31
32
  import * as fs from 'node:fs';
32
33
  import * as os from 'node:os';
33
34
  import * as path from 'node:path';
34
35
  import { z } from 'zod';
36
+ import { stripBom } from '../shared/json.js';
35
37
  export const HookEventSchema = z.enum(['preToolUse', 'postToolUse', 'sessionStart', 'sessionEnd', 'stop']);
36
38
  export const HookSchema = z.object({
37
39
  name: z.string().min(1),
@@ -49,11 +51,28 @@ const MAX_HOOK_OUTPUT_CHARS = 4000;
49
51
  /** Paths already warned about (invalid JSON/schema) — warn once per path. */
50
52
  const warnedPaths = new Set();
51
53
  function warnOnce(filePath, detail) {
52
- if (warnedPaths.has(filePath))
54
+ warnOnceRaw(`invalid:${normWarnKey(filePath)}`, `klyro: hooks: ignoring invalid file ${filePath}: ${detail}\n`);
55
+ }
56
+ /**
57
+ * Warn-once keys normalize the file identity (resolved absolute path +
58
+ * NFKC) so the same content can't re-warn via `./x`, `X/../x`, case, or
59
+ * slash variants — each real file warns at most once.
60
+ */
61
+ function normWarnKey(filePath) {
62
+ try {
63
+ return path.resolve(filePath).normalize('NFKC');
64
+ }
65
+ catch {
66
+ return filePath;
67
+ }
68
+ }
69
+ /** One stderr line per unique key (used for trust notices too). */
70
+ function warnOnceRaw(key, message) {
71
+ if (warnedPaths.has(key))
53
72
  return;
54
- warnedPaths.add(filePath);
73
+ warnedPaths.add(key);
55
74
  try {
56
- process.stderr.write(`klyro: hooks: ignoring invalid file ${filePath}: ${detail}\n`);
75
+ process.stderr.write(message);
57
76
  }
58
77
  catch { /* ignore */ }
59
78
  }
@@ -68,7 +87,7 @@ function readHooksFile(filePath) {
68
87
  }
69
88
  let parsed;
70
89
  try {
71
- parsed = JSON.parse(raw);
90
+ parsed = JSON.parse(stripBom(raw));
72
91
  }
73
92
  catch (err) {
74
93
  warnOnce(filePath, err instanceof Error ? err.message : String(err));
@@ -93,6 +112,11 @@ function globalHooksPath() {
93
112
  /**
94
113
  * Load hooks for a run. Global first, then project — a project hook with
95
114
  * the same `name` replaces the global one. Never throws.
115
+ *
116
+ * Project hooks are repo-authored and run with `shell: true`, so they are
117
+ * gated on trust (see `projectHooksTrusted`): a cloned repo must not execute
118
+ * code just because someone ran `klyro` in it, and an untrusted project can
119
+ * not shadow a global hook by name.
96
120
  */
97
121
  export function loadHooks(cwd) {
98
122
  let out = [];
@@ -100,9 +124,16 @@ export function loadHooks(cwd) {
100
124
  const byName = new Map();
101
125
  for (const h of readHooksFile(globalHooksPath()))
102
126
  byName.set(h.name, h);
103
- const projectFile = path.join(cwd, '.klyro', 'hooks.json');
104
- for (const h of readHooksFile(projectFile))
105
- byName.set(h.name, h);
127
+ const projectFile = projectHooksPath(cwd);
128
+ if (fs.existsSync(projectFile)) {
129
+ if (projectHooksTrusted(cwd)) {
130
+ for (const h of readHooksFile(projectFile))
131
+ byName.set(h.name, h);
132
+ }
133
+ else {
134
+ warnOnceRaw(`untrusted:${normWarnKey(projectFile)}`, `klyro: hooks: project hooks ${projectFile} are NOT trusted — review the file, then run \`klyro hooks trust\` (or set KLYRO_TRUST_PROJECT_HOOKS=1)\n`);
135
+ }
136
+ }
106
137
  out = [...byName.values()];
107
138
  }
108
139
  catch {
@@ -110,6 +141,94 @@ export function loadHooks(cwd) {
110
141
  }
111
142
  return out;
112
143
  }
144
+ /** Absolute path of the project's hook file (may not exist). */
145
+ export function projectHooksPath(cwd) {
146
+ return path.join(cwd, '.klyro', 'hooks.json');
147
+ }
148
+ /** Hash-pinned trust store: `{ "<resolved absolute path>": "<sha256 hex>" }`. */
149
+ function trustStorePath() {
150
+ return path.join(os.homedir() || process.cwd(), '.klyro', 'trusted-hooks.json');
151
+ }
152
+ function sha256File(file) {
153
+ try {
154
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ function trustedHashes() {
161
+ try {
162
+ const parsed = JSON.parse(fs.readFileSync(trustStorePath(), 'utf-8'));
163
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
164
+ const out = {};
165
+ for (const [k, v] of Object.entries(parsed)) {
166
+ if (typeof v === 'string')
167
+ out[k] = v;
168
+ }
169
+ return out;
170
+ }
171
+ }
172
+ catch { /* missing or invalid — nothing trusted */ }
173
+ return {};
174
+ }
175
+ function writeTrustStore(store) {
176
+ const p = trustStorePath();
177
+ fs.mkdirSync(path.dirname(p), { recursive: true });
178
+ const tmp = `${p}.${process.pid}.tmp`;
179
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
180
+ fs.renameSync(tmp, p);
181
+ }
182
+ /**
183
+ * Is the project's hook file allowed to run? Two paths:
184
+ * 1. `KLYRO_TRUST_PROJECT_HOOKS=1` — explicit per-shell opt-in, or
185
+ * 2. the file's current sha256 is pinned in `~/.klyro/trusted-hooks.json`
186
+ * (recorded by an explicit `klyro hooks trust`; any later edit changes
187
+ * the hash, so the file re-locks until reviewed again).
188
+ */
189
+ export function projectHooksTrusted(cwd) {
190
+ if (process.env.KLYRO_TRUST_PROJECT_HOOKS === '1')
191
+ return true;
192
+ const file = projectHooksPath(cwd);
193
+ if (!fs.existsSync(file))
194
+ return false;
195
+ const hash = sha256File(file);
196
+ if (!hash)
197
+ return false;
198
+ const store = trustedHashes();
199
+ const resolved = path.resolve(file);
200
+ return store[resolved] === hash || store[file] === hash;
201
+ }
202
+ /** Describe the project's hook file for the `klyro hooks` surface. */
203
+ export function projectHooksStatus(cwd) {
204
+ const file = projectHooksPath(cwd);
205
+ const exists = fs.existsSync(file);
206
+ return { path: file, exists, trusted: exists && projectHooksTrusted(cwd) };
207
+ }
208
+ /**
209
+ * Trust (or re-trust) the project's hooks file at its current contents.
210
+ * Throws when there is no project hooks file.
211
+ */
212
+ export function trustProjectHooks(cwd) {
213
+ const file = projectHooksPath(cwd);
214
+ const hash = sha256File(file);
215
+ if (!hash)
216
+ throw new Error(`no project hooks file at ${file}`);
217
+ const store = trustedHashes();
218
+ store[path.resolve(file)] = hash;
219
+ writeTrustStore(store);
220
+ return { path: file, hash };
221
+ }
222
+ /** Remove the project's hook file from the trust store. False when absent. */
223
+ export function untrustProjectHooks(cwd) {
224
+ const resolved = path.resolve(projectHooksPath(cwd));
225
+ const store = trustedHashes();
226
+ if (store[resolved] === undefined)
227
+ return false;
228
+ delete store[resolved];
229
+ writeTrustStore(store);
230
+ return true;
231
+ }
113
232
  /**
114
233
  * Minimal filtered env for hook children (mirrors the shell_exec policy:
115
234
  * secrets stripped, only safe prefixes pass). Kept local so the hooks
package/dist/cli/repl.js CHANGED
@@ -2689,7 +2689,7 @@ export async function startRepl(opts = {}) {
2689
2689
  ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2690
2690
  ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2691
2691
  ' PgUp/PgDn or Ctrl+U/Ctrl+D half-page · Ctrl+Home/End top/bottom · Home/End jump · Space jump to unread',
2692
- ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑ history at live tail, scrolls while reading above · Ctrl+P/N history always · PgUp/Dn or Ctrl+U/D scroll (KLYRO_MOUSE=1 adds wheel ±3 lines)',
2692
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ and Ctrl+P/N browse input history only · PgUp/Dn or Ctrl+U/D scroll · wheel scrolls with KLYRO_MOUSE=1',
2693
2693
  ' Text selection/copy and right-click paste work natively · Shift+Enter newline · /vim toggles vim input mode · /keymap <note> saves a display note',
2694
2694
  ].join('\n'),
2695
2695
  });
package/dist/cli/run.js CHANGED
@@ -22,10 +22,6 @@ import { memoryBlock } from '../context/memory.js';
22
22
  import { estimateCost } from '../providers/model-info.js';
23
23
  import { resolveSessionId } from '../persistence/session.js';
24
24
  import * as fs from 'node:fs';
25
- function readEnv(name, fallback) {
26
- const v = process.env[name];
27
- return v && v.length > 0 ? v : fallback;
28
- }
29
25
  /**
30
26
  * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
31
27
  * within 1500ms of the first forces `process.exit(130)`. The live handler
@@ -75,10 +71,22 @@ export async function runOnce(opts) {
75
71
  });
76
72
  };
77
73
  let adapter = opts.adapter;
74
+ let chain = [];
78
75
  if (!adapter) {
79
- const provider = opts.provider ?? 'openai';
80
- const baseUrl = opts.baseUrl ?? readEnv('KLYRO_BASE_URL');
81
- const apiKey = opts.apiKey ?? readEnv('KLYRO_API_KEY');
76
+ // Single source of truth: the provider chain (flags > merged config >
77
+ // env > defaults) resolves the primary identically everywhere, so CLI
78
+ // flags (--provider/--api-key/--base-url) and every key env source apply
79
+ // to the primary and to each failover entry alike.
80
+ const { resolveProviderChain } = await import('./config.js');
81
+ chain = await resolveProviderChain(opts.cwd, {
82
+ ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
83
+ ...(opts.baseUrl !== undefined ? { baseUrl: opts.baseUrl } : {}),
84
+ ...(opts.apiKey !== undefined ? { apiKey: opts.apiKey } : {}),
85
+ });
86
+ const primary = chain[0];
87
+ const provider = primary.provider;
88
+ const baseUrl = primary.baseURL;
89
+ const apiKey = primary.apiKey;
82
90
  if (!apiKey) {
83
91
  stderr.write('klyro: KLYRO_API_KEY is not set (or pass --api-key)\n');
84
92
  return 2;
@@ -102,11 +110,11 @@ export async function runOnce(opts) {
102
110
  // L15 provider failover: extra chain entries (after the primary) become
103
111
  // fallback adapters for the runtime. Custom injected adapters (tests)
104
112
  // skip chain wiring. Failures resolving the chain never block the run.
113
+ // (Chain already resolved above with CLI flags; reuse it — no second
114
+ // resolution, no divergent key sources.)
105
115
  let failoverAdapters;
106
116
  if (!opts.adapter) {
107
117
  try {
108
- const { resolveProviderChain } = await import('./config.js');
109
- const chain = await resolveProviderChain(opts.cwd);
110
118
  const fallbacks = chain.slice(1);
111
119
  if (fallbacks.length > 0) {
112
120
  const built = [];
@@ -111,7 +111,8 @@ export function registerSessionCommands(program) {
111
111
  async function sessionImport(file) {
112
112
  let data;
113
113
  try {
114
- data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
114
+ const { stripBom } = await import('../shared/json.js');
115
+ data = JSON.parse(stripBom(await (await import('node:fs/promises')).readFile(file, 'utf-8')));
115
116
  }
116
117
  catch (err) {
117
118
  process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);