@ruvnet/open-claude-code 2.0.1 → 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 +383 -0
- package/package.json +29 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -2
- package/src/config/settings.mjs +30 -130
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +34 -149
- package/src/core/system-prompt.mjs +43 -47
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +60 -32
- 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/checker.mjs +8 -79
- 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/bash.mjs +3 -40
- 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/app.mjs +286 -0
- package/src/ui/commands.mjs +121 -71
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -2657
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Integrates with prompt system for interactive permission in default mode,
|
|
5
5
|
* injection checking for Bash commands, and path validation for file ops.
|
|
6
|
-
* Caches "always allow" decisions per tool per session.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
import { requiresPermission
|
|
8
|
+
import { requiresPermission } from './prompt.mjs';
|
|
10
9
|
import { checkInjection } from './injection-check.mjs';
|
|
11
10
|
import { validatePath } from './path-check.mjs';
|
|
12
11
|
|
|
@@ -14,18 +13,8 @@ export function createPermissionChecker(config = {}) {
|
|
|
14
13
|
const mode = config.defaultMode || process.env.CLAUDE_CODE_PERMISSION_MODE || 'default';
|
|
15
14
|
const rl = config.rl || null; // readline interface for prompts
|
|
16
15
|
|
|
17
|
-
// Cache for "always allow" decisions: tool -> true
|
|
18
|
-
const alwaysAllowed = new Set();
|
|
19
|
-
|
|
20
16
|
return {
|
|
21
17
|
mode,
|
|
22
|
-
alwaysAllowed,
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Check if a tool call is permitted.
|
|
26
|
-
* In default mode with readline, prompts the user interactively.
|
|
27
|
-
* Caches "always allow" decisions per tool per session.
|
|
28
|
-
*/
|
|
29
18
|
async check(toolName, input) {
|
|
30
19
|
// Always run injection check on Bash commands
|
|
31
20
|
if (toolName === 'Bash' && input?.command) {
|
|
@@ -46,83 +35,23 @@ export function createPermissionChecker(config = {}) {
|
|
|
46
35
|
switch (mode) {
|
|
47
36
|
case 'bypassPermissions': return true;
|
|
48
37
|
case 'acceptEdits':
|
|
49
|
-
// Allow file ops,
|
|
50
|
-
if (['Edit', 'Write', 'MultiEdit', 'Read', 'Glob', 'Grep'].includes(toolName)) {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
38
|
+
// Allow file ops, block Bash/Agent unless rl available
|
|
53
39
|
if (toolName === 'Bash' || toolName === 'Agent') {
|
|
54
|
-
|
|
55
|
-
if (alwaysAllowed.has(toolName)) return true;
|
|
56
|
-
if (rl) return this._promptUser(toolName, input);
|
|
57
|
-
return false;
|
|
40
|
+
return !requiresPermission(toolName) || !!config.bypassBash;
|
|
58
41
|
}
|
|
59
42
|
return true;
|
|
60
43
|
case 'auto': return true; // AI decides
|
|
61
44
|
case 'dontAsk': return false; // deny everything not pre-approved
|
|
62
|
-
case 'plan':
|
|
63
|
-
return ['Read', 'Glob', 'Grep', 'LS', 'ToolSearch'].includes(toolName);
|
|
45
|
+
case 'plan': return toolName === 'Read' || toolName === 'Glob' || toolName === 'Grep';
|
|
64
46
|
case 'default':
|
|
65
47
|
default:
|
|
66
|
-
//
|
|
48
|
+
// In default mode, safe tools pass through
|
|
67
49
|
if (!requiresPermission(toolName)) return true;
|
|
68
|
-
//
|
|
69
|
-
if (alwaysAllowed.has(toolName)) return true;
|
|
70
|
-
// Without readline, allow (headless/print mode)
|
|
50
|
+
// Without a readline interface, allow (headless mode)
|
|
71
51
|
if (!rl) return true;
|
|
72
|
-
//
|
|
73
|
-
return
|
|
52
|
+
// With rl, would call promptPermission — but that's async/interactive
|
|
53
|
+
return true;
|
|
74
54
|
}
|
|
75
55
|
},
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Prompt user for permission, with options: y/n/a (always).
|
|
79
|
-
* @param {string} toolName
|
|
80
|
-
* @param {object} input
|
|
81
|
-
* @returns {Promise<boolean>}
|
|
82
|
-
*/
|
|
83
|
-
async _promptUser(toolName, input) {
|
|
84
|
-
if (!rl || typeof rl.question !== 'function') return false;
|
|
85
|
-
|
|
86
|
-
const summary = formatToolSummary(toolName, input);
|
|
87
|
-
return new Promise(resolve => {
|
|
88
|
-
rl.question(`\x1b[33mAllow ${summary}?\x1b[0m [y/n/a(lways)] `, answer => {
|
|
89
|
-
const a = answer.trim().toLowerCase();
|
|
90
|
-
if (a === 'a' || a === 'always') {
|
|
91
|
-
alwaysAllowed.add(toolName);
|
|
92
|
-
resolve(true);
|
|
93
|
-
} else if (a === 'y' || a === 'yes') {
|
|
94
|
-
resolve(true);
|
|
95
|
-
} else {
|
|
96
|
-
resolve(false);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Grant "always allow" for a tool without prompting.
|
|
104
|
-
* @param {string} toolName
|
|
105
|
-
*/
|
|
106
|
-
allowAlways(toolName) {
|
|
107
|
-
alwaysAllowed.add(toolName);
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Reset cached allow decisions.
|
|
112
|
-
*/
|
|
113
|
-
resetAllowed() {
|
|
114
|
-
alwaysAllowed.clear();
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Get info about current permission state.
|
|
119
|
-
*/
|
|
120
|
-
getInfo() {
|
|
121
|
-
return {
|
|
122
|
-
mode,
|
|
123
|
-
alwaysAllowed: [...alwaysAllowed],
|
|
124
|
-
hasReadline: !!rl,
|
|
125
|
-
};
|
|
126
|
-
},
|
|
127
56
|
};
|
|
128
57
|
}
|
|
@@ -10,7 +10,7 @@ const DANGEROUS_PATTERNS = [
|
|
|
10
10
|
{ pattern: /\|\s*sh\b/, label: 'pipe to sh' },
|
|
11
11
|
{ pattern: /\|\s*bash\b/, label: 'pipe to bash' },
|
|
12
12
|
{ pattern: /`[^`]+`/, label: 'backtick execution' },
|
|
13
|
-
{ pattern: /\$\(
|
|
13
|
+
{ pattern: /\$\(/, label: 'command substitution' },
|
|
14
14
|
{ pattern: />\s*\/etc\//, label: 'write to /etc' },
|
|
15
15
|
{ pattern: />\s*\/usr\//, label: 'write to /usr' },
|
|
16
16
|
{ pattern: /curl\s.*\|\s*(bash|sh)/, label: 'curl pipe to shell' },
|
|
@@ -19,8 +19,9 @@ const DANGEROUS_PATTERNS = [
|
|
|
19
19
|
{ pattern: /dd\s+if=.*of=\/dev\//, label: 'dd to device' },
|
|
20
20
|
{ pattern: /:\(\)\s*\{.*\|.*&\s*\}/, label: 'fork bomb' },
|
|
21
21
|
{ pattern: /chmod\s+777\s+\//, label: 'chmod 777 root' },
|
|
22
|
-
{ pattern: />\s*\/dev\/
|
|
22
|
+
{ pattern: />\s*\/dev\/sd[a-z]/, label: 'write to disk device' },
|
|
23
23
|
{ pattern: /eval\s+"?\$/, label: 'eval variable' },
|
|
24
|
+
{ pattern: /\bexec\s+\d+[<>]/, label: 'exec fd redirect' },
|
|
24
25
|
];
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -16,6 +16,8 @@ const SENSITIVE_PATTERNS = [
|
|
|
16
16
|
/\.key$/,
|
|
17
17
|
/id_rsa$/,
|
|
18
18
|
/id_ed25519$/,
|
|
19
|
+
/id_ecdsa$/,
|
|
20
|
+
/id_dsa$/,
|
|
19
21
|
/\.ssh\/config$/,
|
|
20
22
|
/\.netrc$/,
|
|
21
23
|
/\.pgpass$/,
|
|
@@ -23,6 +25,12 @@ const SENSITIVE_PATTERNS = [
|
|
|
23
25
|
/\.docker\/config\.json$/,
|
|
24
26
|
/secrets\.yaml$/,
|
|
25
27
|
/secrets\.json$/,
|
|
28
|
+
/\.npmrc$/,
|
|
29
|
+
/\.pypirc$/,
|
|
30
|
+
/\.claude\/credentials$/,
|
|
31
|
+
/service[_-]?account.*\.json$/i,
|
|
32
|
+
/kubeconfig$/,
|
|
33
|
+
/\.kube\/config$/,
|
|
26
34
|
];
|
|
27
35
|
|
|
28
36
|
/** Directories that should never be written to. */
|
|
@@ -44,7 +44,10 @@ export class Sandbox {
|
|
|
44
44
|
// Allow specific writable directories
|
|
45
45
|
if (opts.allowWrite) {
|
|
46
46
|
for (const dir of opts.allowWrite) {
|
|
47
|
-
|
|
47
|
+
// Validate: must be a non-empty absolute path with no shell metacharacters
|
|
48
|
+
if (typeof dir === 'string' && dir.length > 0 &&
|
|
49
|
+
dir.startsWith('/') && !/[\s'"`$\\;|&<>(){}!]/.test(dir) &&
|
|
50
|
+
!dir.includes('\0')) {
|
|
48
51
|
args.push('--bind', dir, dir);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
@@ -76,7 +79,10 @@ export class Sandbox {
|
|
|
76
79
|
// Allow writes to specific directories
|
|
77
80
|
if (opts.allowWrite) {
|
|
78
81
|
for (const dir of opts.allowWrite) {
|
|
79
|
-
|
|
82
|
+
// Validate: must be a non-empty absolute path with no shell/seatbelt metacharacters
|
|
83
|
+
if (typeof dir === 'string' && dir.length > 0 &&
|
|
84
|
+
dir.startsWith('/') && !/['"\\;(){}]/.test(dir) &&
|
|
85
|
+
!dir.includes('\0')) {
|
|
80
86
|
rules.push(`(allow file-write* (subpath "${dir}"))`);
|
|
81
87
|
}
|
|
82
88
|
}
|
|
@@ -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);
|
package/src/tools/bash.mjs
CHANGED
|
@@ -7,10 +7,8 @@
|
|
|
7
7
|
* - description parameter
|
|
8
8
|
* - 1MB output limit
|
|
9
9
|
* - ANSI code stripping by default
|
|
10
|
-
* - Sandbox integration (bubblewrap/seatbelt)
|
|
11
10
|
*/
|
|
12
11
|
import { spawn } from 'child_process';
|
|
13
|
-
import { Sandbox } from '../permissions/sandbox.mjs';
|
|
14
12
|
|
|
15
13
|
// Strip ANSI escape sequences
|
|
16
14
|
function stripAnsi(str) {
|
|
@@ -20,40 +18,6 @@ function stripAnsi(str) {
|
|
|
20
18
|
|
|
21
19
|
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB
|
|
22
20
|
|
|
23
|
-
// Sandbox instance — reused across calls
|
|
24
|
-
const sandbox = new Sandbox();
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Determine if a command should be sandboxed.
|
|
28
|
-
* @param {string} command
|
|
29
|
-
* @returns {boolean}
|
|
30
|
-
*/
|
|
31
|
-
function shouldSandbox(command) {
|
|
32
|
-
// Forced sandbox via env var
|
|
33
|
-
if (process.env.CLAUDE_CODE_FORCE_SANDBOX === '1') return true;
|
|
34
|
-
// Bubblewrap env var
|
|
35
|
-
if (process.env.CLAUDE_CODE_BUBBLEWRAP === '1') return true;
|
|
36
|
-
// Sandbox setting
|
|
37
|
-
if (process.env.CLAUDE_CODE_SANDBOX === '1') return true;
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Wrap a command with sandbox if needed.
|
|
43
|
-
* @param {string} command
|
|
44
|
-
* @returns {string} possibly-wrapped command
|
|
45
|
-
*/
|
|
46
|
-
function applySandbox(command) {
|
|
47
|
-
if (!shouldSandbox(command)) return command;
|
|
48
|
-
|
|
49
|
-
const check = sandbox.check();
|
|
50
|
-
if (!check.available) return command; // graceful fallback
|
|
51
|
-
|
|
52
|
-
return sandbox.wrapCommand(command, {
|
|
53
|
-
allowWrite: [process.cwd(), '/tmp'],
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
21
|
export const BashTool = {
|
|
58
22
|
name: 'Bash',
|
|
59
23
|
description: 'Execute a bash command and return its output.',
|
|
@@ -74,10 +38,9 @@ export const BashTool = {
|
|
|
74
38
|
},
|
|
75
39
|
async call(input) {
|
|
76
40
|
const timeout = Math.min(input.timeout || 120000, 600000);
|
|
77
|
-
const command = applySandbox(input.command);
|
|
78
41
|
|
|
79
42
|
if (input.run_in_background) {
|
|
80
|
-
return runBackground(command);
|
|
43
|
+
return runBackground(input.command);
|
|
81
44
|
}
|
|
82
45
|
|
|
83
46
|
return new Promise((resolve) => {
|
|
@@ -86,7 +49,7 @@ export const BashTool = {
|
|
|
86
49
|
let killed = false;
|
|
87
50
|
let exitCode = null;
|
|
88
51
|
|
|
89
|
-
const proc = spawn('bash', ['-c', command], {
|
|
52
|
+
const proc = spawn('bash', ['-c', input.command], {
|
|
90
53
|
env: { ...process.env },
|
|
91
54
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
55
|
timeout: 0, // we handle timeout ourselves
|
|
@@ -182,4 +145,4 @@ function runBackground(command) {
|
|
|
182
145
|
return `Background job started: id=${id}, pid=${proc.pid}`;
|
|
183
146
|
}
|
|
184
147
|
|
|
185
|
-
export { backgroundJobs
|
|
148
|
+
export { backgroundJobs };
|
|
@@ -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
|
-
}
|