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/dist/cli/update.js
CHANGED
|
@@ -112,9 +112,11 @@ export async function checkForUpdate(current) {
|
|
|
112
112
|
const latest = json.version ?? '';
|
|
113
113
|
// Downgrade protection: only ever recommend a strictly newer version.
|
|
114
114
|
// A registry answering with an older-or-equal `latest` (stale mirror,
|
|
115
|
-
// cache poisoning, downgrade attack)
|
|
115
|
+
// cache poisoning, downgrade attack) — or a non-semver tag like
|
|
116
|
+
// "latest"/"next" (which would otherwise flow into `npm i -g klyro@…`)
|
|
117
|
+
// — is treated as "no update".
|
|
116
118
|
const cmp = compareSemver(latest, current);
|
|
117
|
-
const isNewer = cmp
|
|
119
|
+
const isNewer = cmp !== null && cmp > 0;
|
|
118
120
|
if (latest && isNewer) {
|
|
119
121
|
// Verify the tarball's integrity before caching/recommending this version.
|
|
120
122
|
const verRes = await fetchWithTimeout(`${REGISTRY_BASE}/${encodeURIComponent(latest)}`);
|
|
@@ -126,9 +128,9 @@ export async function checkForUpdate(current) {
|
|
|
126
128
|
await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
|
|
127
129
|
return latest;
|
|
128
130
|
}
|
|
129
|
-
if (latest && cmp
|
|
130
|
-
// Refresh the negative cache so a poisoned answer isn't
|
|
131
|
-
// every invocation for the next 24h.
|
|
131
|
+
if (latest && (cmp === null || cmp <= 0)) {
|
|
132
|
+
// Refresh the negative cache so a poisoned or non-semver answer isn't
|
|
133
|
+
// re-fetched every invocation for the next 24h.
|
|
132
134
|
await fs.mkdir(path.dirname(cache), { recursive: true }).catch(() => undefined);
|
|
133
135
|
await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest: current }), 'utf-8').catch(() => undefined);
|
|
134
136
|
}
|
|
@@ -139,7 +141,6 @@ export async function checkForUpdate(current) {
|
|
|
139
141
|
return null;
|
|
140
142
|
}
|
|
141
143
|
export async function runUpdate(opts = {}) {
|
|
142
|
-
const here = await import('../index.js').then(() => '');
|
|
143
144
|
// Get version from package.json via dynamic import
|
|
144
145
|
const { readFileSync } = await import('node:fs');
|
|
145
146
|
const { resolve, dirname } = await import('node:path');
|
|
@@ -152,6 +153,15 @@ export async function runUpdate(opts = {}) {
|
|
|
152
153
|
if (latest) {
|
|
153
154
|
process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
|
|
154
155
|
if (opts.apply) {
|
|
156
|
+
// Defense in depth: the version string flows into a child process
|
|
157
|
+
// (with shell:true on Windows), so re-validate strict semver here
|
|
158
|
+
// even though checkForUpdate already gates on it. A non-semver
|
|
159
|
+
// string (registry compromise, cache tampering) must never reach
|
|
160
|
+
// the shell.
|
|
161
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(latest)) {
|
|
162
|
+
process.stderr.write(`klyro update: refusing to install non-semver version: ${latest}\n`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
155
165
|
// Opt-in self-apply: the tarball was already hash-verified by
|
|
156
166
|
// checkForUpdate, so npm installs exactly the verified version.
|
|
157
167
|
process.stdout.write(`Applying update to klyro@${latest}...\n`);
|
package/dist/eval/harness.d.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* expected tool-call sequence, (b) the verification engine detects the
|
|
5
5
|
* right failure type, and (c) the compressor preserves the goal.
|
|
6
6
|
*
|
|
7
|
-
* This is the MVP gate per
|
|
8
|
-
* reproducible suite of programmatic tasks
|
|
7
|
+
* This is the MVP gate per `plan.md` (§10, root of the repo — there is no
|
|
8
|
+
* `docs/plan.md`): a reproducible suite of programmatic tasks that later
|
|
9
|
+
* graduated into the fixture-backed file harness below.
|
|
9
10
|
*/
|
|
10
11
|
import type { ProviderAdapter, StreamEvent } from '../agent/provider-adapter.js';
|
|
11
12
|
export interface ScriptedTask {
|
package/dist/eval/harness.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* expected tool-call sequence, (b) the verification engine detects the
|
|
5
5
|
* right failure type, and (c) the compressor preserves the goal.
|
|
6
6
|
*
|
|
7
|
-
* This is the MVP gate per
|
|
8
|
-
* reproducible suite of programmatic tasks
|
|
7
|
+
* This is the MVP gate per `plan.md` (§10, root of the repo — there is no
|
|
8
|
+
* `docs/plan.md`): a reproducible suite of programmatic tasks that later
|
|
9
|
+
* graduated into the fixture-backed file harness below.
|
|
9
10
|
*/
|
|
10
11
|
import { run } from '../agent/runtime.js';
|
|
12
|
+
import { cappedOutput } from '../shared/output-cap.js';
|
|
13
|
+
/** Bounded capture for `check.sh` output — fixtures are project-authored. */
|
|
14
|
+
const MAX_CHECK_BYTES = 64 * 1024;
|
|
11
15
|
import { ToolRegistry } from '../tools/registry.js';
|
|
12
16
|
import { readFileTool } from '../tools/fs/read-file.js';
|
|
13
17
|
import { writeFileTool } from '../tools/fs/write-file.js';
|
|
@@ -189,15 +193,15 @@ export async function runFileFixture(fixture, opts = {}) {
|
|
|
189
193
|
const { spawn } = await import('node:child_process');
|
|
190
194
|
const result = await new Promise((resolve) => {
|
|
191
195
|
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
192
|
-
|
|
193
|
-
child.stdout?.on('data', (b) =>
|
|
194
|
-
child.stderr?.on('data', (b) =>
|
|
196
|
+
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
197
|
+
child.stdout?.on('data', (b) => sink.push(b));
|
|
198
|
+
child.stderr?.on('data', (b) => sink.push(b));
|
|
195
199
|
child.on('close', (code) => {
|
|
196
200
|
const pass = code === 0;
|
|
197
201
|
resolve({
|
|
198
202
|
id: path.basename(fixture.dir),
|
|
199
203
|
status: pass ? 'pass' : 'fail',
|
|
200
|
-
details:
|
|
204
|
+
details: sink.text().slice(0, 500),
|
|
201
205
|
observedStatus: pass ? 'complete' : 'verify_failed',
|
|
202
206
|
durationMs: Date.now() - start,
|
|
203
207
|
});
|
|
@@ -247,10 +251,10 @@ export async function runAgentFixture(fixture, opts = {}) {
|
|
|
247
251
|
const { spawn } = await import('node:child_process');
|
|
248
252
|
const out = await new Promise((resolve) => {
|
|
249
253
|
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
250
|
-
|
|
251
|
-
child.stdout?.on('data', (b) =>
|
|
252
|
-
child.stderr?.on('data', (b) =>
|
|
253
|
-
child.on('close', (code) => resolve(`exit=${code ?? -1} ${
|
|
254
|
+
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
255
|
+
child.stdout?.on('data', (b) => sink.push(b));
|
|
256
|
+
child.stderr?.on('data', (b) => sink.push(b));
|
|
257
|
+
child.on('close', (code) => resolve(`exit=${code ?? -1} ${sink.text().slice(0, 500)}`));
|
|
254
258
|
child.on('error', (err) => resolve(`spawn-error: ${String(err)}`));
|
|
255
259
|
});
|
|
256
260
|
if (!out.startsWith('exit=0')) {
|
package/dist/index.js
CHANGED
|
@@ -562,13 +562,33 @@ async function main() {
|
|
|
562
562
|
const code = await serveStdio(process.cwd());
|
|
563
563
|
process.exit(code);
|
|
564
564
|
});
|
|
565
|
-
// 10.2 — Hooks: list
|
|
566
|
-
program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks`
|
|
565
|
+
// 10.2 — Hooks: list, or trust/untrust the project hooks file.
|
|
566
|
+
program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` lists them; `klyro hooks trust` pins the project hooks file so it runs').action(async (cmd) => {
|
|
567
|
+
const { loadHooks, trustProjectHooks, untrustProjectHooks, projectHooksStatus } = await import('./cli/hooks.js');
|
|
568
|
+
if (cmd === 'trust') {
|
|
569
|
+
try {
|
|
570
|
+
const { path: p, hash } = trustProjectHooks(process.cwd());
|
|
571
|
+
process.stdout.write(`trusted project hooks: ${p} (sha256 ${hash.slice(0, 12)}…, re-locks if edited)\n`);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
process.stderr.write(`klyro: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
575
|
+
process.exit(2);
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (cmd === 'untrust') {
|
|
580
|
+
const ok = untrustProjectHooks(process.cwd());
|
|
581
|
+
process.stdout.write(ok ? 'project hooks are no longer trusted\n' : 'project hooks were not trusted\n');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
567
584
|
if (cmd && cmd !== 'list') {
|
|
568
|
-
process.stderr.write(`klyro: unknown hooks command: ${cmd} (usage: klyro hooks [list])\n`);
|
|
585
|
+
process.stderr.write(`klyro: unknown hooks command: ${cmd} (usage: klyro hooks [list|trust|untrust])\n`);
|
|
569
586
|
process.exit(2);
|
|
570
587
|
}
|
|
571
|
-
const
|
|
588
|
+
const st = projectHooksStatus(process.cwd());
|
|
589
|
+
if (st.exists) {
|
|
590
|
+
process.stdout.write(`project hooks: ${st.path} [${st.trusted ? 'trusted' : 'NOT TRUSTED — review, then run `klyro hooks trust`'}]\n`);
|
|
591
|
+
}
|
|
572
592
|
const hooks = loadHooks(process.cwd());
|
|
573
593
|
if (hooks.length === 0) {
|
|
574
594
|
process.stdout.write('hooks: none configured (.klyro/hooks.json, ~/.klyro/hooks.json)\n');
|
package/dist/mcp/client.js
CHANGED
|
@@ -26,6 +26,13 @@ const MAX_STDERR_BYTES = 8192;
|
|
|
26
26
|
const CONNECT_TIMEOUT_MS = 15_000;
|
|
27
27
|
/** Grace period between SIGTERM and SIGKILL in close(). */
|
|
28
28
|
const CLOSE_SIGKILL_AFTER_MS = 2000;
|
|
29
|
+
/**
|
|
30
|
+
* A single JSON-RPC frame (one newline-terminated line) is small. A server
|
|
31
|
+
* that streams megabytes with no frame boundary is broken or hostile; drop
|
|
32
|
+
* the partial line instead of buffering it without bound. The affected call
|
|
33
|
+
* then fails with its normal TIMEOUT instead of taking the harness down.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_STDOUT_FRAME_BYTES = 8 * 1024 * 1024;
|
|
29
36
|
export class McpClient {
|
|
30
37
|
name;
|
|
31
38
|
spec;
|
|
@@ -233,6 +240,11 @@ export class McpClient {
|
|
|
233
240
|
}
|
|
234
241
|
onStdout(chunk) {
|
|
235
242
|
this.stdoutBuf += chunk;
|
|
243
|
+
if (this.stdoutBuf.length > MAX_STDOUT_FRAME_BYTES && this.stdoutBuf.indexOf('\n') === -1) {
|
|
244
|
+
// No frame boundary in megabytes: discard rather than grow the heap.
|
|
245
|
+
this.stdoutBuf = '';
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
236
248
|
let idx;
|
|
237
249
|
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
238
250
|
const line = this.stdoutBuf.slice(0, idx).trim();
|
package/dist/mcp/config.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as fs from 'node:fs';
|
|
|
12
12
|
import * as os from 'node:os';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { stripBom } from '../shared/json.js';
|
|
15
16
|
/** MCP remote URL guard — https:// (or loopback http://) only. */
|
|
16
17
|
export function assertSafeMcpUrl(url) {
|
|
17
18
|
let parsed;
|
|
@@ -125,7 +126,7 @@ function normalizeRaw(raw) {
|
|
|
125
126
|
}
|
|
126
127
|
function readJsonFile(p) {
|
|
127
128
|
try {
|
|
128
|
-
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
129
|
+
return JSON.parse(stripBom(fs.readFileSync(p, 'utf-8')));
|
|
129
130
|
}
|
|
130
131
|
catch {
|
|
131
132
|
return undefined;
|
package/dist/mcp/serve.d.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `klyro mcp serve` — expose the builtin tool registry as a minimal MCP
|
|
3
|
+
* server over stdio (newline-delimited JSON-RPC 2.0).
|
|
4
|
+
*
|
|
5
|
+
* Supported methods: `initialize`, `ping`, `tools/list`, `tools/call`.
|
|
6
|
+
* Every call is gated by the PolicyEngine (default config + builtin rules)
|
|
7
|
+
* in headless mode: `allow` runs, `deny`/`ask` are refused as tool errors
|
|
8
|
+
* (serve cannot prompt, so nothing privileged runs without an explicit
|
|
9
|
+
* allow rule) — serving never bypasses policy.
|
|
10
|
+
*/
|
|
1
11
|
import { type ToolRegistry } from '../tools/registry.js';
|
|
2
12
|
import type { ToolContext } from '../tools/types.js';
|
|
3
13
|
import { PolicyEngine } from '../policy/engine.js';
|
|
14
|
+
/**
|
|
15
|
+
* Max bytes of one JSON-RPC line (defense in depth: readline-style
|
|
16
|
+
* unbounded buffering lets one giant message OOM the server).
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_MCP_LINE_BYTES: number;
|
|
19
|
+
/**
|
|
20
|
+
* Bounded newline-delimited reader. Lines longer than maxBytes are dropped
|
|
21
|
+
* (reported once as `{ tooLong: true }`) and the reader resyncs at the next
|
|
22
|
+
* newline; callers answer oversized messages with a JSON-RPC error instead
|
|
23
|
+
* of parsing unbounded input.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readBoundedLines(input: NodeJS.ReadableStream, maxBytes?: number): AsyncGenerator<string | {
|
|
26
|
+
tooLong: true;
|
|
27
|
+
}, void, void>;
|
|
4
28
|
interface JsonRpcRequest {
|
|
5
29
|
jsonrpc?: string;
|
|
6
30
|
id?: string | number | null;
|
package/dist/mcp/serve.js
CHANGED
|
@@ -8,10 +8,44 @@
|
|
|
8
8
|
* (serve cannot prompt, so nothing privileged runs without an explicit
|
|
9
9
|
* allow rule) — serving never bypasses policy.
|
|
10
10
|
*/
|
|
11
|
-
import * as readline from 'node:readline';
|
|
12
11
|
import { builtinRegistry } from '../tools/registry.js';
|
|
13
12
|
import { PolicyEngine, builtinRules, DEFAULT_POLICY_CONFIG } from '../policy/engine.js';
|
|
14
13
|
import { readVersion } from '../version.js';
|
|
14
|
+
/**
|
|
15
|
+
* Max bytes of one JSON-RPC line (defense in depth: readline-style
|
|
16
|
+
* unbounded buffering lets one giant message OOM the server).
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_MCP_LINE_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Bounded newline-delimited reader. Lines longer than maxBytes are dropped
|
|
21
|
+
* (reported once as `{ tooLong: true }`) and the reader resyncs at the next
|
|
22
|
+
* newline; callers answer oversized messages with a JSON-RPC error instead
|
|
23
|
+
* of parsing unbounded input.
|
|
24
|
+
*/
|
|
25
|
+
export async function* readBoundedLines(input, maxBytes = MAX_MCP_LINE_BYTES) {
|
|
26
|
+
let buf = Buffer.alloc(0);
|
|
27
|
+
let overlong = false;
|
|
28
|
+
for await (const chunk of input) {
|
|
29
|
+
buf = Buffer.concat([buf, typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk]);
|
|
30
|
+
let nl;
|
|
31
|
+
while ((nl = buf.indexOf(0x0a)) !== -1) {
|
|
32
|
+
const line = buf.subarray(0, nl);
|
|
33
|
+
buf = buf.subarray(nl + 1);
|
|
34
|
+
if (overlong) {
|
|
35
|
+
overlong = false; // drop the remainder of the oversized message
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
yield line.toString('utf-8');
|
|
39
|
+
}
|
|
40
|
+
if (buf.length > maxBytes) {
|
|
41
|
+
buf = Buffer.alloc(0);
|
|
42
|
+
overlong = true;
|
|
43
|
+
yield { tooLong: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (buf.length > 0 && !overlong)
|
|
47
|
+
yield buf.toString('utf-8');
|
|
48
|
+
}
|
|
15
49
|
export function makeServeDeps(cwd, overrides = {}) {
|
|
16
50
|
return {
|
|
17
51
|
registry: overrides.registry ?? builtinRegistry(),
|
|
@@ -82,9 +116,12 @@ export async function handleMcpRequest(deps, msg) {
|
|
|
82
116
|
/** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
|
|
83
117
|
export async function serveStdio(cwd) {
|
|
84
118
|
const deps = makeServeDeps(cwd);
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
for await (const item of readBoundedLines(process.stdin)) {
|
|
120
|
+
if (typeof item !== 'string') {
|
|
121
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: `Request too large (limit ${MAX_MCP_LINE_BYTES} bytes)` } }) + '\n');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const trimmed = item.trim();
|
|
88
125
|
if (!trimmed)
|
|
89
126
|
continue;
|
|
90
127
|
let msg;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as crypto from 'node:crypto';
|
|
12
12
|
import * as fs from 'node:fs/promises';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
|
-
import { SessionStore } from './store.js';
|
|
14
|
+
import { SessionStore, hasDangerousKeys } from './store.js';
|
|
15
15
|
export const AUDIT_GENESIS = 'GENESIS';
|
|
16
16
|
/** Canonical JSON: object keys sorted recursively, so hashes are stable. */
|
|
17
17
|
export function canonicalJson(value) {
|
|
@@ -150,6 +150,13 @@ async function verifySegment(filePath, segmentLabel) {
|
|
|
150
150
|
catch {
|
|
151
151
|
return { events, error: `${segmentLabel}:${i + 1}: unparseable JSON` };
|
|
152
152
|
}
|
|
153
|
+
// Fail closed on tampered shapes before any field is trusted: a line
|
|
154
|
+
// carrying prototype-pollution keys can never be a genuine record
|
|
155
|
+
// (the writer only emits AuditEvent shapes), so reject it outright
|
|
156
|
+
// rather than letting it reach the hash comparison.
|
|
157
|
+
if (hasDangerousKeys(parsed)) {
|
|
158
|
+
return { events, error: `${segmentLabel}:${i + 1}: dangerous keys (possible tampering)` };
|
|
159
|
+
}
|
|
153
160
|
if (parsed.prevHash !== expectedPrev) {
|
|
154
161
|
return { events, error: `${segmentLabel}:${i + 1}: prevHash mismatch (chain fork or truncation)` };
|
|
155
162
|
}
|
|
@@ -99,6 +99,8 @@ export declare class SessionStore {
|
|
|
99
99
|
/** Atomic append + fsync — survives crashes; suitable for audit log. */
|
|
100
100
|
static appendJsonl(filePath: string, entry: unknown): Promise<void>;
|
|
101
101
|
}
|
|
102
|
+
/** True when any own key (at any depth) is a prototype-pollution key. */
|
|
103
|
+
export declare function hasDangerousKeys(value: unknown): boolean;
|
|
102
104
|
/**
|
|
103
105
|
* Redact text blocks of a stored message at the persist boundary.
|
|
104
106
|
* Handles string content, `{ text }` blocks, arrays of blocks, and plain
|
|
@@ -395,6 +395,30 @@ export class SessionStore {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Keys that must never be copied by assignment: `out['__proto__'] = …`
|
|
400
|
+
* invokes the Object.prototype setter (pollution), and `constructor` /
|
|
401
|
+
* `prototype` walks can reach live prototypes. Tampered session content
|
|
402
|
+
* carrying them is dropped at the persist boundary.
|
|
403
|
+
*/
|
|
404
|
+
function isDangerousKey(k) {
|
|
405
|
+
return k === '__proto__' || k === 'constructor' || k === 'prototype';
|
|
406
|
+
}
|
|
407
|
+
/** True when any own key (at any depth) is a prototype-pollution key. */
|
|
408
|
+
export function hasDangerousKeys(value) {
|
|
409
|
+
if (Array.isArray(value))
|
|
410
|
+
return value.some(hasDangerousKeys);
|
|
411
|
+
if (value && typeof value === 'object') {
|
|
412
|
+
const rec = value;
|
|
413
|
+
for (const k of Object.keys(rec)) {
|
|
414
|
+
if (isDangerousKey(k))
|
|
415
|
+
return true;
|
|
416
|
+
if (hasDangerousKeys(rec[k]))
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
398
422
|
/**
|
|
399
423
|
* Redact text blocks of a stored message at the persist boundary.
|
|
400
424
|
* Handles string content, `{ text }` blocks, arrays of blocks, and plain
|
|
@@ -424,6 +448,8 @@ export function redactStoredContent(content) {
|
|
|
424
448
|
}
|
|
425
449
|
const out = {};
|
|
426
450
|
for (const [k, v] of Object.entries(rec)) {
|
|
451
|
+
if (isDangerousKey(k))
|
|
452
|
+
continue; // tampered key — drop, never assign
|
|
427
453
|
out[k] = typeof v === 'string' ? redact(v) : (v && typeof v === 'object' ? redactStoredContent(v) : v);
|
|
428
454
|
}
|
|
429
455
|
return out;
|
|
@@ -24,9 +24,9 @@ const PATTERNS = [
|
|
|
24
24
|
// Generic provider keys — must be redacted even if not prefixed Bearer
|
|
25
25
|
{ name: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
26
26
|
{ name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
27
|
-
{ name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{
|
|
27
|
+
{ name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{8,}['"]?/gi },
|
|
28
28
|
{ name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
|
|
29
|
-
{ name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{
|
|
29
|
+
{ name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{8,}['"]?/gi },
|
|
30
30
|
{ name: 'discord-token', re: /mfa\.[\w-]{84}|[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g },
|
|
31
31
|
{ name: 'npm-token', re: /\bnpm_[A-Za-z0-9]{24,}/g },
|
|
32
32
|
{ name: 'sendgrid-key', re: /\bSG\.[\w-]{22}\.[\w-]{43}/g },
|
package/dist/shared/index.d.ts
CHANGED
package/dist/shared/index.js
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip a leading U+FEFF byte-order mark. Windows editors (e.g. Notepad)
|
|
3
|
+
* save JSON with a BOM by default; `JSON.parse` rejects it, so user-authored
|
|
4
|
+
* config files must be BOM-tolerant. Use this on every raw string read for
|
|
5
|
+
* config/threat-surface parsing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripBom(text: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip a leading U+FEFF byte-order mark. Windows editors (e.g. Notepad)
|
|
3
|
+
* save JSON with a BOM by default; `JSON.parse` rejects it, so user-authored
|
|
4
|
+
* config files must be BOM-tolerant. Use this on every raw string read for
|
|
5
|
+
* config/threat-surface parsing.
|
|
6
|
+
*/
|
|
7
|
+
export function stripBom(text) {
|
|
8
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded output collector for child-process stdout/stderr.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the counter pattern already used by `tools/verify/run-verify.ts:66`.
|
|
5
|
+
* The naive alternative —
|
|
6
|
+
*
|
|
7
|
+
* child.stdout.on('data', (b) => { if (Buffer.concat(chunks).length < CAP) chunks.push(b); });
|
|
8
|
+
*
|
|
9
|
+
* — re-concatenates everything on *every* chunk, so a chatty verifier (a test
|
|
10
|
+
* suite printing megabytes) burns O(n²) CPU and keeps paying that cost for the
|
|
11
|
+
* rest of the run even after the cap is reached; with no guard at all the heap
|
|
12
|
+
* grows without bound. `push` here is O(1) and stops storing after `capBytes`.
|
|
13
|
+
*
|
|
14
|
+
* Streams are deliberately NOT paused when full: pausing a piped stdout sends
|
|
15
|
+
* backpressure to the child, which can then block on write and never exit —
|
|
16
|
+
* the caller would hang until its own timeout. Draining and discarding is the
|
|
17
|
+
* safe trade: bounded memory, no hang.
|
|
18
|
+
*/
|
|
19
|
+
export interface CappedOutput {
|
|
20
|
+
/** Store `chunk` (partially, when it overflows the cap). O(1). */
|
|
21
|
+
push(chunk: Buffer): void;
|
|
22
|
+
/** True once a byte was dropped, or a chunk arrived after the cap was hit. */
|
|
23
|
+
readonly truncated: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Collected bytes decoded as UTF-8. Call once per run: the byte cap also
|
|
26
|
+
* bounds the character count, so no character slicing is needed.
|
|
27
|
+
*/
|
|
28
|
+
text(): string;
|
|
29
|
+
}
|
|
30
|
+
export declare function cappedOutput(capBytes: number): CappedOutput;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function cappedOutput(capBytes) {
|
|
2
|
+
const chunks = [];
|
|
3
|
+
let bytes = 0;
|
|
4
|
+
let truncated = false;
|
|
5
|
+
return {
|
|
6
|
+
push(chunk) {
|
|
7
|
+
if (bytes >= capBytes) {
|
|
8
|
+
truncated = true; // more output exists but is intentionally dropped
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const room = capBytes - bytes;
|
|
12
|
+
if (chunk.length > room) {
|
|
13
|
+
chunks.push(chunk.subarray(0, room));
|
|
14
|
+
bytes = capBytes;
|
|
15
|
+
truncated = true;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
bytes += chunk.length;
|
|
20
|
+
},
|
|
21
|
+
get truncated() {
|
|
22
|
+
return truncated;
|
|
23
|
+
},
|
|
24
|
+
text() {
|
|
25
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -34,6 +34,15 @@ export interface WebFetchOutput {
|
|
|
34
34
|
}
|
|
35
35
|
/** Hard ceiling on downloaded bytes regardless of `maxChars`. */
|
|
36
36
|
export declare const MAX_DOWNLOAD_BYTES: number;
|
|
37
|
+
/**
|
|
38
|
+
* Expand an IPv4-mapped IPv6 host to its dotted quad, else null.
|
|
39
|
+
*
|
|
40
|
+
* `::ffff:169.254.169.254` and `::ffff:a9fe:a9fe` are the SAME address, but
|
|
41
|
+
* WHATWG URL stringifies the second form in hex — so a dotted-quad regex
|
|
42
|
+
* alone is bypassable by writing the mapped form. Returns the dotted quad for
|
|
43
|
+
* both spellings so every address check below sees one canonical shape.
|
|
44
|
+
*/
|
|
45
|
+
export declare function mappedIPv4(host: string): string | null;
|
|
37
46
|
/**
|
|
38
47
|
* Allow-list check for fetch targets. `https:` is always structurally OK;
|
|
39
48
|
* `http:` is restricted to loopback/private hosts unless the user opts in
|
|
@@ -39,6 +39,25 @@ const InputSchema = z.object({
|
|
|
39
39
|
export const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
|
|
40
40
|
const DEFAULT_MAX_CHARS = 20_000;
|
|
41
41
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
42
|
+
/**
|
|
43
|
+
* Expand an IPv4-mapped IPv6 host to its dotted quad, else null.
|
|
44
|
+
*
|
|
45
|
+
* `::ffff:169.254.169.254` and `::ffff:a9fe:a9fe` are the SAME address, but
|
|
46
|
+
* WHATWG URL stringifies the second form in hex — so a dotted-quad regex
|
|
47
|
+
* alone is bypassable by writing the mapped form. Returns the dotted quad for
|
|
48
|
+
* both spellings so every address check below sees one canonical shape.
|
|
49
|
+
*/
|
|
50
|
+
export function mappedIPv4(host) {
|
|
51
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
|
|
52
|
+
if (dotted)
|
|
53
|
+
return dotted[1];
|
|
54
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
55
|
+
if (!hex)
|
|
56
|
+
return null;
|
|
57
|
+
const hi = parseInt(hex[1], 16);
|
|
58
|
+
const lo = parseInt(hex[2], 16);
|
|
59
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
60
|
+
}
|
|
42
61
|
/**
|
|
43
62
|
* Allow-list check for fetch targets. `https:` is always structurally OK;
|
|
44
63
|
* `http:` is restricted to loopback/private hosts unless the user opts in
|
|
@@ -55,13 +74,30 @@ export function fetchUrlDenialReason(raw, env) {
|
|
|
55
74
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
56
75
|
return `refusing non-http(s) URL scheme: ${u.protocol}`;
|
|
57
76
|
}
|
|
58
|
-
|
|
77
|
+
// WHATWG URL keeps IPv6 brackets in `hostname` (`[::1]`), so strip them
|
|
78
|
+
// once and compare bare hosts everywhere below.
|
|
79
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
80
|
+
// Address view: unmap IPv4-mapped IPv6 so `[::ffff:0.0.0.0]` and
|
|
81
|
+
// `[::ffff:169.254.169.254]` cannot slip past the checks as hex strings.
|
|
82
|
+
const addr = mappedIPv4(host) ?? host;
|
|
83
|
+
// Unspecified addresses are never valid fetch targets (SSRF evasion
|
|
84
|
+
// vector — "this host" without naming it).
|
|
85
|
+
if (addr === '0.0.0.0' || addr === '::' || addr === '0:0:0:0:0:0:0:0') {
|
|
86
|
+
return `refusing unspecified destination address: ${u.hostname}`;
|
|
87
|
+
}
|
|
88
|
+
// Cloud metadata endpoints (169.254.0.0/16, any scheme): link-local
|
|
89
|
+
// IMDS services expose IAM credentials to any local requester. An agent
|
|
90
|
+
// tricked into fetching these exfiltrates cloud identity — deny always,
|
|
91
|
+
// even over https.
|
|
92
|
+
if (/^169\.254\.\d+\.\d+$/.test(addr)) {
|
|
93
|
+
return `refusing cloud metadata address: ${u.hostname}`;
|
|
94
|
+
}
|
|
59
95
|
const loopback = host === 'localhost' ||
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/^10\./.test(
|
|
63
|
-
/^192\.168\./.test(
|
|
64
|
-
/^172\.(1[6-9]|2\d|3[01])\./.test(
|
|
96
|
+
addr === '127.0.0.1' ||
|
|
97
|
+
addr === '::1' ||
|
|
98
|
+
/^10\./.test(addr) ||
|
|
99
|
+
/^192\.168\./.test(addr) ||
|
|
100
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(addr);
|
|
65
101
|
if (u.protocol === 'http:' && !loopback && env.KLYRO_ALLOW_INSECURE !== '1') {
|
|
66
102
|
return 'refusing plaintext http for non-local host (use https or set KLYRO_ALLOW_INSECURE=1)';
|
|
67
103
|
}
|
package/dist/tui/app.js
CHANGED
|
@@ -1052,30 +1052,23 @@ export function App(props) {
|
|
|
1052
1052
|
void props.onSlash({ kind: 'quit' });
|
|
1053
1053
|
return;
|
|
1054
1054
|
}
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
commands.lineUp();
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1055
|
+
// ↑/↓ are history keys, always (§8.3): ↑ browses older prompts
|
|
1056
|
+
// newest-first (empty history → no-op), ↓ browses back newer. Neither
|
|
1057
|
+
// ever scrolls the transcript — scrolling is PgUp/PgDn, Ctrl+U/D/B/F,
|
|
1058
|
+
// Shift+↑/↓, Home/End, Space, or the wheel (KLYRO_MOUSE=1). Ctrl+P /
|
|
1059
|
+
// Ctrl+N are escape-free aliases for the same history moves.
|
|
1060
|
+
const wantHistPrev = (key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'p');
|
|
1061
|
+
const wantHistNext = (key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n');
|
|
1062
|
+
if (wantHistPrev) {
|
|
1066
1063
|
if (history.length > 0) {
|
|
1067
1064
|
const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
1068
1065
|
setHistIdx(next);
|
|
1069
1066
|
setInput(history[next] ?? '');
|
|
1070
1067
|
setVimCursor(null);
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
if (isFullscreen && maxTop > 0) {
|
|
1074
|
-
commands.lineUp();
|
|
1075
|
-
return;
|
|
1076
1068
|
}
|
|
1069
|
+
return;
|
|
1077
1070
|
}
|
|
1078
|
-
if (
|
|
1071
|
+
if (wantHistNext) {
|
|
1079
1072
|
if (histIdx !== null) {
|
|
1080
1073
|
const next = histIdx + 1;
|
|
1081
1074
|
if (next >= history.length) {
|
|
@@ -1087,14 +1080,8 @@ export function App(props) {
|
|
|
1087
1080
|
setInput(history[next] ?? '');
|
|
1088
1081
|
}
|
|
1089
1082
|
setVimCursor(null);
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
if (input.trim() !== '')
|
|
1093
|
-
return; // single line with text, nothing newer
|
|
1094
|
-
if (isFullscreen && maxTop > 0) {
|
|
1095
|
-
commands.lineDown();
|
|
1096
|
-
return;
|
|
1097
1083
|
}
|
|
1084
|
+
return;
|
|
1098
1085
|
}
|
|
1099
1086
|
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
1100
1087
|
if (key.return) {
|