klyro 1.0.14 → 1.0.17

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.
Files changed (91) hide show
  1. package/README.md +14 -5
  2. package/dist/agent/anthropic-adapter.js +26 -1
  3. package/dist/agent/child-worker.js +15 -4
  4. package/dist/agent/provider-adapter.d.ts +7 -0
  5. package/dist/agent/provider-adapter.js +26 -1
  6. package/dist/agent/runtime.d.ts +7 -0
  7. package/dist/agent/runtime.js +18 -21
  8. package/dist/chat.js +16 -56
  9. package/dist/checkpoints/store.js +7 -9
  10. package/dist/cli/completion.js +0 -3
  11. package/dist/cli/config.js +87 -2
  12. package/dist/cli/doctor.d.ts +13 -0
  13. package/dist/cli/doctor.js +133 -1
  14. package/dist/cli/dotenv.d.ts +2 -0
  15. package/dist/cli/dotenv.js +74 -0
  16. package/dist/cli/errors.js +1 -1
  17. package/dist/cli/eval.js +1 -3
  18. package/dist/cli/hooks.d.ts +31 -0
  19. package/dist/cli/hooks.js +126 -7
  20. package/dist/cli/markdown.js +1 -1
  21. package/dist/cli/repl.js +13 -24
  22. package/dist/cli/run.d.ts +32 -0
  23. package/dist/cli/run.js +93 -17
  24. package/dist/cli/session-commands.js +2 -1
  25. package/dist/cli/update.js +23 -7
  26. package/dist/context/accounting.js +1 -1
  27. package/dist/context/level7.d.ts +4 -0
  28. package/dist/context/level7.js +20 -1
  29. package/dist/context/project-map.js +14 -13
  30. package/dist/eval/harness.d.ts +4 -3
  31. package/dist/eval/harness.js +16 -12
  32. package/dist/index.js +76 -5
  33. package/dist/mcp/client.js +12 -0
  34. package/dist/mcp/config.js +2 -1
  35. package/dist/mcp/serve.d.ts +24 -0
  36. package/dist/mcp/serve.js +41 -4
  37. package/dist/persistence/audit.js +8 -1
  38. package/dist/persistence/store.d.ts +2 -0
  39. package/dist/persistence/store.js +28 -2
  40. package/dist/policy/approval.js +3 -0
  41. package/dist/policy/path-guard.d.ts +9 -4
  42. package/dist/policy/path-guard.js +47 -23
  43. package/dist/policy/secret-redactor.js +2 -2
  44. package/dist/providers/endpoints.js +8 -0
  45. package/dist/providers/model-info.d.ts +1 -0
  46. package/dist/providers/model-info.js +19 -0
  47. package/dist/providers.js +6 -2
  48. package/dist/renderers/terminal.d.ts +7 -0
  49. package/dist/renderers/terminal.js +52 -0
  50. package/dist/repl.d.ts +9 -0
  51. package/dist/repl.js +24 -5
  52. package/dist/shared/index.d.ts +2 -0
  53. package/dist/shared/index.js +2 -0
  54. package/dist/shared/json.d.ts +7 -0
  55. package/dist/shared/json.js +9 -0
  56. package/dist/shared/output-cap.d.ts +30 -0
  57. package/dist/shared/output-cap.js +28 -0
  58. package/dist/shared/proxy.d.ts +37 -0
  59. package/dist/shared/proxy.js +360 -0
  60. package/dist/tools/agent/task-list.d.ts +1 -1
  61. package/dist/tools/fs/apply-patch.js +4 -4
  62. package/dist/tools/fs/edit-file.js +2 -4
  63. package/dist/tools/fs/list-dir.js +1 -1
  64. package/dist/tools/fs/multi-edit.js +2 -2
  65. package/dist/tools/fs/read-file.js +1 -1
  66. package/dist/tools/fs/write-file.js +2 -2
  67. package/dist/tools/lsp/diagnostics.js +2 -3
  68. package/dist/tools/plan/todo-write.d.ts +1 -1
  69. package/dist/tools/search/dependencies.d.ts +4 -4
  70. package/dist/tools/search/dependencies.js +1 -1
  71. package/dist/tools/search/glob.js +1 -1
  72. package/dist/tools/search/grep.js +1 -1
  73. package/dist/tools/search/recent-files.js +1 -1
  74. package/dist/tools/search/search-files.js +1 -1
  75. package/dist/tools/web/web-fetch.d.ts +9 -0
  76. package/dist/tools/web/web-fetch.js +44 -7
  77. package/dist/tools/web/web-search.js +3 -1
  78. package/dist/tui/app.js +85 -31
  79. package/dist/tui/app.test.js +54 -7
  80. package/dist/tui/measure.js +1 -0
  81. package/dist/tui/mouse.d.ts +7 -5
  82. package/dist/tui/mouse.js +9 -6
  83. package/dist/tui/scroll-flow.test.js +0 -1
  84. package/dist/util/log.d.ts +19 -10
  85. package/dist/util/log.js +87 -52
  86. package/dist/verification/baseline.js +10 -11
  87. package/dist/verification/classify.d.ts +1 -1
  88. package/dist/verification/classify.js +19 -10
  89. package/dist/verification/engine.js +9 -16
  90. package/dist/verification/scoped.js +16 -15
  91. package/package.json +6 -1
