@ruvnet/open-claude-code 2.1.0 → 2.1.1

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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * SSRF Protection — block requests to private/internal/loopback hosts.
3
+ *
4
+ * Prevents the WebFetch tool from being used as a Server-Side Request
5
+ * Forgery vector to probe internal network services, cloud metadata
6
+ * endpoints, or localhost.
7
+ *
8
+ * Covers:
9
+ * - Loopback: 127.0.0.0/8, ::1
10
+ * - Link-local: 169.254.0.0/16 (includes AWS/GCP/Azure metadata: 169.254.169.254)
11
+ * - Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
12
+ * - IPv6 private: fc00::/7 (ULA), fe80::/10 (link-local)
13
+ * - Unroutable: 0.0.0.0/8, 100.64.0.0/10 (CGNAT), 192.0.0.0/24
14
+ * - "localhost" and other reserved hostnames
15
+ */
16
+
17
+ /** Hostnames that are unconditionally blocked. */
18
+ const BLOCKED_HOSTNAMES = new Set([
19
+ 'localhost',
20
+ 'ip6-localhost',
21
+ 'ip6-loopback',
22
+ 'broadcasthost',
23
+ 'metadata.google.internal', // GCP metadata
24
+ 'metadata.gcp.internal',
25
+ ]);
26
+
27
+ /**
28
+ * Check whether a hostname resolves to a private or internal address.
29
+ * This operates purely on the literal hostname/IP string; it does NOT
30
+ * perform DNS resolution (to avoid TOCTOU races).
31
+ *
32
+ * @param {string} hostname - the hostname portion from a parsed URL
33
+ * @returns {boolean} true if the host should be blocked
34
+ */
35
+ export function isPrivateHost(hostname) {
36
+ if (!hostname || typeof hostname !== 'string') return true;
37
+
38
+ const h = hostname.toLowerCase().trim();
39
+
40
+ // Block by hostname
41
+ if (BLOCKED_HOSTNAMES.has(h)) return true;
42
+
43
+ // Block *.local mDNS hostnames
44
+ if (h.endsWith('.local')) return true;
45
+
46
+ // Strip IPv6 brackets if present
47
+ const raw = h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
48
+
49
+ // IPv4 check
50
+ if (isIPv4(raw)) return isPrivateIPv4(raw);
51
+
52
+ // IPv6 check
53
+ if (isIPv6Like(raw)) return isPrivateIPv6(raw);
54
+
55
+ // Numeric decimal/hex/octal IP encodings (bypass attempts)
56
+ if (/^[0-9]+$/.test(raw) || /^0x[0-9a-f]+$/i.test(raw)) return true;
57
+
58
+ return false;
59
+ }
60
+
61
+ function isIPv4(s) {
62
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(s);
63
+ }
64
+
65
+ function isIPv6Like(s) {
66
+ return s.includes(':');
67
+ }
68
+
69
+ function isPrivateIPv4(ip) {
70
+ const parts = ip.split('.').map(Number);
71
+ if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) return true;
72
+ const [a, b, c] = parts;
73
+
74
+ if (a === 0) return true; // 0.0.0.0/8
75
+ if (a === 10) return true; // 10.0.0.0/8
76
+ if (a === 127) return true; // 127.0.0.0/8 loopback
77
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
78
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / metadata
79
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
80
+ if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24
81
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
82
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
83
+ if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 documentation
84
+ if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 documentation
85
+ if (a >= 224) return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved
86
+
87
+ return false;
88
+ }
89
+
90
+ function isPrivateIPv6(ip) {
91
+ const lower = ip.toLowerCase();
92
+ if (lower === '::1') return true; // loopback
93
+ if (lower === '::') return true; // unspecified
94
+ if (lower.startsWith('::ffff:')) { // IPv4-mapped
95
+ const v4 = lower.slice(7);
96
+ if (isIPv4(v4)) return isPrivateIPv4(v4);
97
+ }
98
+ if (lower.startsWith('fe80:')) return true; // fe80::/10 link-local
99
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7 ULA
100
+ if (lower.startsWith('ff')) return true; // multicast
101
+ return false;
102
+ }
@@ -8,7 +8,7 @@
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
 
