@calliopelabs/cli 2.2.0 → 2.5.0

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 (244) hide show
  1. package/README.md +17 -0
  2. package/dist/agents/agent-config-loader.js +1 -1
  3. package/dist/agents/agent-config-presets.js +13 -13
  4. package/dist/agents/agent-config-presets.js.map +1 -1
  5. package/dist/agents/agent-config-types.d.ts +1 -1
  6. package/dist/agents/agent-config-types.d.ts.map +1 -1
  7. package/dist/agents/council-types.d.ts +2 -0
  8. package/dist/agents/council-types.d.ts.map +1 -1
  9. package/dist/agents/council-types.js.map +1 -1
  10. package/dist/agents/council.d.ts +5 -0
  11. package/dist/agents/council.d.ts.map +1 -1
  12. package/dist/agents/council.js +150 -14
  13. package/dist/agents/council.js.map +1 -1
  14. package/dist/agents/dynamic-tools.d.ts.map +1 -1
  15. package/dist/agents/dynamic-tools.js +39 -10
  16. package/dist/agents/dynamic-tools.js.map +1 -1
  17. package/dist/agents/orchestrator.d.ts +7 -0
  18. package/dist/agents/orchestrator.d.ts.map +1 -1
  19. package/dist/agents/orchestrator.js +48 -7
  20. package/dist/agents/orchestrator.js.map +1 -1
  21. package/dist/agents/sdk-backend.js +1 -1
  22. package/dist/agents/sdk-backend.js.map +1 -1
  23. package/dist/agents/swarm-types.d.ts +1 -0
  24. package/dist/agents/swarm-types.d.ts.map +1 -1
  25. package/dist/agents/swarm.d.ts +5 -0
  26. package/dist/agents/swarm.d.ts.map +1 -1
  27. package/dist/agents/swarm.js +85 -17
  28. package/dist/agents/swarm.js.map +1 -1
  29. package/dist/agents/types.d.ts +1 -0
  30. package/dist/agents/types.d.ts.map +1 -1
  31. package/dist/agents/types.js.map +1 -1
  32. package/dist/api-server.d.ts +9 -0
  33. package/dist/api-server.d.ts.map +1 -1
  34. package/dist/api-server.js +75 -4
  35. package/dist/api-server.js.map +1 -1
  36. package/dist/auto-checkpoint.d.ts.map +1 -1
  37. package/dist/auto-checkpoint.js +50 -17
  38. package/dist/auto-checkpoint.js.map +1 -1
  39. package/dist/auto-compressor.d.ts +14 -0
  40. package/dist/auto-compressor.d.ts.map +1 -1
  41. package/dist/auto-compressor.js +67 -10
  42. package/dist/auto-compressor.js.map +1 -1
  43. package/dist/background-jobs.d.ts.map +1 -1
  44. package/dist/background-jobs.js +8 -3
  45. package/dist/background-jobs.js.map +1 -1
  46. package/dist/bin.d.ts +8 -0
  47. package/dist/bin.d.ts.map +1 -1
  48. package/dist/bin.js +63 -4
  49. package/dist/bin.js.map +1 -1
  50. package/dist/branching.d.ts.map +1 -1
  51. package/dist/branching.js +14 -1
  52. package/dist/branching.js.map +1 -1
  53. package/dist/checkpoint.d.ts.map +1 -1
  54. package/dist/checkpoint.js +13 -1
  55. package/dist/checkpoint.js.map +1 -1
  56. package/dist/cli/agent.d.ts.map +1 -1
  57. package/dist/cli/agent.js +130 -62
  58. package/dist/cli/agent.js.map +1 -1
  59. package/dist/cli/commands.d.ts.map +1 -1
  60. package/dist/cli/commands.js +218 -11
  61. package/dist/cli/commands.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +55 -6
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/types.d.ts +3 -0
  66. package/dist/cli/types.d.ts.map +1 -1
  67. package/dist/cli/types.js +2 -1
  68. package/dist/cli/types.js.map +1 -1
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.d.ts.map +1 -1
  71. package/dist/config.js +15 -3
  72. package/dist/config.js.map +1 -1
  73. package/dist/diff.d.ts.map +1 -1
  74. package/dist/diff.js +42 -4
  75. package/dist/diff.js.map +1 -1
  76. package/dist/env-expansion.d.ts +15 -0
  77. package/dist/env-expansion.d.ts.map +1 -0
  78. package/dist/env-expansion.js +43 -0
  79. package/dist/env-expansion.js.map +1 -0
  80. package/dist/errors.d.ts.map +1 -1
  81. package/dist/errors.js +30 -3
  82. package/dist/errors.js.map +1 -1
  83. package/dist/headless.d.ts.map +1 -1
  84. package/dist/headless.js +59 -4
  85. package/dist/headless.js.map +1 -1
  86. package/dist/hooks.d.ts +8 -2
  87. package/dist/hooks.d.ts.map +1 -1
  88. package/dist/hooks.js +97 -11
  89. package/dist/hooks.js.map +1 -1
  90. package/dist/idle-eviction.d.ts.map +1 -1
  91. package/dist/idle-eviction.js +8 -1
  92. package/dist/idle-eviction.js.map +1 -1
  93. package/dist/iteration-ledger.d.ts +111 -2
  94. package/dist/iteration-ledger.d.ts.map +1 -1
  95. package/dist/iteration-ledger.js +327 -19
  96. package/dist/iteration-ledger.js.map +1 -1
  97. package/dist/iteration-limit.d.ts +5 -0
  98. package/dist/iteration-limit.d.ts.map +1 -0
  99. package/dist/iteration-limit.js +17 -0
  100. package/dist/iteration-limit.js.map +1 -0
  101. package/dist/markdown.d.ts.map +1 -1
  102. package/dist/markdown.js +32 -10
  103. package/dist/markdown.js.map +1 -1
  104. package/dist/mcp.d.ts +35 -5
  105. package/dist/mcp.d.ts.map +1 -1
  106. package/dist/mcp.js +191 -12
  107. package/dist/mcp.js.map +1 -1
  108. package/dist/memory.d.ts.map +1 -1
  109. package/dist/memory.js +4 -9
  110. package/dist/memory.js.map +1 -1
  111. package/dist/model-detection.d.ts +14 -1
  112. package/dist/model-detection.d.ts.map +1 -1
  113. package/dist/model-detection.js +307 -114
  114. package/dist/model-detection.js.map +1 -1
  115. package/dist/model-router.js +7 -7
  116. package/dist/model-router.js.map +1 -1
  117. package/dist/parallel-tools.d.ts +9 -1
  118. package/dist/parallel-tools.d.ts.map +1 -1
  119. package/dist/parallel-tools.js +6 -5
  120. package/dist/parallel-tools.js.map +1 -1
  121. package/dist/plugins.d.ts +37 -0
  122. package/dist/plugins.d.ts.map +1 -1
  123. package/dist/plugins.js +87 -0
  124. package/dist/plugins.js.map +1 -1
  125. package/dist/prevent-sleep.d.ts +10 -0
  126. package/dist/prevent-sleep.d.ts.map +1 -0
  127. package/dist/prevent-sleep.js +85 -0
  128. package/dist/prevent-sleep.js.map +1 -0
  129. package/dist/providers/anthropic.d.ts.map +1 -1
  130. package/dist/providers/anthropic.js +36 -2
  131. package/dist/providers/anthropic.js.map +1 -1
  132. package/dist/providers/bedrock.d.ts.map +1 -1
  133. package/dist/providers/bedrock.js +81 -17
  134. package/dist/providers/bedrock.js.map +1 -1
  135. package/dist/providers/compat.d.ts.map +1 -1
  136. package/dist/providers/compat.js +21 -6
  137. package/dist/providers/compat.js.map +1 -1
  138. package/dist/providers/index.d.ts.map +1 -1
  139. package/dist/providers/index.js +2 -0
  140. package/dist/providers/index.js.map +1 -1
  141. package/dist/providers/openai-compat-shims.d.ts +31 -0
  142. package/dist/providers/openai-compat-shims.d.ts.map +1 -0
  143. package/dist/providers/openai-compat-shims.js +179 -0
  144. package/dist/providers/openai-compat-shims.js.map +1 -0
  145. package/dist/providers/openai.d.ts.map +1 -1
  146. package/dist/providers/types.d.ts.map +1 -1
  147. package/dist/providers/types.js +19 -10
  148. package/dist/providers/types.js.map +1 -1
  149. package/dist/risk.d.ts.map +1 -1
  150. package/dist/risk.js +15 -5
  151. package/dist/risk.js.map +1 -1
  152. package/dist/sandbox-native.d.ts +1 -0
  153. package/dist/sandbox-native.d.ts.map +1 -1
  154. package/dist/sandbox-native.js +37 -5
  155. package/dist/sandbox-native.js.map +1 -1
  156. package/dist/scope.d.ts +10 -0
  157. package/dist/scope.d.ts.map +1 -1
  158. package/dist/scope.js +75 -15
  159. package/dist/scope.js.map +1 -1
  160. package/dist/scuttlebot/client.d.ts +83 -0
  161. package/dist/scuttlebot/client.d.ts.map +1 -0
  162. package/dist/scuttlebot/client.js +350 -0
  163. package/dist/scuttlebot/client.js.map +1 -0
  164. package/dist/scuttlebot/config.d.ts +28 -0
  165. package/dist/scuttlebot/config.d.ts.map +1 -0
  166. package/dist/scuttlebot/config.js +91 -0
  167. package/dist/scuttlebot/config.js.map +1 -0
  168. package/dist/scuttlebot/http-client.d.ts +63 -0
  169. package/dist/scuttlebot/http-client.d.ts.map +1 -0
  170. package/dist/scuttlebot/http-client.js +124 -0
  171. package/dist/scuttlebot/http-client.js.map +1 -0
  172. package/dist/scuttlebot/index.d.ts +13 -0
  173. package/dist/scuttlebot/index.d.ts.map +1 -0
  174. package/dist/scuttlebot/index.js +10 -0
  175. package/dist/scuttlebot/index.js.map +1 -0
  176. package/dist/scuttlebot/irc-client.d.ts +124 -0
  177. package/dist/scuttlebot/irc-client.d.ts.map +1 -0
  178. package/dist/scuttlebot/irc-client.js +599 -0
  179. package/dist/scuttlebot/irc-client.js.map +1 -0
  180. package/dist/skills.d.ts +19 -0
  181. package/dist/skills.d.ts.map +1 -1
  182. package/dist/skills.js +98 -10
  183. package/dist/skills.js.map +1 -1
  184. package/dist/smart-router.js +4 -4
  185. package/dist/smart-router.js.map +1 -1
  186. package/dist/storage.d.ts +18 -3
  187. package/dist/storage.d.ts.map +1 -1
  188. package/dist/storage.js +182 -14
  189. package/dist/storage.js.map +1 -1
  190. package/dist/tools.d.ts.map +1 -1
  191. package/dist/tools.js +233 -39
  192. package/dist/tools.js.map +1 -1
  193. package/dist/trust.d.ts +16 -3
  194. package/dist/trust.d.ts.map +1 -1
  195. package/dist/trust.js +23 -4
  196. package/dist/trust.js.map +1 -1
  197. package/dist/types.d.ts.map +1 -1
  198. package/dist/types.js +18 -12
  199. package/dist/types.js.map +1 -1
  200. package/dist/ui/agent.d.ts +1 -1
  201. package/dist/ui/agent.d.ts.map +1 -1
  202. package/dist/ui/agent.js +175 -121
  203. package/dist/ui/agent.js.map +1 -1
  204. package/dist/ui/chat-input.d.ts +3 -1
  205. package/dist/ui/chat-input.d.ts.map +1 -1
  206. package/dist/ui/chat-input.js +82 -17
  207. package/dist/ui/chat-input.js.map +1 -1
  208. package/dist/ui/commands.d.ts +4 -0
  209. package/dist/ui/commands.d.ts.map +1 -1
  210. package/dist/ui/commands.js +562 -39
  211. package/dist/ui/commands.js.map +1 -1
  212. package/dist/ui/completions.d.ts.map +1 -1
  213. package/dist/ui/completions.js +2 -0
  214. package/dist/ui/completions.js.map +1 -1
  215. package/dist/ui/index.d.ts.map +1 -1
  216. package/dist/ui/index.js +288 -60
  217. package/dist/ui/index.js.map +1 -1
  218. package/dist/ui/input-utils.d.ts +20 -0
  219. package/dist/ui/input-utils.d.ts.map +1 -0
  220. package/dist/ui/input-utils.js +35 -0
  221. package/dist/ui/input-utils.js.map +1 -0
  222. package/dist/ui/messages.d.ts +6 -2
  223. package/dist/ui/messages.d.ts.map +1 -1
  224. package/dist/ui/messages.js +42 -11
  225. package/dist/ui/messages.js.map +1 -1
  226. package/dist/ui/modals.d.ts +21 -1
  227. package/dist/ui/modals.d.ts.map +1 -1
  228. package/dist/ui/modals.js +67 -5
  229. package/dist/ui/modals.js.map +1 -1
  230. package/dist/ui/status-bar.d.ts +4 -1
  231. package/dist/ui/status-bar.d.ts.map +1 -1
  232. package/dist/ui/status-bar.js +12 -1
  233. package/dist/ui/status-bar.js.map +1 -1
  234. package/dist/ui/types.d.ts +3 -0
  235. package/dist/ui/types.d.ts.map +1 -1
  236. package/package.json +4 -7
  237. package/dist/completion.d.ts +0 -75
  238. package/dist/completion.d.ts.map +0 -1
  239. package/dist/completion.js +0 -234
  240. package/dist/completion.js.map +0 -1
  241. package/dist/keyboard.d.ts +0 -57
  242. package/dist/keyboard.d.ts.map +0 -1
  243. package/dist/keyboard.js +0 -265
  244. package/dist/keyboard.js.map +0 -1
