@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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/package.json +29 -4
  4. package/src/auth/oauth.mjs +46 -6
  5. package/src/config/cli-args.mjs +20 -0
  6. package/src/config/env.mjs +2 -2
  7. package/src/config/settings.mjs +30 -130
  8. package/src/core/agent-loop.mjs +92 -28
  9. package/src/core/providers.mjs +19 -1
  10. package/src/core/session.mjs +34 -149
  11. package/src/core/system-prompt.mjs +43 -47
  12. package/src/hooks/engine.mjs +10 -2
  13. package/src/index.mjs +60 -32
  14. package/src/optimize/cascade.mjs +128 -0
  15. package/src/optimize/commands.mjs +139 -0
  16. package/src/optimize/router.mjs +212 -0
  17. package/src/optimize/store.mjs +170 -0
  18. package/src/permissions/checker.mjs +8 -79
  19. package/src/permissions/injection-check.mjs +3 -2
  20. package/src/permissions/path-check.mjs +8 -0
  21. package/src/permissions/sandbox.mjs +8 -2
  22. package/src/permissions/ssrf-check.mjs +102 -0
  23. package/src/plugins/loader.mjs +14 -6
  24. package/src/skills/loader.mjs +16 -4
  25. package/src/tools/bash.mjs +3 -40
  26. package/src/tools/enter-worktree.mjs +5 -3
  27. package/src/tools/exit-worktree.mjs +4 -3
  28. package/src/tools/grep.mjs +26 -11
  29. package/src/tools/lsp.mjs +23 -9
  30. package/src/tools/read.mjs +20 -9
  31. package/src/tools/send-message.mjs +5 -1
  32. package/src/tools/web-fetch.mjs +15 -1
  33. package/src/tools/web-search.mjs +13 -1
  34. package/src/ui/app.mjs +286 -0
  35. package/src/ui/commands.mjs +121 -71
  36. package/src/ui/components.mjs +219 -0
  37. package/src/ui/ink-app.mjs +13 -52
  38. package/src/ui/markdown.mjs +278 -0
  39. package/src/ui/repl.mjs +10 -25
  40. 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, promptPermission, formatToolSummary } from './prompt.mjs';
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, prompt for Bash/Agent
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
- if (config.bypassBash) return true;
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
- // Safe tools always pass
48
+ // In default mode, safe tools pass through
67
49
  if (!requiresPermission(toolName)) return true;
68
- // Check cached "always allow"
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
- // Interactive prompt
73
- return this._promptUser(toolName, input);
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: /\$\([^)]+\)/, label: 'command substitution' },
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\/sda/, label: 'write to disk device' },
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
- if (typeof dir === 'string' && dir.length > 0) {
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
- if (typeof dir === 'string' && dir.length > 0) {
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
+ }
@@ -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);
@@ -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, shouldSandbox, applySandbox };
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 { 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
- }