13
13
  export class PluginLoader {
14
14
  /**
@@ -62,18 +62,26 @@ export class PluginLoader {
62
62
  * @returns {object|null} loaded manifest
63
63
  */
64
64
  async loadFromGit(repoUrl, name) {
65
+ // Security: validate repoUrl to only allow https:// or ssh git URLs,
66
+ // preventing shell metacharacter injection when passed to execFileSync.
67
+ if (typeof repoUrl !== 'string' ||
68
+ !/^(https?:\/\/|git@)[^\s;|&`$<>'"\\]+$/.test(repoUrl)) {
69
+ return null;
70
+ }
65
71
  const pluginName = name || repoUrl.split('/').pop()?.replace('.git', '') || 'plugin';
66
- const targetDir = path.join(this.pluginDir, pluginName);
72
+ // Normalize plugin name to alphanumeric + hyphens only
73
+ const safePluginName = pluginName.replace(/[^a-zA-Z0-9_-]/g, '_');
74
+ const targetDir = path.join(this.pluginDir, safePluginName);
67
75
 
68
76
  try {
69
77
  fs.mkdirSync(this.pluginDir, { recursive: true });
70
78
 
71
79
  if (fs.existsSync(targetDir)) {
72
- // Update existing
73
- execSync('git pull', { cwd: targetDir, stdio: 'pipe' });
80
+ // Update existing — cwd is explicit, no user data in args
81
+ execFileSync('git', ['pull'], { cwd: targetDir, stdio: 'pipe' });
74
82
  } else {
75
- // Clone new
76
- execSync(`git clone --depth 1 ${repoUrl} ${targetDir}`, { stdio: 'pipe' });
83
+ // Clone new — repoUrl and targetDir passed as discrete args, not shell string
84
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, targetDir], { stdio: 'pipe' });
77
85
  }
78
86
 
79
87
  const manifestPath = path.join(targetDir, 'plugin.json');
@@ -18,13 +18,25 @@ export class SkillsLoader {
18
18
 
19
19
  /**
20
20
  * Load skills from standard directories.
21
+ *
22
+ * When cwd equals process.cwd() (the default), also loads skills from the
23
+ * user's global ~/.claude/skills directory so that user-installed skills
24
+ * are always available. When an explicit alternative cwd is provided,
25
+ * only that directory's .claude/skills is searched — this keeps unit tests
26
+ * hermetic and avoids leaking host-machine skills into isolated environments.
27
+ *
21
28
  * @param {string} [cwd] - project working directory
22
29
  */
23
30
  load(cwd = process.cwd()) {
24
- this.searchPaths = [
25
- path.join(cwd, '.claude', 'skills'),
26
- path.join(process.env.HOME || '', '.claude', 'skills'),
27
- ];
31
+ const isDefaultCwd = cwd === process.cwd();
32
+ this.searchPaths = [path.join(cwd, '.claude', 'skills')];
33
+
34
+ if (isDefaultCwd) {
35
+ const globalSkills = path.join(process.env.HOME || '', '.claude', 'skills');
36
+ if (globalSkills !== this.searchPaths[0]) {
37
+ this.searchPaths.push(globalSkills);
38
+ }
39
+ }
28
40
 
29
41
  for (const dir of this.searchPaths) {
30
42
  this._loadFromDir(dir);
@@ -4,7 +4,7 @@
4
4
  * Creates a temporary worktree branch so edits do not affect the main branch.
5
5
  */
6
6
 
7
- import { execSync } from 'child_process';
7
+ import { execFileSync } from 'child_process';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
 
@@ -38,7 +38,8 @@ export const EnterWorktreeTool = {
38
38
 
39
39
  try {
40
40
  // Verify we are in a git repo
41
- execSync('git rev-parse --is-inside-work-tree', { encoding: 'utf-8' });
41
+ // Security: execFileSync so arguments are not shell-interpolated
42
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf-8' });
42
43
  } catch {
43
44
  return 'Error: not inside a git repository';
44
45
  }
@@ -48,7 +49,8 @@ export const EnterWorktreeTool = {
48
49
  const originalCwd = process.cwd();
49
50
 
50
51
  try {
51
- execSync(`git worktree add -b "${branch}" "${worktreePath}"`, {
52
+ // Security: pass branch and worktreePath as discrete args — no shell injection
53
+ execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath], {
52
54
  encoding: 'utf-8',
53
55
  stdio: 'pipe',
54
56
  });
@@ -2,7 +2,7 @@
2
2
  * ExitWorktree Tool — exit and clean up a git worktree.
3
3
  */
4
4
 
5
- import { execSync } from 'child_process';
5
+ import { execFileSync } from 'child_process';
6
6
 
7
7
  export const ExitWorktreeTool = {
8
8
  name: 'ExitWorktree',
@@ -35,11 +35,12 @@ export const ExitWorktreeTool = {
35
35
 
36
36
  if (cleanup) {
37
37
  try {
38
- execSync(`git worktree remove "${wt.path}" --force`, {
38
+ // Security: pass path and branch as discrete args — no shell injection
39
+ execFileSync('git', ['worktree', 'remove', wt.path, '--force'], {
39
40
  encoding: 'utf-8',
40
41
  stdio: 'pipe',
41
42
  });
42
- execSync(`git branch -D "${wt.branch}"`, {
43
+ execFileSync('git', ['branch', '-D', wt.branch], {
43
44
  encoding: 'utf-8',
44
45
  stdio: 'pipe',
45
46
  });
@@ -9,8 +9,11 @@
9
9
  * - glob filter and type filter
10
10
  * - head_limit (default 250)
11
11
  * - multiline mode
12
+ *
13
+ * Security: uses execFileSync (array-based) so pattern, glob, type, and dir
14
+ * are never interpolated into a shell string — no command injection.
12
15
  */
13
- import { execSync } from 'child_process';
16
+ import { execFileSync } from 'child_process';
14
17
  import path from 'path';
15
18
 
16
19
  export const GrepTool = {
@@ -96,16 +99,28 @@ export const GrepTool = {
96
99
  args.push('--', input.pattern, dir);
97
100
  }
98
101
 
99
- // Apply head_limit
100
- const cmd = limit > 0
101
- ? `${args.join(' ')} 2>/dev/null | head -${limit}`
102
- : `${args.join(' ')} 2>/dev/null`;
102
+ // args[0] is the tool name (rg/grep); the rest are arguments.
103
+ // Use execFileSync so nothing is shell-interpolated.
104
+ const tool = args.shift(); // 'rg' or 'grep'
105
+ let result;
106
+ try {
107
+ result = execFileSync(tool, args, {
108
+ encoding: 'utf-8',
109
+ maxBuffer: 10 * 1024 * 1024,
110
+ timeout: 30000,
111
+ });
112
+ } catch (e) {
113
+ // exit code 1 from grep/rg means no matches — not an error
114
+ result = e.stdout || '';
115
+ }
103
116
 
104
- const result = execSync(cmd, {
105
- encoding: 'utf-8',
106
- maxBuffer: 10 * 1024 * 1024,
107
- timeout: 30000,
108
- });
117
+ // Apply head_limit by slicing lines in JS (no shell pipe needed)
118
+ if (limit > 0) {
119
+ const lines = result.split('\n');
120
+ if (lines.length > limit) {
121
+ result = lines.slice(0, limit).join('\n');
122
+ }
123
+ }
109
124
 
110
125
  return result.trim() || 'No matches found.';
111
126
  } catch {
@@ -118,7 +133,7 @@ let _hasRg = null;
118
133
  function hasRipgrep() {
119
134
  if (_hasRg !== null) return _hasRg;
120
135
  try {
121
- execSync('which rg', { encoding: 'utf-8', timeout: 5000 });
136
+ execFileSync('which', ['rg'], { encoding: 'utf-8', timeout: 5000 });
122
137
  _hasRg = true;
123
138
  } catch {
124
139
  _hasRg = false;
package/src/tools/lsp.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * server process and communicate via JSON-RPC.
13
13
  */
14
14
 
15
- import { execSync } from 'child_process';
15
+ import { execFileSync } from 'child_process';
16
16
  import path from 'path';
17
17
 
18
18
  export const LspTool = {
@@ -78,21 +78,35 @@ function getDiagnostics(filePath, language) {
78
78
  const ext = path.extname(filePath);
79
79
  const lang = language || extToLanguage(ext);
80
80
 
81
+ // Security: use execFileSync with explicit args arrays so filePath is
82
+ // never shell-interpolated — no command injection via crafted file names.
81
83
  try {
82
84
  switch (lang) {
83
85
  case 'typescript':
84
86
  case 'javascript': {
85
- const result = execSync(
86
- `npx tsc --noEmit --pretty false "${filePath}" 2>&1 || true`,
87
- { encoding: 'utf-8', timeout: 15000 }
88
- );
87
+ let result = '';
88
+ try {
89
+ result = execFileSync(
90
+ 'npx',
91
+ ['tsc', '--noEmit', '--pretty', 'false', filePath],
92
+ { encoding: 'utf-8', timeout: 15000 }
93
+ );
94
+ } catch (e) {
95
+ result = e.stdout || e.stderr || '';
96
+ }
89
97
  return result.trim() || 'No diagnostics.';
90
98
  }
91
99
  case 'python': {
92
- const result = execSync(
93
- `python3 -m py_compile "${filePath}" 2>&1 || true`,
94
- { encoding: 'utf-8', timeout: 10000 }
95
- );
100
+ let result = '';
101
+ try {
102
+ result = execFileSync(
103
+ 'python3',
104
+ ['-m', 'py_compile', filePath],
105
+ { encoding: 'utf-8', timeout: 10000 }
106
+ );
107
+ } catch (e) {
108
+ result = e.stderr || e.stdout || '';
109
+ }
96
110
  return result.trim() || 'No diagnostics.';
97
111
  }
98
112
  default:
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
+ import { execFileSync } from 'child_process';
14
15
 
15
16
  const DEFAULT_LIMIT = 2000;
16
17
 
@@ -117,11 +118,26 @@ export const ReadTool = {
117
118
 
118
119
  function readPdf(filePath, pages) {
119
120
  // PDF reading requires external tools; provide a best-effort
120
- // text extraction using a simple approach
121
+ // text extraction using a simple approach.
122
+ // Security: use execFileSync with an explicit args array so filePath and
123
+ // page numbers are never shell-interpolated (no injection possible).
121
124
  try {
122
- const { execSync } = await_import_child_process();
123
- const pageArg = pages ? `-f ${pages.split('-')[0]} -l ${pages.split('-').pop()}` : '-f 1 -l 20';
124
- const text = execSync(`pdftotext ${pageArg} "${filePath}" - 2>/dev/null`, {
125
+ const args = [];
126
+ if (pages) {
127
+ const parts = pages.split('-');
128
+ const first = parseInt(parts[0], 10);
129
+ const last = parseInt(parts[parts.length - 1], 10);
130
+ if (!isNaN(first) && !isNaN(last) && first > 0 && last >= first) {
131
+ args.push('-f', String(first), '-l', String(last));
132
+ } else {
133
+ args.push('-f', '1', '-l', '20');
134
+ }
135
+ } else {
136
+ args.push('-f', '1', '-l', '20');
137
+ }
138
+ args.push(filePath, '-');
139
+
140
+ const text = execFileSync('pdftotext', args, {
125
141
  encoding: 'utf-8',
126
142
  timeout: 30000,
127
143
  maxBuffer: 1024 * 1024,
@@ -131,8 +147,3 @@ function readPdf(filePath, pages) {
131
147
  return `[PDF file at ${filePath} — pdftotext not available. Install poppler-utils for PDF support.]`;
132
148
  }
133
149
  }
134
-
135
- // Lazy import helper
136
- function await_import_child_process() {
137
- return require('child_process');
138
- }
@@ -5,6 +5,8 @@
5
5
  * Messages are stored in a shared message queue.
6
6
  */
7
7
 
8
+ import { randomBytes } from 'crypto';
9
+
8
10
  const messageQueue = new Map(); // agentId -> messages[]
9
11
 
10
12
  export const SendMessageTool = {
@@ -38,8 +40,10 @@ export const SendMessageTool = {
38
40
  },
39
41
 
40
42
  async call(input) {
43
+ // Use cryptographically secure random bytes for message IDs to
44
+ // prevent guessing/enumeration of message identifiers.
41
45
  const msg = {
42
- id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
46
+ id: `msg_${Date.now()}_${randomBytes(6).toString('hex')}`,
43
47
  from: process.env.AGENT_ID || 'main',
44
48
  to: input.to,
45
49
  type: input.type || 'notification',
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Web Fetch Tool — fetch URL content using built-in Node.js fetch.
3
+ *
4
+ * Security: validates the URL before fetching to prevent SSRF attacks.
5
+ * Private/link-local/loopback addresses and cloud metadata endpoints are
6
+ * blocked so the tool cannot be used to probe internal network services.
3
7
  */
4
8
 
9
+ import { isPrivateHost } from '../permissions/ssrf-check.mjs';
10
+
5
11
  export const WebFetchTool = {
6
12
  name: 'WebFetch',
7
13
  description: 'Fetch content from a URL. Returns the response body as text.',
@@ -25,7 +31,15 @@ export const WebFetchTool = {
25
31
  const errors = [];
26
32
  if (!input.url) errors.push('url is required');
27
33
  try {
28
- new URL(input.url);
34
+ const parsed = new URL(input.url);
35
+ // Only allow http and https protocols
36
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
37
+ errors.push('url must use http or https protocol');
38
+ }
39
+ // Block SSRF: reject private/internal/loopback hosts
40
+ if (isPrivateHost(parsed.hostname)) {
41
+ errors.push('url must not target private or internal network addresses');
42
+ }
29
43
  } catch {
30
44
  errors.push('url must be a valid URL');
31
45
  }
@@ -5,7 +5,11 @@
5
5
  * - Brave Search API (BRAVE_API_KEY)
6
6
  * - SearXNG (self-hosted, SEARXNG_URL)
7
7
  * - Fallback: returns instructions to use web-fetch instead
8
+ *
9
+ * Security: validates that SEARXNG_URL does not target private/internal
10
+ * addresses (SSRF guard) before making requests.
8
11
  */
12
+ import { isPrivateHost } from '../permissions/ssrf-check.mjs';
9
13
 
10
14
  export const WebSearchTool = {
11
15
  name: 'WebSearch',
@@ -32,9 +36,17 @@ export const WebSearchTool = {
32
36
  return searchBrave(input.query, limit, braveKey);
33
37
  }
34
38
 
35
- // Try SearXNG
39
+ // Try SearXNG — validate the configured URL is not an internal host
36
40
  const searxUrl = process.env.SEARXNG_URL;
37
41
  if (searxUrl) {
42
+ try {
43
+ const parsed = new URL(searxUrl);
44
+ if (isPrivateHost(parsed.hostname)) {
45
+ return 'SEARXNG_URL points to a private/internal address. Configure a public SearXNG instance.';
46
+ }
47
+ } catch {
48
+ return 'SEARXNG_URL is not a valid URL.';
49
+ }
38
50
  return searchSearxng(input.query, limit, searxUrl);
39
51
  }
40
52
 
@@ -9,7 +9,9 @@ import { SessionManager } from '../core/session.mjs';
9
9
  import { CheckpointManager } from '../core/checkpoints.mjs';
10
10
  import { PromptCache } from '../core/cache.mjs';
11
11
  import { readEnv, listEnvVars } from '../config/env.mjs';
12
+ import { readApiKey } from '../core/providers.mjs';
12
13
  import * as telemetry from '../telemetry/index.mjs';
14
+ import { OutcomeStore } from '../optimize/store.mjs';
13
15
 
14
16
  const checkpoints = new CheckpointManager();
15
17
  const promptCache = new PromptCache();
@@ -96,8 +98,9 @@ export const COMMANDS = {
96
98
  description: 'Check system health and configuration',
97
99
  handler(args, state) {
98
100
  const checks = [];
101
+ const anthropicKey = readApiKey('ANTHROPIC_API_KEY');
99
102
  checks.push(`Node.js: ${process.version}`);
100
- checks.push(`ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? 'set' : 'NOT SET'}`);
103
+ checks.push(`ANTHROPIC_API_KEY: ${anthropicKey ? 'set' : 'NOT SET'}`);
101
104
  checks.push(`Model: ${state.model || 'default'}`);
102
105
  checks.push(`Tools: ${state.tools?.list?.()?.length || 0}`);
103
106
  checks.push(`Messages: ${state.messages.length}`);
@@ -106,7 +109,7 @@ export const COMMANDS = {
106
109
 
107
110
  // Check API connectivity
108
111
  let apiStatus = 'unchecked';
109
- if (process.env.ANTHROPIC_API_KEY) {
112
+ if (anthropicKey) {
110
113
  apiStatus = 'key present';
111
114
  }
112
115
  checks.push(`API: ${apiStatus}`);
@@ -214,8 +217,9 @@ export const COMMANDS = {
214
217
  '/login': {
215
218
  description: 'Set API key',
216
219
  handler(args) {
217
- if (args) {
218
- process.env.ANTHROPIC_API_KEY = args;
220
+ const key = (args || '').trim();
221
+ if (key) {
222
+ process.env.ANTHROPIC_API_KEY = key;
219
223
  return 'API key set.';
220
224
  }
221
225
  return 'Usage: /login <api-key>';
@@ -443,10 +447,13 @@ export const COMMANDS = {
443
447
  description: 'Create a git commit with AI message',
444
448
  handler(args) {
445
449
  try {
446
- const { execSync } = require('child_process');
450
+ // Security: use execFileSync with discrete args so the commit
451
+ // message is never shell-interpolated (prevents injection via
452
+ // crafted message strings containing shell metacharacters).
453
+ const { execFileSync } = require('child_process');
447
454
  const msg = args || 'Update from open-claude-code';
448
- execSync('git add -A', { encoding: 'utf-8' });
449
- execSync(`git commit -m "${msg}"`, { encoding: 'utf-8' });
455
+ execFileSync('git', ['add', '-A'], { encoding: 'utf-8' });
456
+ execFileSync('git', ['commit', '-m', msg], { encoding: 'utf-8' });
450
457
  return `Committed: ${msg}`;
451
458
  } catch (err) {
452
459
  return `Commit failed: ${err.message}`;
@@ -467,6 +474,36 @@ export const COMMANDS = {
467
474
  return 'Release creation requires gh CLI. Run: gh release create <tag>';
468
475
  },
469
476
  },
477
+
478
+ '/optimize': {
479
+ description: 'Self-optimization router status / report (metaharness)',
480
+ handler(args, state) {
481
+ const sub = (args || '').trim().split(/\s+/)[0] || 'status';
482
+ const store = new OutcomeStore();
483
+ if (sub === 'report') {
484
+ const summary = store.summary();
485
+ if (summary.total === 0) return 'No recorded outcomes yet. Start occ with --self-optimize to collect real data.';
486
+ const lines = [`Optimization report — ${summary.total} outcomes`];
487
+ for (const [m, b] of Object.entries(summary.byModel)) {
488
+ lines.push(` ${m}: ${b.attempts} runs, ${(b.successRate * 100).toFixed(0)}% ok, ~$${b.costUsd.toFixed(4)}`);
489
+ }
490
+ lines.push(` total est cost: $${summary.totalCostUsd.toFixed(4)}`);
491
+ return lines.join('\n');
492
+ }
493
+ if (sub === 'reset') {
494
+ return store.reset() ? 'Outcome store cleared.' : 'Nothing to reset.';
495
+ }
496
+ // status (default)
497
+ const enabled = !!(state && state._cascade);
498
+ const summary = store.summary();
499
+ return [
500
+ 'MetaHarness self-optimization',
501
+ ` live router this session: ${enabled ? 'ON' : 'OFF (start with --self-optimize)'}`,
502
+ ` recorded outcomes: ${summary.total}`,
503
+ ` store: ${store.filePath}`,
504
+ ].join('\n');
505
+ },
506
+ },
470
507
  };
471
508
 
472
509
  /**