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.
- package/README.md +14 -5
- package/dist/agent/anthropic-adapter.js +26 -1
- package/dist/agent/child-worker.js +15 -4
- package/dist/agent/provider-adapter.d.ts +7 -0
- package/dist/agent/provider-adapter.js +26 -1
- package/dist/agent/runtime.d.ts +7 -0
- package/dist/agent/runtime.js +18 -21
- package/dist/chat.js +16 -56
- package/dist/checkpoints/store.js +7 -9
- package/dist/cli/completion.js +0 -3
- package/dist/cli/config.js +87 -2
- package/dist/cli/doctor.d.ts +13 -0
- package/dist/cli/doctor.js +133 -1
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.js +74 -0
- package/dist/cli/errors.js +1 -1
- package/dist/cli/eval.js +1 -3
- package/dist/cli/hooks.d.ts +31 -0
- package/dist/cli/hooks.js +126 -7
- package/dist/cli/markdown.js +1 -1
- package/dist/cli/repl.js +13 -24
- package/dist/cli/run.d.ts +32 -0
- package/dist/cli/run.js +93 -17
- package/dist/cli/session-commands.js +2 -1
- package/dist/cli/update.js +23 -7
- package/dist/context/accounting.js +1 -1
- package/dist/context/level7.d.ts +4 -0
- package/dist/context/level7.js +20 -1
- package/dist/context/project-map.js +14 -13
- package/dist/eval/harness.d.ts +4 -3
- package/dist/eval/harness.js +16 -12
- package/dist/index.js +76 -5
- package/dist/mcp/client.js +12 -0
- package/dist/mcp/config.js +2 -1
- package/dist/mcp/serve.d.ts +24 -0
- package/dist/mcp/serve.js +41 -4
- package/dist/persistence/audit.js +8 -1
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +28 -2
- package/dist/policy/approval.js +3 -0
- package/dist/policy/path-guard.d.ts +9 -4
- package/dist/policy/path-guard.js +47 -23
- package/dist/policy/secret-redactor.js +2 -2
- package/dist/providers/endpoints.js +8 -0
- package/dist/providers/model-info.d.ts +1 -0
- package/dist/providers/model-info.js +19 -0
- package/dist/providers.js +6 -2
- package/dist/renderers/terminal.d.ts +7 -0
- package/dist/renderers/terminal.js +52 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +24 -5
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/json.d.ts +7 -0
- package/dist/shared/json.js +9 -0
- package/dist/shared/output-cap.d.ts +30 -0
- package/dist/shared/output-cap.js +28 -0
- package/dist/shared/proxy.d.ts +37 -0
- package/dist/shared/proxy.js +360 -0
- package/dist/tools/agent/task-list.d.ts +1 -1
- package/dist/tools/fs/apply-patch.js +4 -4
- package/dist/tools/fs/edit-file.js +2 -4
- package/dist/tools/fs/list-dir.js +1 -1
- package/dist/tools/fs/multi-edit.js +2 -2
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/lsp/diagnostics.js +2 -3
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/search/dependencies.d.ts +4 -4
- package/dist/tools/search/dependencies.js +1 -1
- package/dist/tools/search/glob.js +1 -1
- package/dist/tools/search/grep.js +1 -1
- package/dist/tools/search/recent-files.js +1 -1
- package/dist/tools/search/search-files.js +1 -1
- package/dist/tools/web/web-fetch.d.ts +9 -0
- package/dist/tools/web/web-fetch.js +44 -7
- package/dist/tools/web/web-search.js +3 -1
- package/dist/tui/app.js +85 -31
- package/dist/tui/app.test.js +54 -7
- package/dist/tui/measure.js +1 -0
- package/dist/tui/mouse.d.ts +7 -5
- package/dist/tui/mouse.js +9 -6
- package/dist/tui/scroll-flow.test.js +0 -1
- package/dist/util/log.d.ts +19 -10
- package/dist/util/log.js +87 -52
- package/dist/verification/baseline.js +10 -11
- package/dist/verification/classify.d.ts +1 -1
- package/dist/verification/classify.js +19 -10
- package/dist/verification/engine.js +9 -16
- package/dist/verification/scoped.js +16 -15
- package/package.json +6 -1
package/dist/cli/doctor.js
CHANGED
|
@@ -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());
|
package/dist/cli/dotenv.d.ts
CHANGED
|
@@ -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[];
|
package/dist/cli/dotenv.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
+
const tasks = [];
|
|
75
73
|
try {
|
|
76
74
|
const entries = await fs.readdir(fixturesDir);
|
|
77
75
|
for (const e of entries) {
|
package/dist/cli/hooks.d.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
73
|
+
warnedPaths.add(key);
|
|
55
74
|
try {
|
|
56
|
-
process.stderr.write(
|
|
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 =
|
|
104
|
-
|
|
105
|
-
|
|
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/markdown.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
376
|
-
// clicks
|
|
377
|
-
// selection
|
|
378
|
-
//
|
|
379
|
-
//
|
|
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 ·
|
|
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
|
});
|