@@ -6,7 +6,7 @@
6
6
  import * as fs from 'node:fs/promises';
7
7
  import * as fsSync from 'node:fs';
8
8
  import * as path from 'node:path';
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, execFileSync } from 'node:child_process';
10
10
  import { getConfigPath, loadConfig } from './config.js';
11
11
  import { resolveProvider, providerHelp } from '../providers.js';
12
12
  import { getDefaultSessionsDir } from '../persistence/session.js';
@@ -120,6 +120,45 @@ async function checkSandbox() {
120
120
  return { name: 'Sandbox', ok: true, detail: 'none — policy+path guards only' };
121
121
  }
122
122
  }
123
+ function checkPackageManager() {
124
+ try {
125
+ const npm = execFileSync('npm', ['--version'], { encoding: 'utf-8', timeout: 5000 }).trim();
126
+ return { name: 'npm', ok: true, detail: `npm ${npm} ✓` };
127
+ }
128
+ catch {
129
+ return { name: 'npm', ok: false, detail: 'npm not on PATH — install Node ≥20' };
130
+ }
131
+ }
132
+ function checkPath() {
133
+ const pathVar = process.env.PATH ?? process.env.Path ?? '';
134
+ const nodeDir = process.execPath.includes('\\') || process.execPath.includes('/')
135
+ ? process.execPath.slice(0, Math.max(process.execPath.lastIndexOf('\\'), process.execPath.lastIndexOf('/')))
136
+ : '';
137
+ const onPath = !!pathVar && !!nodeDir && pathVar.toLowerCase().split(path.delimiter).some((p) => p.toLowerCase() === nodeDir.toLowerCase());
138
+ return { name: 'PATH', ok: true, detail: onPath ? `node dir on PATH ✓` : `node dir not on PATH (${nodeDir || 'unknown'}) — child shells may miss node` };
139
+ }
140
+ function checkRipgrep() {
141
+ try {
142
+ const v = execFileSync('rg', ['--version'], { encoding: 'utf-8', timeout: 5000 }).trim().split('\n')[0] ?? '';
143
+ return { name: 'ripgrep', ok: true, detail: `${v} ✓` };
144
+ }
145
+ catch {
146
+ return { name: 'ripgrep', ok: true, detail: 'not found — built-in grep tool is used instead' };
147
+ }
148
+ }
149
+ function checkTerminal() {
150
+ const isTTY = !!process.stdout.isTTY;
151
+ const term = process.env.TERM ?? '(unset)';
152
+ const cols = process.stdout.columns ?? 0;
153
+ return { name: 'Terminal', ok: true, detail: `${isTTY ? 'TTY' : 'non-TTY (headless/pipe mode)'}, TERM=${term}, cols=${cols}` };
154
+ }
155
+ function checkProxy() {
156
+ const vars = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']
157
+ .filter((k) => process.env[k] && process.env[k].length > 0);
158
+ if (vars.length === 0)
159
+ return { name: 'Proxy', ok: true, detail: 'no proxy env — direct connections' };
160
+ return { name: 'Proxy', ok: true, detail: `${vars.join(', ')} set — honored for provider/web/update fetches` };
161
+ }
123
162
  async function checkMcp(cwd) {
124
163
  try {
125
164
  const { loadMcpServers } = await import('../mcp/config.js');
@@ -169,14 +208,107 @@ async function checkTrust() {
169
208
  return { name: 'Trust stores', ok: true, detail: 'none' };
170
209
  }
171
210
  }
211
+ /**
212
+ * Pure byte interpreter for `doctor --keys` (unit-tested): names what a
213
+ * terminal delivered so broken arrow/paste/scroll input becomes provable
214
+ * fact instead of guesswork. Covers arrows, paging/home/end, Ctrl codes,
215
+ * bracketed-paste markers, and SGR/X10 mouse sequences.
216
+ */
217
+ export function describeKeyBytes(chunk) {
218
+ const s = chunk.toString('latin1');
219
+ switch (s) {
220
+ case '\x1b[A': return 'Up arrow (history prev)';
221
+ case '\x1b[B': return 'Down arrow (history next)';
222
+ case '\x1b[C': return 'Right arrow';
223
+ case '\x1b[D': return 'Left arrow';
224
+ case '\x1b[5~': return 'PageUp (scroll half-page up)';
225
+ case '\x1b[6~': return 'PageDown (scroll half-page down)';
226
+ case '\x1b[H':
227
+ case '\x1b[1~': return 'Home (jump top)';
228
+ case '\x1b[F':
229
+ case '\x1b[4~': return 'End (jump bottom)';
230
+ case '\x1b[Z': return 'Shift+Tab';
231
+ case '\r':
232
+ case '\n': return 'Enter';
233
+ case '\x7f': return 'Backspace';
234
+ case '\x1b': return 'Escape (lone — arrow/paste sequences arrive joined; lone ESC means the terminal split them or you pressed Esc)';
235
+ case '\x1b[200~': return 'Bracketed-paste START (terminal supports bulk paste)';
236
+ case '\x1b[201~': return 'Bracketed-paste END';
237
+ case '\x03': return 'Ctrl+C';
238
+ case '\x10': return 'Ctrl+P (history prev, escape-free)';
239
+ case '\x0e': return 'Ctrl+N (history next, escape-free)';
240
+ case '\x15': return 'Ctrl+U (scroll half-page up)';
241
+ case '\x04': return 'Ctrl+D (scroll half-page down)';
242
+ case '\x09': return 'Tab';
243
+ }
244
+ // eslint-disable-next-line no-control-regex -- intentional: parsing SGR mouse sequences requires ESC matching
245
+ if (/^\x1b\[<\d+;\d+;\d+[Mm]$/.test(s)) {
246
+ const cb = Number(s.slice(3).split(';')[0]);
247
+ return (cb & 64) !== 0
248
+ ? `Mouse wheel ${(cb & 1) === 0 ? 'up' : 'down'} (reporting active)`
249
+ : 'Mouse click/drag (reporting active — plain selection needs Shift+drag)';
250
+ }
251
+ // eslint-disable-next-line no-control-regex -- intentional: parsing X10 mouse sequences requires ESC matching
252
+ if (/^\x1b\[M...$/.test(s))
253
+ return 'Mouse event, X10 encoding (reporting active)';
254
+ if (/^[\x20-\x7e]+$/.test(s))
255
+ return `Printable text (${s.length} chars — typing/paste-as-typing works)`;
256
+ return 'Unrecognized sequence (terminal-specific — paste the bytes line into a bug report)';
257
+ }
258
+ /**
259
+ * Interactive key probe: raw-mode stdin echo of every chunk with its
260
+ * meaning. Proves what THIS terminal delivers for arrows, Ctrl+P/N,
261
+ * paste, and wheel — run it when TUI input misbehaves.
262
+ */
263
+ export async function runKeysProbe() {
264
+ const stdin = process.stdin;
265
+ const stdout = process.stdout;
266
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
267
+ process.stderr.write('klyro: doctor --keys needs an interactive terminal (stdin is not a TTY)\n');
268
+ return 2;
269
+ }
270
+ stdout.write('klyro doctor --keys — press keys, see exactly what your terminal sends.\n');
271
+ stdout.write('Try: ↑ ↓ PgUp PgDn Home End Ctrl+P Ctrl+N, then paste text, then scroll the wheel. Ctrl+C quits.\n');
272
+ stdin.setRawMode(true);
273
+ stdin.resume();
274
+ try {
275
+ await new Promise((resolve) => {
276
+ const onData = (chunk) => {
277
+ const bytes = [...chunk].map((b) => b.toString(16).padStart(2, '0')).join(' ');
278
+ stdout.write(`bytes: ${bytes} → ${describeKeyBytes(chunk)}\n`);
279
+ if (chunk.length === 1 && chunk[0] === 0x03) {
280
+ stdin.off('data', onData);
281
+ resolve();
282
+ }
283
+ };
284
+ stdin.on('data', onData);
285
+ });
286
+ }
287
+ finally {
288
+ try {
289
+ stdin.setRawMode(false);
290
+ }
291
+ catch { /* ignore */ }
292
+ try {
293
+ stdin.pause();
294
+ }
295
+ catch { /* ignore */ }
296
+ }
297
+ return 0;
298
+ }
172
299
  export async function runDoctor(opts = {}) {
173
300
  const cwd = opts.cwd ?? process.cwd();
174
301
  const checks = [];
175
302
  checks.push(checkNode());
303
+ checks.push(checkPackageManager());
304
+ checks.push(checkPath());
176
305
  checks.push(await checkConfig());
177
306
  checks.push(await checkProvider());
178
307
  checks.push(await checkSessions());
179
308
  checks.push(await checkGit());
309
+ checks.push(checkRipgrep());
310
+ checks.push(checkTerminal());
311
+ checks.push(checkProxy());
180
312
  checks.push(await checkTools());
181
313
  checks.push(checkPlatform());
182
314
  checks.push(await checkSandbox());
@@ -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
  }
@@ -5,7 +5,7 @@ import { KlyroError } from '../shared/errors.js';
5
5
  export function handleFatal(err) {
6
6
  const isDebug = !!process.env.KLYRO_LOG_LEVEL || !!process.env.DEBUG || process.argv.includes('--debug');
7
7
  let code = 1;
8
- let message = err instanceof Error ? err.message : String(err);
8
+ const message = err instanceof Error ? err.message : String(err);
9
9
  let hint;
10
10
  if (err instanceof KlyroError) {
11
11
  code = err.exitCode;
package/dist/cli/eval.js CHANGED
@@ -65,13 +65,11 @@ export async function runEval(opts) {
65
65
  }
66
66
  // 5.4 — suite mode: load from evals/fixtures
67
67
  if (opts.suite) {
68
- const { runHarness, loadFileFixture } = await import('../eval/harness.js');
69
68
  const fs = await import('node:fs/promises');
70
69
  const path = await import('node:path');
71
- const suiteDir = path.join(process.cwd(), 'evals', 'fixtures', opts.suite === 'smoke' ? '' : opts.suite);
72
70
  // For smoke, use the 10 fixtures directly
73
71
  const fixturesDir = path.join(process.cwd(), 'evals', 'fixtures');
74
- let tasks = [];
72
+ const tasks = [];
75
73
  try {
76
74
  const entries = await fs.readdir(fixturesDir);
77
75
  for (const e of entries) {
@@ -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
@@ -38,7 +38,7 @@ export function renderMarkdown(md, opts = {}) {
38
38
  const lines = md.split('\n');
39
39
  let inCodeBlock = false;
40
40
  let fenceLang = '';
41
- for (let line of lines) {
41
+ for (const line of lines) {
42
42
  if (line.startsWith('```')) {
43
43
  if (!inCodeBlock)
44
44
  fenceLang = line.replace(/^```/, '').trim().toLowerCase();
package/dist/cli/repl.js CHANGED
@@ -103,6 +103,7 @@ export async function startRepl(opts = {}) {
103
103
  // sanitizeForPrompt in sibling-owned policy/approval.ts — intentionally
104
104
  // not imported; kept local to avoid coupling the REPL to that module).
105
105
  function sanitizeForPrompt(s) {
106
+ // eslint-disable-next-line no-control-regex -- intentional: stripping C0 controls + CSI sequences
106
107
  return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g, '').slice(0, 120);
107
108
  }
108
109
  async function loadMcpTools() {
@@ -190,7 +191,7 @@ export async function startRepl(opts = {}) {
190
191
  // become fallback adapters for agent runs in this session. Live
191
192
  // /provider switches rebuild a single adapter (buildAdapter) and bypass
192
193
  // the chain — documented, not a bug.
193
- let replFailoverAdapters = [];
194
+ const replFailoverAdapters = [];
194
195
  try {
195
196
  const { resolveProviderChain } = await import('./config.js');
196
197
  const chain = await resolveProviderChain(cwd);
@@ -372,11 +373,12 @@ export async function startRepl(opts = {}) {
372
373
  // ── Full-screen takeover like OpenCode — always when klyro in TTY (user explicitly wants it)
373
374
  // Scroll now works correctly via internal viewport, not native terminal scroll
374
375
  const isAltScreen = useTui && !!process.stdout.isTTY && process.env.KLYRO_NO_ALT !== '1';
375
- // Mouse reporting is opt-in (KLYRO_MOUSE=1): when on, the terminal sends
376
- // clicks/wheel to the app (wheel scrolls ±3 lines) but native text
377
- // selection and right-click paste stop working. Default off so select to
378
- // copy and right-click paste work out of the box; bracketed paste
379
- // (keyboard paste) is unaffected and always enabled below.
376
+ // Mouse reporting is ON by default (opencode parity): the wheel scrolls
377
+ // the chat, clicks are swallowed by the app. That means plain mouse
378
+ // selection/right-click go to the app, not the terminal — use Shift+drag
379
+ // to select and Shift+right-click to paste, or set KLYRO_MOUSE=0 for fully
380
+ // native selection (then scroll with PgUp/PgDn, Ctrl+U/D, Shift+↑/↓,
381
+ // Home/End, Space). Bracketed (keyboard) paste is unaffected and always on.
380
382
  const mouseReporting = isAltScreen && isMouseReportingEnabled();
381
383
  const enterAlt = () => {
382
384
  if (!isAltScreen)
@@ -501,8 +503,9 @@ export async function startRepl(opts = {}) {
501
503
  if (consoleRing.length > 200)
502
504
  consoleRing.splice(0, consoleRing.length - 200);
503
505
  try {
506
+ // KLYRO_LOG_DIR relocates the debug log (1.5); default is ~/.klyro.
504
507
  const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
505
- const dir = nodePath.join(home, '.klyro');
508
+ const dir = process.env.KLYRO_LOG_DIR ?? nodePath.join(home, '.klyro');
506
509
  fsSync.mkdirSync(dir, { recursive: true });
507
510
  const logFile = nodePath.join(dir, 'debug.log');
508
511
  // Rotation: never let the debug log grow without bound (2 MiB cap).
@@ -724,6 +727,7 @@ export async function startRepl(opts = {}) {
724
727
  }
725
728
  catch { /* silent */ }
726
729
  })();
730
+ // eslint-disable-next-line prefer-const -- single assignment, but `let` is required: declaration precedes this for TDZ-safe handler wiring.
727
731
  app = render(React.createElement(App, {
728
732
  initialModel: model,
729
733
  maxSteps: currentMaxSteps,
@@ -778,6 +782,7 @@ export async function startRepl(opts = {}) {
778
782
  }), { patchConsole: false });
779
783
  // Install SIGINT handler only after app exists (avoids TDZ) and use once
780
784
  // On Ctrl+C in alt-screen, leave alt before exit so shell is restored
785
+ // eslint-disable-next-line prefer-const -- see above: declaration order is load-bearing.
781
786
  sigintHandler = () => {
782
787
  ac.abort();
783
788
  queuedStatus({ status: 'aborted' });
@@ -861,9 +866,6 @@ export async function startRepl(opts = {}) {
861
866
  return;
862
867
  }
863
868
  }
864
- let activeCallId = null;
865
- let activeCallName = null;
866
- let activeCallArgs = '';
867
869
  let runEndStatus = 'error';
868
870
  try {
869
871
  const result = await run({
@@ -902,14 +904,6 @@ export async function startRepl(opts = {}) {
902
904
  else if (ev.kind === 'checkpoint_saved') {
903
905
  queuedStatus({ status: 'running' });
904
906
  }
905
- else if (ev.kind === 'tool_call_start') {
906
- activeCallId = ev.id;
907
- activeCallName = ev.name;
908
- activeCallArgs = '';
909
- }
910
- else if (ev.kind === 'tool_call_delta') {
911
- activeCallArgs += ev.argsJson;
912
- }
913
907
  else if (ev.kind === 'tool_call_end') {
914
908
  pendingToolIds.add(ev.id);
915
909
  queuedAppend({
@@ -920,9 +914,6 @@ export async function startRepl(opts = {}) {
920
914
  args: JSON.stringify(ev.input, null, 2),
921
915
  status: 'running',
922
916
  });
923
- activeCallId = null;
924
- activeCallName = null;
925
- activeCallArgs = '';
926
917
  }
927
918
  else if (ev.kind === 'policy_decision') {
928
919
  queuedAppend({
@@ -1354,8 +1345,6 @@ export async function startRepl(opts = {}) {
1354
1345
  }
1355
1346
  case 'context': {
1356
1347
  const { renderContextBreakdown } = await import('./context.js');
1357
- // use last transcript via closure? approximate with empty
1358
- const { accounting } = await import('../context/accounting.js');
1359
1348
  const sys = ''; // system prompt approx
1360
1349
  const breakdown = renderContextBreakdown(sys, []);
1361
1350
  queuedAppend({ id: `ctx-${Date.now()}`, kind: 'text', text: breakdown, role: 'assistant' });
@@ -2689,7 +2678,7 @@ export async function startRepl(opts = {}) {
2689
2678
  ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2690
2679
  ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2691
2680
  ' 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)',
2681
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ and Ctrl+P/N browse input history only · wheel scrolls · PgUp/Dn or Ctrl+U/D scroll · KLYRO_MOUSE=0 for native selection',
2693
2682
  ' Text selection/copy and right-click paste work natively · Shift+Enter newline · /vim toggles vim input mode · /keymap <note> saves a display note',
2694
2683
  ].join('\n'),
2695
2684
  });