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.
@@ -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) is treated as "no update".
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 === null ? latest !== current : cmp > 0;
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 !== null && cmp <= 0) {
130
- // Refresh the negative cache so a poisoned answer isn't re-fetched
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`);
@@ -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 Devolopment-plan.md / docs/plan.md §10: a
8
- * reproducible suite of programmatic tasks. Real repo tasks come in v1.0.
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 {
@@ -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 Devolopment-plan.md / docs/plan.md §10: a
8
- * reproducible suite of programmatic tasks. Real repo tasks come in v1.0.
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
- let out = '';
193
- child.stdout?.on('data', (b) => { out += b.toString(); });
194
- child.stderr?.on('data', (b) => { out += b.toString(); });
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: out.slice(0, 500),
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
- let buf = '';
251
- child.stdout?.on('data', (b) => { buf += b.toString(); });
252
- child.stderr?.on('data', (b) => { buf += b.toString(); });
253
- child.on('close', (code) => resolve(`exit=${code ?? -1} ${buf.slice(0, 500)}`));
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 configured preToolUse/postToolUse hooks.
566
- program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
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 { loadHooks } = await import('./cli/hooks.js');
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');
@@ -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();
@@ -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;
@@ -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 rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
86
- for await (const line of rl) {
87
- const trimmed = line.trim();
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_\-]{16,}['"]?/gi },
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_\-+/=]{16,}['"]?/gi },
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 },
@@ -1,3 +1,4 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
3
  export * from './sanitize.js';
4
+ export * from './json.js';
@@ -1,3 +1,4 @@
1
1
  export * from './errors.js';
2
2
  export * from './types.js';
3
3
  export * from './sanitize.js';
4
+ export * from './json.js';
@@ -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
- const host = u.hostname.toLowerCase();
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
- host === '127.0.0.1' ||
61
- host === '::1' ||
62
- /^10\./.test(host) ||
63
- /^192\.168\./.test(host) ||
64
- /^172\.(1[6-9]|2\d|3[01])\./.test(host);
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
- // Contextual ↑/↓ (§8.3): both behaviors, split by where you are.
1056
- // Scrolled up reading the transcript (not at the live tail, not already
1057
- // browsing) → plain ↑ keeps scrolling so chat scroll is never lost.
1058
- // At the live tail (or no scroll surface) → ↑ browses input history
1059
- // newest-first, so send + ↑ restores the last prompt. ↓ is unchanged:
1060
- // browse newer while browsing, else scroll toward the tail.
1061
- if (key.upArrow && !key.shift && !key.ctrl) {
1062
- if (isFullscreen && maxTop > 0 && !atBottom && histIdx === null && input.trim() === '') {
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 (key.downArrow && !key.shift && !key.ctrl) {
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) {