klyro 1.0.13 → 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 +14 -5
- package/dist/agent/child-worker.js +15 -4
- package/dist/checkpoints/store.js +7 -9
- package/dist/cli/config.js +87 -2
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.js +74 -0
- package/dist/cli/hooks.d.ts +31 -0
- package/dist/cli/hooks.js +126 -7
- package/dist/cli/repl.js +1 -1
- package/dist/cli/run.js +17 -9
- package/dist/cli/session-commands.js +2 -1
- package/dist/cli/update.js +16 -6
- package/dist/eval/harness.d.ts +3 -2
- package/dist/eval/harness.js +14 -10
- package/dist/index.js +24 -4
- 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 +26 -0
- package/dist/policy/secret-redactor.js +2 -2
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -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/tools/web/web-fetch.d.ts +9 -0
- package/dist/tools/web/web-fetch.js +42 -6
- package/dist/tui/app.js +11 -24
- package/dist/tui/app.test.js +35 -6
- package/dist/verification/baseline.js +10 -11
- package/dist/verification/classify.js +17 -8
- package/dist/verification/engine.js +9 -10
- package/dist/verification/scoped.js +10 -11
- package/package.json +1 -1
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/
|
|
78
|
-
| [`
|
|
79
|
-
| [`
|
|
80
|
-
| [`
|
|
81
|
-
| [`
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
218
|
-
child.stdout.on('data', (b) =>
|
|
219
|
-
child.on('close', () => resolve(
|
|
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
|
}
|
package/dist/cli/config.js
CHANGED
|
@@ -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;
|
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/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/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 ·
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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`);
|