package/dist/tools.js CHANGED
@@ -6,6 +6,8 @@
6
6
  import { spawn } from 'child_process';
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
+ import * as os from 'os';
10
+ import { StringDecoder } from 'string_decoder';
9
11
  import * as sandbox from './sandbox.js';
10
12
  import * as nativeSandbox from './sandbox-native.js';
11
13
  import { getAgtermTools, isAgtermTool, executeAgtermTool } from './agents/index.js';
@@ -15,6 +17,7 @@ import config from './config.js';
15
17
  import { applySkin, applyPalette, listSkins, listPalettes } from './hud/api.js';
16
18
  import { listCompanions } from './companions.js';
17
19
  import { generateDiff as generateFileDiff } from './diff.js';
20
+ import { scuttlebotClient } from './scuttlebot/index.js';
18
21
  /**
19
22
  * Available tools for the agent
20
23
  */
@@ -274,7 +277,7 @@ export const TOOLS = [
274
277
 
275
278
  CONFIGURABLE SETTINGS:
276
279
  - defaultProvider: AI provider (anthropic, google, openai, together, openrouter, groq, fireworks, mistral, ollama, ai21, huggingface, litellm, bedrock, auto)
277
- - defaultModel: Model name string (provider-specific, e.g. "claude-sonnet-4-20250514", "gemini-2.0-flash", "gpt-4o")
280
+ - defaultModel: Model name string (provider-specific, e.g. "claude-sonnet-4-6", "gemini-2.0-flash", "gpt-4o")
278
281
  - persona: Agent persona style (calliope, muse, minimal)
279
282
  - maxIterations: Max agent loop iterations (0 = unlimited)
280
283
  - maxIterationTime: Max seconds per iteration (0 = no limit, default: 600)
@@ -368,14 +371,23 @@ function validatePath(filePath, cwd) {
368
371
  if (filePath.includes('\0')) {
369
372
  throw new Error(`Invalid path: contains null bytes`);
370
373
  }
371
- // Check raw input for path traversal attempts before resolution
374
+ // Check raw input for path traversal attempts before resolution.
375
+ // The scope manager is the single source of truth — no /tmp escape hatch (#139).
372
376
  if (filePath.includes('..')) {
373
377
  const resolved = path.resolve(cwd, filePath);
374
378
  const normalizedCwd = path.resolve(cwd);
375
- if (!resolved.startsWith(normalizedCwd + path.sep) && resolved !== normalizedCwd && !resolved.startsWith('/tmp/') && resolved !== '/tmp') {
379
+ if (!resolved.startsWith(normalizedCwd + path.sep) && resolved !== normalizedCwd) {
376
380
  throw new Error(`Path traversal detected: ${filePath} resolves outside allowed scope`);
377
381
  }
378
382
  }
383
+ // Block tool access to the CLI's own state directory (~/.calliope-cli):
384
+ // hooks, plugins, skills, trust, and MCP server configs there are a
385
+ // code-execution / trust foothold if the agent can plant or read them (#141).
386
+ const cliStateDir = path.join(os.homedir(), '.calliope-cli');
387
+ const absResolved = path.resolve(cwd, filePath);
388
+ if (absResolved === cliStateDir || absResolved.startsWith(cliStateDir + path.sep)) {
389
+ throw new Error(`Refusing tool access inside the Calliope state directory (${cliStateDir}); hooks/plugins/skills/trust there are protected`);
390
+ }
379
391
  // Primary validation via scope manager
380
392
  const validated = scopeValidatePath(filePath, cwd);
381
393
  return validated;
@@ -385,6 +397,12 @@ function validatePath(filePath, cwd) {
385
397
  */
386
398
  export async function executeTool(toolCall, cwd, timeout = 60000, onOutput) {
387
399
  const { id, name, arguments: args } = toolCall;
400
+ // Mirror tool call to scuttlebot
401
+ if (scuttlebotClient.isEnabled()) {
402
+ await scuttlebotClient.mirrorToolCall(name, args).catch(() => {
403
+ // Silently fail - don't interrupt tool execution
404
+ });
405
+ }
388
406
  // Handle agent tools
389
407
  if (isAgtermTool(name)) {
390
408
  return executeAgtermTool(toolCall, cwd);
@@ -573,7 +591,7 @@ export async function executeTool(toolCall, cwd, timeout = 60000, onOutput) {
573
591
  'companionIntensity', 'useEmojis', 'diffStyle', 'borderStyle', 'bannerStyle',
574
592
  'circuitBreakersEnabled', 'sandboxMode',
575
593
  'smartRoutingEnabled', 'smartRoutingCostSensitivity',
576
- 'recordSessions', 'recordingRetentionDays',
594
+ 'recordSessions', 'recordingRetentionDays', 'sessionLogLimit',
577
595
  'awsRegion', 'awsProfile',
578
596
  ]);
579
597
  if (!SAFE_CONFIG_KEYS.has(key)) {
@@ -686,6 +704,12 @@ export async function executeTool(toolCall, cwd, timeout = 60000, onOutput) {
686
704
  *
687
705
  * Patterns are tested against the normalized command (see normalizeCommand())
688
706
  * to defeat common bypass techniques like quoting, env prefixes, and subshells.
707
+ *
708
+ * SECURITY NOTE (#132): This denylist is ADVISORY / defense-in-depth only. It is
709
+ * fundamentally unwinnable to perfectly screen an arbitrary `bash -c` string, so
710
+ * treat this as a best-effort tripwire, NOT a security boundary. The real control
711
+ * is the native sandbox (see src/sandbox-native.ts); when no sandbox is available
712
+ * the shell tool fails closed (see shouldUseNativeSandbox / executeShell).
689
713
  */
690
714
  const BLOCKED_COMMANDS = [
691
715
  /^sudo\s/,
@@ -738,12 +762,29 @@ function normalizeCommand(command) {
738
762
  return cmd.trim();
739
763
  }
740
764
  /**
741
- * Check a command (and all sub-commands separated by ; or &&/||) against
742
- * the blocklist. Returns the matching pattern source string, or null if allowed.
765
+ * Split a shell command into sub-commands on every separator so anchored deny
766
+ * patterns (^sudo, ^su, ^rm -rf /, ...) are tested at the start of each fragment.
767
+ *
768
+ * Process two-char operators (&&, ||) before single & / | so the singles do not
769
+ * shred the two-char operators they are part of. Newline is also a separator.
770
+ * normalizeCommand() strips quotes for matching, so quoted separators are
771
+ * intentionally not special-cased here (advisory blocklist, #132).
772
+ */
773
+ function splitSubCommands(command) {
774
+ return command
775
+ // two-char logical operators first
776
+ .split(/\s*(?:&&|\|\|)\s*/)
777
+ // then single separators: ; & | and newline (also a trailing & background)
778
+ .flatMap((part) => part.split(/\s*[;&|\n]\s*/));
779
+ }
780
+ /**
781
+ * Check a command (and all sub-commands separated by ; && || & | or newline)
782
+ * against the blocklist. Returns the matching pattern source string, or null if
783
+ * allowed.
743
784
  */
744
785
  function matchesBlocklist(command) {
745
786
  // Split on command separators to check each sub-command
746
- const subCommands = command.split(/\s*(?:;|&&|\|\|)\s*/);
787
+ const subCommands = splitSubCommands(command);
747
788
  for (const sub of subCommands) {
748
789
  const normalized = normalizeCommand(sub);
749
790
  for (const pattern of BLOCKED_COMMANDS) {
@@ -853,10 +894,18 @@ async function executeShell(command, cwd, timeout, onOutput) {
853
894
  if (sandboxDecision === 'require' && !nativeSandbox.isNativeSandboxAvailable()) {
854
895
  return 'Error: Native sandbox required (sandboxMode=native) but not available on this platform.';
855
896
  }
897
+ // 'auto' is best-effort: when no native backend exists (e.g. Linux/Windows
898
+ // without Seatbelt/Landlock) the command runs unsandboxed rather than failing,
899
+ // so shell execution keeps working on every platform. Users who require
900
+ // enforcement set sandboxMode=native (fail-closed above) or sandboxMode=docker.
901
+ // The sandbox hardening (#133 — network off by default, restricted reads)
902
+ // still applies whenever a sandbox IS active ('use'/'require'/docker).
856
903
  if (sandboxDecision === 'use' || sandboxDecision === 'require') {
904
+ // Network is OFF by default; opt in via CALLIOPE_SHELL_NETWORK=1 (#133).
905
+ const networkEnabled = process.env.CALLIOPE_SHELL_NETWORK === '1';
857
906
  const result = await nativeSandbox.executeInNativeSandbox(command, cwd, {
858
907
  timeout,
859
- networkEnabled: true,
908
+ networkEnabled,
860
909
  });
861
910
  // Shell tool output is transparent — same format as unsandboxed execution
862
911
  let output = result.stdout + (result.stderr ? `\nstderr: ${result.stderr}` : '');
@@ -940,15 +989,12 @@ async function readFile(filePath, cwd) {
940
989
  throw new Error(`File too large (${Math.round(stats.size / 1024)}KB). Max 1MB.`);
941
990
  }
942
991
  const content = fs.readFileSync(absPath, 'utf-8');
943
- // Inline file preview header (#119)
944
- const PREVIEW_CAP = 20;
945
- const allLines = content.split('\n');
946
- const totalLines = allLines.length;
947
- const previewLines = allLines.slice(0, PREVIEW_CAP);
992
+ // Inline file preview header (#119). The header carries the line count;
993
+ // the full content follows once — no preview/footer, those duplicated the
994
+ // first 20 lines on every read and cost ~20% extra tokens on short files.
995
+ const totalLines = content.split('\n').length;
948
996
  const header = `[file: ${filePath} \u2014 ${totalLines} line${totalLines !== 1 ? 's' : ''}]\n${'─'.repeat(40)}`;
949
- const previewBody = previewLines.join('\n');
950
- const footer = totalLines > PREVIEW_CAP ? `\n... (${totalLines - PREVIEW_CAP} more lines)` : '';
951
- return `${header}\n${previewBody}${footer}\n\n${content}`;
997
+ return `${header}\n${content}`;
952
998
  }
953
999
  /**
954
1000
  * Generate a simple line-diff between old and new content
@@ -1121,6 +1167,8 @@ function listFilesRecursive(dir, prefix, depth) {
1121
1167
  for (const entry of entries.slice(0, 50)) {
1122
1168
  if (entry.name.startsWith('.'))
1123
1169
  continue; // Skip hidden files
1170
+ if (entry.isDirectory() && WALK_IGNORED_DIRS.has(entry.name))
1171
+ continue;
1124
1172
  const entryPath = path.join(dir, entry.name);
1125
1173
  if (entry.isDirectory()) {
1126
1174
  lines.push(`${prefix}📁 ${entry.name}/`);
@@ -1277,44 +1325,74 @@ async function webSearch(query, numResults) {
1277
1325
  /**
1278
1326
  * Execute git commands safely
1279
1327
  */
1328
+ // Tokenize git args supporting simple shell-style quoting so commit messages
1329
+ // like -m "fix: thing" parse into two tokens.
1330
+ function parseGitArgs(args) {
1331
+ const tokens = [];
1332
+ const re = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+)/g;
1333
+ let m;
1334
+ while ((m = re.exec(args)) !== null) {
1335
+ tokens.push(m[1] ?? m[2] ?? m[3]);
1336
+ }
1337
+ return tokens;
1338
+ }
1339
+ // Single-quote each token so the final string is safe to pass through bash -c
1340
+ // (what executeShell does). A single quote inside a single-quoted string is
1341
+ // escaped as '\'' .
1342
+ function shellEscape(s) {
1343
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1344
+ }
1345
+ // Git-specific flags that cause arbitrary command execution even when passed
1346
+ // without a shell (e.g. over SSH via upload-pack/receive-pack). Blocklisted
1347
+ // because the shell-metachar strip used before did not catch them.
1348
+ const DANGEROUS_GIT_FLAG_RE = /^(--upload-pack|--receive-pack|--exec|--config-env)(=|$)/;
1280
1349
  async function executeGit(operation, args, cwd) {
1281
1350
  const allowedOps = ['status', 'diff', 'log', 'branch', 'add', 'commit', 'push', 'pull', 'stash'];
1282
1351
  if (!allowedOps.includes(operation)) {
1283
1352
  return `Error: Unknown git operation: ${operation}. Allowed: ${allowedOps.join(', ')}`;
1284
1353
  }
1285
- // Sanitize args to prevent command injection via shell metacharacters
1286
- const safeArgs = args.replace(/[;&|`$(){}!#\n\r]/g, '');
1354
+ const tokens = parseGitArgs(args);
1355
+ for (const tok of tokens) {
1356
+ if (DANGEROUS_GIT_FLAG_RE.test(tok)) {
1357
+ return `Error: git flag "${tok.split('=')[0]}" is not allowed (RCE risk)`;
1358
+ }
1359
+ // ext:: remote protocol runs an arbitrary command on the client side.
1360
+ if (tok.startsWith('ext::')) {
1361
+ return 'Error: git "ext::" remote protocol is not allowed (RCE risk)';
1362
+ }
1363
+ }
1364
+ const quoted = tokens.map(shellEscape).join(' ');
1287
1365
  let command;
1288
1366
  switch (operation) {
1289
1367
  case 'status':
1290
1368
  command = 'git status --short';
1291
1369
  break;
1292
1370
  case 'diff':
1293
- command = `git diff ${safeArgs}`.trim();
1371
+ command = `git diff ${quoted}`.trim();
1294
1372
  break;
1295
1373
  case 'log':
1296
- command = `git log --oneline -20 ${safeArgs}`.trim();
1374
+ command = `git log --oneline -20 ${quoted}`.trim();
1297
1375
  break;
1298
1376
  case 'branch':
1299
- command = `git branch ${safeArgs}`.trim();
1377
+ command = `git branch ${quoted}`.trim();
1300
1378
  break;
1301
1379
  case 'add':
1302
- command = `git add ${safeArgs || '.'}`.trim();
1380
+ command = tokens.length ? `git add ${quoted}` : 'git add .';
1303
1381
  break;
1304
1382
  case 'commit':
1305
- if (!safeArgs.includes('-m')) {
1383
+ if (!tokens.includes('-m')) {
1306
1384
  return 'Error: commit requires -m "message"';
1307
1385
  }
1308
- command = `git commit ${safeArgs}`.trim();
1386
+ command = `git commit ${quoted}`.trim();
1309
1387
  break;
1310
1388
  case 'push':
1311
- command = `git push ${safeArgs}`.trim();
1389
+ command = `git push ${quoted}`.trim();
1312
1390
  break;
1313
1391
  case 'pull':
1314
- command = `git pull ${safeArgs}`.trim();
1392
+ command = `git pull ${quoted}`.trim();
1315
1393
  break;
1316
1394
  case 'stash':
1317
- command = `git stash ${safeArgs}`.trim();
1395
+ command = `git stash ${quoted}`.trim();
1318
1396
  break;
1319
1397
  default:
1320
1398
  return `Unknown operation: ${operation}`;
@@ -1468,7 +1546,56 @@ function globToRegex(pattern) {
1468
1546
  /**
1469
1547
  * Recursively walk a directory and collect all file paths relative to the base.
1470
1548
  */
1471
- function walkDir(dir, base, results) {
1549
+ // Directories that are almost always noise when searching a project tree.
1550
+ // Keeping this list small and hardcoded (rather than parsing .gitignore) avoids
1551
+ // both performance cliffs on large monorepos and accidental scans of vendored
1552
+ // secrets in node_modules. Users who need to search these can use the shell tool.
1553
+ const WALK_IGNORED_DIRS = new Set([
1554
+ '.git',
1555
+ 'node_modules',
1556
+ 'dist',
1557
+ 'build',
1558
+ '.next',
1559
+ '.nuxt',
1560
+ '.turbo',
1561
+ '.cache',
1562
+ 'coverage',
1563
+ '.venv',
1564
+ 'venv',
1565
+ '__pycache__',
1566
+ 'target', // rust
1567
+ '.gradle',
1568
+ '.idea',
1569
+ '.vscode',
1570
+ ]);
1571
+ /** Maximum directory recursion depth before walkDir stops descending (#154). */
1572
+ const WALK_MAX_DEPTH = 40;
1573
+ /** Maximum number of entries walkDir will collect before it stops (#154). */
1574
+ const WALK_MAX_ENTRIES = 100000;
1575
+ /**
1576
+ * Recursively collect file paths under `dir` (relative to `base`).
1577
+ *
1578
+ * Hardened (#154): bounded recursion depth, a per-walk Set of visited real
1579
+ * directory paths to guard against directory cycles (bind mounts / hardlinks /
1580
+ * any future symlink-follow), and a cap on the total number of collected
1581
+ * entries. `depth` and `visited` are internal accumulators.
1582
+ */
1583
+ function walkDir(dir, base, results, depth = 0, visited = new Set()) {
1584
+ if (depth > WALK_MAX_DEPTH)
1585
+ return;
1586
+ if (results.length >= WALK_MAX_ENTRIES)
1587
+ return;
1588
+ // Guard against directory cycles by tracking canonical (real) paths.
1589
+ let realDir;
1590
+ try {
1591
+ realDir = fs.realpathSync(dir);
1592
+ }
1593
+ catch {
1594
+ return;
1595
+ }
1596
+ if (visited.has(realDir))
1597
+ return;
1598
+ visited.add(realDir);
1472
1599
  let entries;
1473
1600
  try {
1474
1601
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -1477,10 +1604,14 @@ function walkDir(dir, base, results) {
1477
1604
  return;
1478
1605
  }
1479
1606
  for (const entry of entries) {
1607
+ if (results.length >= WALK_MAX_ENTRIES)
1608
+ return;
1609
+ if (entry.isDirectory() && WALK_IGNORED_DIRS.has(entry.name))
1610
+ continue;
1480
1611
  const fullPath = path.join(dir, entry.name);
1481
1612
  const relPath = path.relative(base, fullPath);
1482
1613
  if (entry.isDirectory()) {
1483
- walkDir(fullPath, base, results);
1614
+ walkDir(fullPath, base, results, depth + 1, visited);
1484
1615
  }
1485
1616
  else {
1486
1617
  results.push(relPath);
@@ -1516,6 +1647,73 @@ async function globFiles(pattern, searchCwd) {
1516
1647
  }
1517
1648
  return matched.join('\n');
1518
1649
  }
1650
+ /**
1651
+ * Scan a single file line-by-line and push matching lines into `results`,
1652
+ * without holding the entire file in memory (#154).
1653
+ *
1654
+ * Reads the file in fixed-size chunks, emits complete lines as they are found,
1655
+ * and stops as soon as `results` reaches `maxResults`.
1656
+ */
1657
+ function grepFileStreaming(filePath, regex, relPath, results, maxResults) {
1658
+ const CHUNK_SIZE = 64 * 1024;
1659
+ let fd;
1660
+ try {
1661
+ fd = fs.openSync(filePath, 'r');
1662
+ }
1663
+ catch {
1664
+ return;
1665
+ }
1666
+ try {
1667
+ // StringDecoder buffers partial multi-byte UTF-8 sequences that straddle a
1668
+ // chunk boundary, so lines decode correctly across reads.
1669
+ const decoder = new StringDecoder('utf-8');
1670
+ const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
1671
+ let leftover = '';
1672
+ let lineNo = 0;
1673
+ let bytesRead;
1674
+ try {
1675
+ do {
1676
+ bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, null);
1677
+ if (bytesRead <= 0)
1678
+ break;
1679
+ const text = leftover + decoder.write(buffer.subarray(0, bytesRead));
1680
+ const parts = text.split('\n');
1681
+ // The last element may be a partial line; carry it to the next chunk.
1682
+ leftover = parts.pop() ?? '';
1683
+ for (const line of parts) {
1684
+ lineNo++;
1685
+ if (regex.test(line)) {
1686
+ results.push(`${relPath}:${lineNo}: ${line}`);
1687
+ if (results.length >= maxResults)
1688
+ return;
1689
+ }
1690
+ }
1691
+ } while (bytesRead > 0);
1692
+ }
1693
+ catch {
1694
+ // Unreadable file (e.g. EISDIR, binary read error) — skip it, mirroring
1695
+ // the previous readFileSync try/catch behavior.
1696
+ return;
1697
+ }
1698
+ // Flush any bytes the decoder was still holding.
1699
+ leftover += decoder.end();
1700
+ // Final partial line (file not ending in newline).
1701
+ if (leftover.length > 0) {
1702
+ lineNo++;
1703
+ if (regex.test(leftover) && results.length < maxResults) {
1704
+ results.push(`${relPath}:${lineNo}: ${leftover}`);
1705
+ }
1706
+ }
1707
+ }
1708
+ finally {
1709
+ try {
1710
+ fs.closeSync(fd);
1711
+ }
1712
+ catch {
1713
+ /* ignore */
1714
+ }
1715
+ }
1716
+ }
1519
1717
  /**
1520
1718
  * Search file contents using a regex pattern (or literal string fallback).
1521
1719
  */
@@ -1568,25 +1766,21 @@ async function grepFiles(pattern, searchPath, cwd, globPattern, caseInsensitive)
1568
1766
  for (const filePath of filesToSearch) {
1569
1767
  if (results.length >= MAX_RESULTS)
1570
1768
  break;
1571
- let content;
1572
1769
  try {
1573
1770
  const fileStat = fs.statSync(filePath);
1771
+ if (!fileStat.isFile())
1772
+ continue; // skip dirs / symlinked dirs / sockets
1574
1773
  if (fileStat.size > 5 * 1024 * 1024)
1575
1774
  continue; // skip files > 5MB
1576
- content = fs.readFileSync(filePath, 'utf-8');
1577
1775
  }
1578
1776
  catch {
1579
1777
  continue;
1580
1778
  }
1581
- const lines = content.split('\n');
1582
1779
  const relPath = path.relative(cwd, filePath);
1583
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1584
- if (results.length >= MAX_RESULTS)
1585
- break;
1586
- if (regex.test(lines[lineIdx])) {
1587
- results.push(`${relPath}:${lineIdx + 1}: ${lines[lineIdx]}`);
1588
- }
1589
- }
1780
+ // Scan line-by-line without holding the whole file in memory (#154):
1781
+ // stream the file and only test each line as it is produced, breaking early
1782
+ // once MAX_RESULTS is reached.
1783
+ grepFileStreaming(filePath, regex, relPath, results, MAX_RESULTS);
1590
1784
  }
1591
1785
  if (results.length === 0) {
1592
1786
  return 'No matches found';