@ruvnet/open-claude-code 2.1.0 → 2.1.1-nightly.20261011.202
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/LICENSE +21 -0
- package/README.md +376 -106
- package/package.json +11 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -0
- package/src/config/settings.mjs +6 -0
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +30 -4
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +40 -2
- package/src/optimize/cascade.mjs +128 -0
- package/src/optimize/commands.mjs +139 -0
- package/src/optimize/router.mjs +212 -0
- package/src/optimize/store.mjs +170 -0
- package/src/permissions/injection-check.mjs +3 -2
- package/src/permissions/path-check.mjs +8 -0
- package/src/permissions/sandbox.mjs +8 -2
- package/src/permissions/ssrf-check.mjs +102 -0
- package/src/plugins/loader.mjs +14 -6
- package/src/skills/loader.mjs +16 -4
- package/src/tools/enter-worktree.mjs +5 -3
- package/src/tools/exit-worktree.mjs +4 -3
- package/src/tools/grep.mjs +26 -11
- package/src/tools/lsp.mjs +23 -9
- package/src/tools/read.mjs +20 -9
- package/src/tools/send-message.mjs +5 -1
- package/src/tools/web-fetch.mjs +15 -1
- package/src/tools/web-search.mjs +13 -1
- package/src/ui/commands.mjs +44 -7
- package/test/test.mjs +0 -1857
|
@@ -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
|
+
}
|
package/src/plugins/loader.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import os from 'os';
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/src/skills/loader.mjs
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
execFileSync('git', ['branch', '-D', wt.branch], {
|
|
43
44
|
encoding: 'utf-8',
|
|
44
45
|
stdio: 'pipe',
|
|
45
46
|
});
|
package/src/tools/grep.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
package/src/tools/read.mjs
CHANGED
|
@@ -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
|
|
123
|
-
|
|
124
|
-
|
|
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()}_${
|
|
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',
|
package/src/tools/web-fetch.mjs
CHANGED
|
@@ -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
|
}
|
package/src/tools/web-search.mjs
CHANGED
|
@@ -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
|
|
package/src/ui/commands.mjs
CHANGED
|
@@ -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: ${
|
|
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 (
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
/**
|